mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
chore: apply prettier on the entire project
This commit is contained in:
@@ -2,9 +2,35 @@
|
||||
* Admin integration tests.
|
||||
* Covers ADMIN-001 to ADMIN-022.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createInviteToken,
|
||||
createTrip,
|
||||
createBudgetItem,
|
||||
createJourney,
|
||||
createJourneyEntry,
|
||||
addJourneyContributor,
|
||||
addTripPhoto,
|
||||
createCategory,
|
||||
createTag,
|
||||
createTodoItem,
|
||||
createMcpToken,
|
||||
createBucketListItem,
|
||||
createVisitedCountry,
|
||||
createCollabNote,
|
||||
addTripMember,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +43,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +79,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -70,9 +104,7 @@ describe('Admin access control', () => {
|
||||
it('ADMIN-022 — non-admin cannot access admin routes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/admin/users').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -92,9 +124,7 @@ describe('Admin user management', () => {
|
||||
createUser(testDb);
|
||||
createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/users').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.users).toHaveLength(3);
|
||||
});
|
||||
@@ -137,9 +167,7 @@ describe('Admin user management', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${user.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/admin/users/${user.id}`).set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
@@ -157,10 +185,14 @@ describe('Admin user management', () => {
|
||||
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||
// (trip survives deletion; only invited_by should become NULL)
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)')
|
||||
.run(otherTrip.id, thirdUser.id, target.id);
|
||||
|
||||
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-admin-test', ?)").run(otherTrip.id, target.id);
|
||||
testDb
|
||||
.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-admin-test', ?)")
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||
@@ -174,35 +206,43 @@ describe('Admin user management', () => {
|
||||
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_share_tokens: target created a share token for otherUser's journey
|
||||
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-admin-test', ?)").run(otherJourney.id, target.id);
|
||||
testDb
|
||||
.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-admin-test', ?)")
|
||||
.run(otherJourney.id, target.id);
|
||||
|
||||
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||
const sentNotif = testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, target.id, otherUser.id);
|
||||
const sentNotif = testDb
|
||||
.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')",
|
||||
)
|
||||
.run(otherTrip.id, target.id, otherUser.id);
|
||||
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||
testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, otherUser.id, target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')",
|
||||
)
|
||||
.run(otherTrip.id, otherUser.id, target.id);
|
||||
|
||||
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||
testDb.prepare(
|
||||
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||
).run(target.id, Date.now());
|
||||
testDb
|
||||
.prepare("INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)")
|
||||
.run(target.id, Date.now());
|
||||
|
||||
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||
const ownedJourney = createJourney(testDb, target.id);
|
||||
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||
|
||||
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||
const fileRow = testDb.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const fileRow = testDb
|
||||
.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)",
|
||||
)
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||
const trekPhotoRow = testDb.prepare(
|
||||
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-admin-test', ?)"
|
||||
).run(target.id);
|
||||
const trekPhotoRow = testDb
|
||||
.prepare("INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-admin-test', ?)")
|
||||
.run(target.id);
|
||||
|
||||
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-admin', 'immich');
|
||||
@@ -224,30 +264,34 @@ describe('Admin user management', () => {
|
||||
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||
|
||||
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||
const packBagRow = testDb.prepare(
|
||||
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const packBagRow = testDb
|
||||
.prepare("INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)")
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||
createMcpToken(testDb, target.id);
|
||||
|
||||
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-admin-test', ?, 'App', 'cid-admin-test', 'h')"
|
||||
).run(otherUser.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-admin-test', ?, 'ath-admin', 'rth-admin', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||
).run(target.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-admin-test', ?)"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-admin-test', ?, 'App', 'cid-admin-test', 'h')",
|
||||
)
|
||||
.run(otherUser.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-admin-test', ?, 'ath-admin', 'rth-admin', datetime('now','+1 hour'), datetime('now','+30 days'))",
|
||||
)
|
||||
.run(target.id);
|
||||
testDb.prepare("INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-admin-test', ?)").run(target.id);
|
||||
|
||||
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||
const vacayPlanRow = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(target.id);
|
||||
|
||||
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
const otherVacayPlanRow = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherUser.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)')
|
||||
.run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
|
||||
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||
createBucketListItem(testDb, target.id);
|
||||
@@ -256,14 +300,16 @@ describe('Admin user management', () => {
|
||||
createVisitedCountry(testDb, target.id, 'JP');
|
||||
|
||||
// visited_regions.user_id (CASCADE): target has visited a region
|
||||
testDb.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
// packing_templates.created_by (CASCADE): target created a packing template
|
||||
const packTemplateRow = testDb.prepare(
|
||||
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||
).run(target.id);
|
||||
const packTemplateRow = testDb
|
||||
.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)")
|
||||
.run(target.id);
|
||||
|
||||
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||
createInviteToken(testDb, { created_by: target.id });
|
||||
@@ -275,59 +321,99 @@ describe('Admin user management', () => {
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||
|
||||
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||
testDb.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-admin', datetime('now','+1 hour'))"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-admin', datetime('now','+1 hour'))",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
// audit_log.user_id (SET NULL): target performed an audited action
|
||||
const auditRow = testDb.prepare(
|
||||
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||
).run(target.id);
|
||||
const auditRow = testDb
|
||||
.prepare("INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')")
|
||||
.run(target.id);
|
||||
|
||||
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${target.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/admin/users/${target.id}`).set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||
// trip_members row survives but invited_by is now NULL
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||
expect(
|
||||
(
|
||||
testDb
|
||||
.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(otherTrip.id, thirdUser.id) as any
|
||||
).invited_by,
|
||||
).toBeNull();
|
||||
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
(testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any)
|
||||
.paid_by_user_id,
|
||||
).toBeNull();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
|
||||
.get(otherJourney.id, target.id),
|
||||
).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// sent notification survives but sender_id becomes NULL
|
||||
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any)
|
||||
.sender_id,
|
||||
).toBeNull();
|
||||
// received notification is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||
// notice dismissals are cascade-deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'")
|
||||
.get(target.id),
|
||||
).toBeUndefined();
|
||||
// owned journey and its entries are cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||
// uploaded file survives but uploaded_by is now NULL
|
||||
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any)
|
||||
.uploaded_by,
|
||||
).toBeNull();
|
||||
// trek_photos row survives but owner_id is now NULL
|
||||
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any)
|
||||
.owner_id,
|
||||
).toBeNull();
|
||||
// trip_photos row for target is cascade-deleted
|
||||
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id),
|
||||
).toBeUndefined();
|
||||
// owned trip is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||
// trip membership on others' trips is removed
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id),
|
||||
).toBeUndefined();
|
||||
// category survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id,
|
||||
).toBeNull();
|
||||
// tag is deleted
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||
// todo assigned_user_id is NULL
|
||||
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id,
|
||||
).toBeNull();
|
||||
// packing bag survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id,
|
||||
).toBeNull();
|
||||
// MCP tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// OAuth tokens and consents are deleted
|
||||
@@ -336,34 +422,52 @@ describe('Admin user management', () => {
|
||||
// owned vacay plan is deleted
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||
// vacay plan membership on others' plans is removed
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||||
.get(otherVacayPlanRow.lastInsertRowid, target.id),
|
||||
).toBeUndefined();
|
||||
// bucket list items are deleted
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// travel history is deleted
|
||||
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?')
|
||||
.get(target.id, 'JP'),
|
||||
).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// packing template is deleted
|
||||
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid),
|
||||
).toBeUndefined();
|
||||
// invite tokens created by target are deleted
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// collab content is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id),
|
||||
).toBeUndefined();
|
||||
// user settings are deleted
|
||||
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM settings WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// password reset tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// audit log entry survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id,
|
||||
).toBeNull();
|
||||
// notification channel preferences are deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare(
|
||||
"SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'",
|
||||
)
|
||||
.get(target.id),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${admin.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/admin/users/${admin.id}`).set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -423,9 +527,7 @@ describe('System stats', () => {
|
||||
it('ADMIN-007 — GET /admin/stats returns system statistics', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/stats')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/stats').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('totalUsers');
|
||||
expect(res.body).toHaveProperty('totalTrips');
|
||||
@@ -440,9 +542,7 @@ describe('Permissions management', () => {
|
||||
it('ADMIN-008 — GET /admin/permissions returns permission config', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/permissions').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('permissions');
|
||||
expect(Array.isArray(res.body.permissions)).toBe(true);
|
||||
@@ -460,9 +560,7 @@ describe('Permissions management', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Re-fetch and verify the change persisted
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const getRes = await request(app).get('/api/admin/permissions').set('Cookie', authCookie(admin.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
const tripCreatePerm = getRes.body.permissions.find((p: any) => p.key === 'trip_create');
|
||||
expect(tripCreatePerm).toBeDefined();
|
||||
@@ -488,9 +586,7 @@ describe('Audit log', () => {
|
||||
it('ADMIN-009 — GET /admin/audit-log returns log entries', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/audit-log')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/audit-log').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
@@ -514,10 +610,7 @@ describe('Addon management', () => {
|
||||
it('ADMIN-012 — PUT /admin/addons/:id re-enables an addon', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/addons/atlas')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ enabled: false });
|
||||
await request(app).put('/api/admin/addons/atlas').set('Cookie', authCookie(admin.id)).send({ enabled: false });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/admin/addons/atlas')
|
||||
@@ -535,10 +628,7 @@ describe('Invite token management', () => {
|
||||
it('ADMIN-013 — POST /admin/invites creates an invite token', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/invites')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ max_uses: 5 });
|
||||
const res = await request(app).post('/api/admin/invites').set('Cookie', authCookie(admin.id)).send({ max_uses: 5 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.invite.token).toBeDefined();
|
||||
});
|
||||
@@ -547,9 +637,7 @@ describe('Invite token management', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { created_by: admin.id });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/invites/${invite.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/admin/invites/${invite.id}`).set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -611,9 +699,7 @@ describe('JWT rotation', () => {
|
||||
it('ADMIN-018 — POST /admin/rotate-jwt-secret rotates the JWT secret', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/rotate-jwt-secret')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/admin/rotate-jwt-secret').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -647,9 +733,7 @@ describe('Packing template CRUD (full)', () => {
|
||||
it('ADMIN-019b — GET /admin/packing-templates/:id returns 404 for missing', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/packing-templates/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/packing-templates/99999').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -779,9 +863,7 @@ describe('MCP token management', () => {
|
||||
it('ADMIN-023 — GET /admin/mcp-tokens returns list', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/mcp-tokens')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/mcp-tokens').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.tokens)).toBe(true);
|
||||
});
|
||||
@@ -789,9 +871,7 @@ describe('MCP token management', () => {
|
||||
it('ADMIN-024 — DELETE /admin/mcp-tokens/:id returns 404 for missing token', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/admin/mcp-tokens/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete('/api/admin/mcp-tokens/99999').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -804,9 +884,7 @@ describe('OAuth sessions', () => {
|
||||
it('ADMIN-025 — GET /admin/oauth-sessions returns list', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/oauth-sessions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/oauth-sessions').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.sessions)).toBe(true);
|
||||
});
|
||||
@@ -814,9 +892,7 @@ describe('OAuth sessions', () => {
|
||||
it('ADMIN-026 — DELETE /admin/oauth-sessions/:id returns 404 for missing session', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/admin/oauth-sessions/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete('/api/admin/oauth-sessions/99999').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -829,9 +905,7 @@ describe('OIDC settings', () => {
|
||||
it('ADMIN-027 — GET /admin/oidc returns OIDC configuration', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/oidc')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/oidc').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -855,9 +929,7 @@ describe('Demo baseline', () => {
|
||||
it('ADMIN-029 — POST /admin/save-demo-baseline returns 404 when DEMO_MODE is not set', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/save-demo-baseline')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/admin/save-demo-baseline').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -880,9 +952,7 @@ describe('GitHub releases and version check', () => {
|
||||
it('ADMIN-031 — GET /admin/version-check returns version info', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/version-check')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/version-check').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('current');
|
||||
});
|
||||
@@ -896,9 +966,7 @@ describe('Admin list routes', () => {
|
||||
it('ADMIN-032 — GET /admin/invites lists invites', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/invites')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/invites').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.invites)).toBe(true);
|
||||
});
|
||||
@@ -906,18 +974,14 @@ describe('Admin list routes', () => {
|
||||
it('ADMIN-033 — GET /admin/bag-tracking returns bag tracking setting', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/bag-tracking')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/bag-tracking').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ADMIN-034 — GET /admin/packing-templates lists templates', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/packing-templates').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.templates)).toBe(true);
|
||||
});
|
||||
@@ -925,9 +989,7 @@ describe('Admin list routes', () => {
|
||||
it('ADMIN-035 — GET /admin/addons lists addons', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/addons')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/admin/addons').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Day Assignments integration tests.
|
||||
* Covers ASSIGN-001 to ASSIGN-009.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Atlas integration tests.
|
||||
* Covers ATLAS-001 to ATLAS-008.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -66,9 +82,7 @@ describe('Atlas stats', () => {
|
||||
it('ATLAS-001 — GET /api/atlas/stats returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('countries');
|
||||
expect(res.body).toHaveProperty('stats');
|
||||
@@ -77,9 +91,7 @@ describe('Atlas stats', () => {
|
||||
it('ATLAS-002 — GET /api/atlas/country/:code returns places in country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/country/FR')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/country/FR').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.places)).toBe(true);
|
||||
});
|
||||
@@ -89,16 +101,12 @@ describe('Mark/unmark country', () => {
|
||||
it('ATLAS-003 — POST /country/:code/mark marks country as visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/country/DE/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post('/api/addons/atlas/country/DE/mark').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it appears in visited countries
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const stats = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
});
|
||||
@@ -106,13 +114,9 @@ describe('Mark/unmark country', () => {
|
||||
it('ATLAS-004 — DELETE /country/:code/mark unmarks country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/country/IT/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/atlas/country/IT/mark').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/atlas/country/IT/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/addons/atlas/country/IT/mark').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -125,7 +129,7 @@ describe('Bucket list', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Machu Picchu', country_code: 'PE', lat: -13.1631, lng: -72.5450 });
|
||||
.send({ name: 'Machu Picchu', country_code: 'PE', lat: -13.1631, lng: -72.545 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('Machu Picchu');
|
||||
});
|
||||
@@ -148,9 +152,7 @@ describe('Bucket list', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Santorini', country_code: 'GR' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/bucket-list').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
@@ -181,24 +183,18 @@ describe('Bucket list', () => {
|
||||
.send({ name: 'Tokyo' });
|
||||
const id = create.body.item.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/addons/atlas/bucket-list/${id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete(`/api/addons/atlas/bucket-list/${id}`).set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get('/api/addons/atlas/bucket-list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get('/api/addons/atlas/bucket-list').set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ATLAS-008 — DELETE non-existent item returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/atlas/bucket-list/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/addons/atlas/bucket-list/99999').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -246,9 +242,7 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const stats = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
@@ -283,9 +277,7 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Bayern', country_code: 'DE' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('regions');
|
||||
@@ -304,16 +296,12 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete('/api/addons/atlas/region/DE-NW/mark').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
|
||||
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||
const codes = (deRegions || []).map((r: any) => r.code);
|
||||
@@ -328,13 +316,9 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).delete('/api/addons/atlas/region/DE-NW/mark').set('Cookie', authCookie(user.id));
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const stats = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).not.toContain('DE');
|
||||
@@ -353,13 +337,9 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Bayern', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).delete('/api/addons/atlas/region/DE-NW/mark').set('Cookie', authCookie(user.id));
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const stats = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
@@ -374,9 +354,7 @@ describe('Mark/unmark region', () => {
|
||||
.set('Cookie', authCookie(user1.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user2.id));
|
||||
const res = await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user2.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||
@@ -388,9 +366,7 @@ describe('Regions geo', () => {
|
||||
it('ATLAS-012 — GET /regions/geo without countries param returns empty FeatureCollection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions/geo')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/atlas/regions/geo').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
@@ -4,10 +4,37 @@
|
||||
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
||||
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createUserWithMfa,
|
||||
createInviteToken,
|
||||
createTrip,
|
||||
createBudgetItem,
|
||||
createJourney,
|
||||
createJourneyEntry,
|
||||
addJourneyContributor,
|
||||
addTripPhoto,
|
||||
createCategory,
|
||||
createTag,
|
||||
createTodoItem,
|
||||
createMcpToken,
|
||||
createBucketListItem,
|
||||
createVisitedCountry,
|
||||
createCollabNote,
|
||||
addTripMember,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import { authenticator } from 'otplib';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -24,16 +51,32 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -48,14 +91,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -100,7 +135,9 @@ describe('Login', () => {
|
||||
});
|
||||
|
||||
it('AUTH-003 — non-existent email returns 401 with same generic message (no user enumeration)', async () => {
|
||||
const res = await request(app).post('/api/auth/login').send({ email: 'nobody@example.com', password: 'SomePass1!' });
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'nobody@example.com', password: 'SomePass1!' });
|
||||
expect(res.status).toBe(401);
|
||||
// Must be same message as wrong-password to avoid email enumeration
|
||||
expect(res.body.error).toContain('Invalid email or password');
|
||||
@@ -111,7 +148,9 @@ describe('Login', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: (res.headers['set-cookie'] ? [res.headers['set-cookie']] : []);
|
||||
: res.headers['set-cookie']
|
||||
? [res.headers['set-cookie']]
|
||||
: [];
|
||||
const sessionCookie = cookies.find((c: string) => c.includes('trek_session'));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie).toMatch(/expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
||||
@@ -192,7 +231,9 @@ describe('Registration', () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as {
|
||||
used_count: number;
|
||||
};
|
||||
expect(row.used_count).toBe(1);
|
||||
});
|
||||
|
||||
@@ -230,7 +271,9 @@ describe('Registration — whitespace normalization', () => {
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as { username: string };
|
||||
const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as {
|
||||
username: string;
|
||||
};
|
||||
expect(row.username).toBe('trimmeduser');
|
||||
});
|
||||
|
||||
@@ -333,9 +376,9 @@ describe('Demo login', () => {
|
||||
});
|
||||
|
||||
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')"
|
||||
).run();
|
||||
testDb
|
||||
.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')")
|
||||
.run();
|
||||
process.env.DEMO_MODE = 'true';
|
||||
try {
|
||||
const res = await request(app).post('/api/auth/demo-login');
|
||||
@@ -354,9 +397,7 @@ describe('Demo login', () => {
|
||||
describe('MFA', () => {
|
||||
it('AUTH-015 — POST /api/auth/mfa/setup returns secret and QR data URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/setup')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post('/api/auth/mfa/setup').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toBeDefined();
|
||||
expect(res.body.otpauth_url).toContain('otpauth://');
|
||||
@@ -366,9 +407,7 @@ describe('MFA', () => {
|
||||
it('AUTH-015 — POST /api/auth/mfa/enable with valid TOTP code enables MFA', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const setupRes = await request(app)
|
||||
.post('/api/auth/mfa/setup')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const setupRes = await request(app).post('/api/auth/mfa/setup').set('Cookie', authCookie(user.id));
|
||||
expect(setupRes.status).toBe(200);
|
||||
|
||||
const enableRes = await request(app)
|
||||
@@ -382,9 +421,7 @@ describe('MFA', () => {
|
||||
|
||||
it('AUTH-016 — login with MFA-enabled account returns mfa_required + mfa_token', async () => {
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.mfa_required).toBe(true);
|
||||
expect(typeof loginRes.body.mfa_token).toBe('string');
|
||||
@@ -393,9 +430,7 @@ describe('MFA', () => {
|
||||
it('AUTH-016 — POST /api/auth/mfa/verify-login with valid code completes login', async () => {
|
||||
const { user, password, totpSecret } = createUserWithMfa(testDb);
|
||||
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
const { mfa_token } = loginRes.body;
|
||||
|
||||
const verifyRes = await request(app)
|
||||
@@ -411,9 +446,7 @@ describe('MFA', () => {
|
||||
|
||||
it('AUTH-017 — verify-login with invalid TOTP code returns 401', async () => {
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
|
||||
const verifyRes = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
@@ -476,9 +509,7 @@ describe('Forced MFA policy', () => {
|
||||
describe('Short-lived tokens', () => {
|
||||
it('AUTH-029 — POST /api/auth/ws-token returns a single-use token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/ws-token')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post('/api/auth/ws-token').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.token.length).toBeGreaterThan(0);
|
||||
@@ -504,9 +535,7 @@ describe('Extended auth scenarios', () => {
|
||||
it('AUTH-031 — login succeeds with uppercased email (case-insensitive lookup)', async () => {
|
||||
const { user, password } = createUser(testDb, { email: 'alice@example.com' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'ALICE@EXAMPLE.COM', password });
|
||||
const res = await request(app).post('/api/auth/login').send({ email: 'ALICE@EXAMPLE.COM', password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
});
|
||||
@@ -527,27 +556,20 @@ describe('Extended auth scenarios', () => {
|
||||
// Generate and store backup codes on the MFA-enabled user
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?')
|
||||
.run(JSON.stringify(backupHashes), user.id);
|
||||
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(backupHashes), user.id);
|
||||
|
||||
// Step 1: login to get mfa_token
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
expect(loginRes.body.mfa_required).toBe(true);
|
||||
const { mfa_token } = loginRes.body;
|
||||
|
||||
// Step 2: verify with a backup code
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token, code: backupCodes[0] });
|
||||
const res = await request(app).post('/api/auth/mfa/verify-login').send({ mfa_token, code: backupCodes[0] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
|
||||
// Step 3: same backup code is now consumed — second login attempt fails
|
||||
const loginRes2 = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const loginRes2 = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
const { mfa_token: mfa_token2 } = loginRes2.body;
|
||||
|
||||
const res2 = await request(app)
|
||||
@@ -571,10 +593,14 @@ describe('Account deletion', () => {
|
||||
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||
// (trip survives deletion; only invited_by should become NULL)
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)')
|
||||
.run(otherTrip.id, thirdUser.id, target.id);
|
||||
|
||||
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id);
|
||||
testDb
|
||||
.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)")
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||
@@ -588,35 +614,43 @@ describe('Account deletion', () => {
|
||||
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_share_tokens: target created a share token for otherUser's journey
|
||||
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id);
|
||||
testDb
|
||||
.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)")
|
||||
.run(otherJourney.id, target.id);
|
||||
|
||||
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||
const sentNotif = testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, target.id, otherUser.id);
|
||||
const sentNotif = testDb
|
||||
.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')",
|
||||
)
|
||||
.run(otherTrip.id, target.id, otherUser.id);
|
||||
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||
testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, otherUser.id, target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')",
|
||||
)
|
||||
.run(otherTrip.id, otherUser.id, target.id);
|
||||
|
||||
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||
testDb.prepare(
|
||||
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||
).run(target.id, Date.now());
|
||||
testDb
|
||||
.prepare("INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)")
|
||||
.run(target.id, Date.now());
|
||||
|
||||
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||
const ownedJourney = createJourney(testDb, target.id);
|
||||
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||
|
||||
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||
const fileRow = testDb.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const fileRow = testDb
|
||||
.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)",
|
||||
)
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||
const trekPhotoRow = testDb.prepare(
|
||||
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)"
|
||||
).run(target.id);
|
||||
const trekPhotoRow = testDb
|
||||
.prepare("INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)")
|
||||
.run(target.id);
|
||||
|
||||
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
|
||||
@@ -638,30 +672,34 @@ describe('Account deletion', () => {
|
||||
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||
|
||||
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||
const packBagRow = testDb.prepare(
|
||||
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const packBagRow = testDb
|
||||
.prepare("INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)")
|
||||
.run(otherTrip.id, target.id);
|
||||
|
||||
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||
createMcpToken(testDb, target.id);
|
||||
|
||||
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')"
|
||||
).run(otherUser.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||
).run(target.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')",
|
||||
)
|
||||
.run(otherUser.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))",
|
||||
)
|
||||
.run(target.id);
|
||||
testDb.prepare("INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)").run(target.id);
|
||||
|
||||
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||
const vacayPlanRow = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(target.id);
|
||||
|
||||
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
const otherVacayPlanRow = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherUser.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)')
|
||||
.run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
|
||||
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||
createBucketListItem(testDb, target.id);
|
||||
@@ -670,14 +708,16 @@ describe('Account deletion', () => {
|
||||
createVisitedCountry(testDb, target.id, 'JP');
|
||||
|
||||
// visited_regions.user_id (CASCADE): target has visited a region
|
||||
testDb.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
// packing_templates.created_by (CASCADE): target created a packing template
|
||||
const packTemplateRow = testDb.prepare(
|
||||
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||
).run(target.id);
|
||||
const packTemplateRow = testDb
|
||||
.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)")
|
||||
.run(target.id);
|
||||
|
||||
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||
createInviteToken(testDb, { created_by: target.id });
|
||||
@@ -689,62 +729,102 @@ describe('Account deletion', () => {
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||
|
||||
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||
testDb.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))"
|
||||
).run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
// audit_log.user_id (SET NULL): target performed an audited action
|
||||
const auditRow = testDb.prepare(
|
||||
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||
).run(target.id);
|
||||
const auditRow = testDb
|
||||
.prepare("INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')")
|
||||
.run(target.id);
|
||||
|
||||
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')",
|
||||
)
|
||||
.run(target.id);
|
||||
|
||||
// admin exists to ensure target (non-admin user) passes the last-admin guard
|
||||
void admin;
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(target.id));
|
||||
const res = await request(app).delete('/api/auth/me').set('Cookie', authCookie(target.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||
// trip_members row survives but invited_by is now NULL
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||
expect(
|
||||
(
|
||||
testDb
|
||||
.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(otherTrip.id, thirdUser.id) as any
|
||||
).invited_by,
|
||||
).toBeNull();
|
||||
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
(testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any)
|
||||
.paid_by_user_id,
|
||||
).toBeNull();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
|
||||
.get(otherJourney.id, target.id),
|
||||
).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// sent notification survives but sender_id becomes NULL
|
||||
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any)
|
||||
.sender_id,
|
||||
).toBeNull();
|
||||
// received notification is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||
// notice dismissals are cascade-deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'")
|
||||
.get(target.id),
|
||||
).toBeUndefined();
|
||||
// owned journey and its entries are cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||
// uploaded file survives but uploaded_by is now NULL
|
||||
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any)
|
||||
.uploaded_by,
|
||||
).toBeNull();
|
||||
// trek_photos row survives but owner_id is now NULL
|
||||
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any)
|
||||
.owner_id,
|
||||
).toBeNull();
|
||||
// trip_photos row for target is cascade-deleted
|
||||
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id),
|
||||
).toBeUndefined();
|
||||
// owned trip is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||
// trip membership on others' trips is removed
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id),
|
||||
).toBeUndefined();
|
||||
// category survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id,
|
||||
).toBeNull();
|
||||
// tag is deleted
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||
// todo assigned_user_id is NULL
|
||||
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id,
|
||||
).toBeNull();
|
||||
// packing bag survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id,
|
||||
).toBeNull();
|
||||
// MCP tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// OAuth tokens and consents are deleted
|
||||
@@ -753,26 +833,46 @@ describe('Account deletion', () => {
|
||||
// owned vacay plan is deleted
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||
// vacay plan membership on others' plans is removed
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||||
.get(otherVacayPlanRow.lastInsertRowid, target.id),
|
||||
).toBeUndefined();
|
||||
// bucket list items are deleted
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// travel history is deleted
|
||||
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?')
|
||||
.get(target.id, 'JP'),
|
||||
).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// packing template is deleted
|
||||
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid),
|
||||
).toBeUndefined();
|
||||
// invite tokens created by target are deleted
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// collab content is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id),
|
||||
).toBeUndefined();
|
||||
// user settings are deleted
|
||||
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM settings WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// password reset tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// audit log entry survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect(
|
||||
(testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id,
|
||||
).toBeNull();
|
||||
// notification channel preferences are deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||
expect(
|
||||
testDb
|
||||
.prepare(
|
||||
"SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'",
|
||||
)
|
||||
.get(target.id),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -797,9 +897,7 @@ describe('Rate limiting', () => {
|
||||
it('AUTH-018 — MFA verify-login endpoint rate-limits after 5 attempts', async () => {
|
||||
let lastStatus = 0;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token: 'badtoken', code: '000000' });
|
||||
const res = await request(app).post('/api/auth/mfa/verify-login').send({ mfa_token: 'badtoken', code: '000000' });
|
||||
lastStatus = res.status;
|
||||
if (lastStatus === 429) break;
|
||||
}
|
||||
@@ -814,9 +912,7 @@ describe('Rate limiting', () => {
|
||||
describe('MCP token management', () => {
|
||||
it('AUTH-034 — GET /auth/mcp-tokens returns empty list initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokens).toEqual([]);
|
||||
});
|
||||
@@ -834,10 +930,7 @@ describe('MCP token management', () => {
|
||||
|
||||
it('AUTH-036 — POST /auth/mcp-tokens without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -850,23 +943,17 @@ describe('MCP token management', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const tokenId = createRes.body.token.id;
|
||||
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/auth/mcp-tokens/${tokenId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const delRes = await request(app).delete(`/api/auth/mcp-tokens/${tokenId}`).set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(delRes.body.success).toBe(true);
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get('/api/auth/mcp-tokens').set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('AUTH-038 — DELETE /auth/mcp-tokens/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/mcp-tokens/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/auth/mcp-tokens/99999').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,21 @@
|
||||
* These tests run in test env and may not have a full DB file to zip,
|
||||
* but the service should handle gracefully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -21,13 +33,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -43,7 +71,9 @@ vi.mock('../../src/config', () => ({
|
||||
|
||||
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
|
||||
vi.mock('../../src/services/backupService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/backupService')>('../../src/services/backupService');
|
||||
const actual = await vi.importActual<typeof import('../../src/services/backupService')>(
|
||||
'../../src/services/backupService',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createBackup: vi.fn().mockResolvedValue({
|
||||
@@ -69,18 +99,6 @@ vi.mock('../../src/services/backupService', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -102,9 +120,7 @@ describe('Backup access control', () => {
|
||||
it('non-admin cannot access backup routes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/list')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/backup/list').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -113,9 +129,7 @@ describe('Backup list', () => {
|
||||
it('BACKUP-001 — GET /backup/list returns backups array', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/list')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/backup/list').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.backups)).toBe(true);
|
||||
});
|
||||
@@ -125,9 +139,7 @@ describe('Backup creation', () => {
|
||||
it('BACKUP-001 — POST /backup/create creates a backup', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/backup/create').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.backup).toHaveProperty('filename');
|
||||
@@ -139,9 +151,7 @@ describe('Auto-backup settings', () => {
|
||||
it('BACKUP-008 — GET /backup/auto-settings returns current config', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/auto-settings')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/backup/auto-settings').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('settings');
|
||||
expect(res.body.settings).toHaveProperty('enabled');
|
||||
@@ -165,9 +175,7 @@ describe('Backup security', () => {
|
||||
it('BACKUP-007 — Download with path traversal filename is rejected', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/../../etc/passwd')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get('/api/backup/download/../../etc/passwd').set('Cookie', authCookie(admin.id));
|
||||
// Express normalises the URL before routing; path traversal gets resolved
|
||||
// to a path that matches no route → 404
|
||||
expect(res.status).toBe(404);
|
||||
@@ -176,9 +184,7 @@ describe('Backup security', () => {
|
||||
it('BACKUP-007 — Delete with path traversal filename is rejected', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/../../../etc/passwd')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete('/api/backup/../../../etc/passwd').set('Cookie', authCookie(admin.id));
|
||||
// Express normalises the URL, stripping traversal → no route match → 404
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -200,16 +206,16 @@ describe('Backup download', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
it('BACKUP-INT-001 — GET /backup/download/:filename returns 200 with content-disposition', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/backup/download/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).get(`/api/backup/download/${filename}`).set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toMatch(/attachment/i);
|
||||
@@ -253,9 +259,7 @@ describe('Backup restore', () => {
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post(`/api/backup/restore/${filename}`).set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -277,9 +281,7 @@ describe('Backup restore', () => {
|
||||
it('BACKUP-INT-006 — POST /backup/restore/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/restore/../../evil.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/backup/restore/../../evil.zip').set('Cookie', authCookie(admin.id));
|
||||
|
||||
// Express resolves path traversal → no route or invalid filename check
|
||||
expect([400, 404]).toContain(res.status);
|
||||
@@ -296,9 +298,7 @@ describe('Backup restore', () => {
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post(`/api/backup/restore/${filename}`).set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/travel\.db not found/i);
|
||||
@@ -317,9 +317,7 @@ describe('Backup delete', () => {
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.deleteBackup).mockReturnValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/backup/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/backup/${filename}`).set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -342,9 +340,7 @@ describe('Backup delete', () => {
|
||||
it('BACKUP-INT-010 — DELETE /backup/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/not-a-backup.tar.gz')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete('/api/backup/not-a-backup.tar.gz').set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid filename/i);
|
||||
@@ -368,16 +364,12 @@ describe('Backup rate limiter', () => {
|
||||
|
||||
// First 3 succeed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/backup/create').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
// 4th is rate-limited
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/backup/create').set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/too many/i);
|
||||
});
|
||||
@@ -409,9 +401,7 @@ describe('Backup upload-restore', () => {
|
||||
it('BACKUP-INT-013 — POST /backup/upload-restore with no file returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).post('/api/backup/upload-restore').set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no file/i);
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Budget Planner integration tests.
|
||||
* Covers BUDGET-001 to BUDGET-010.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -115,9 +131,7 @@ describe('List budget items', () => {
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 300 });
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 500 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(2);
|
||||
});
|
||||
@@ -129,9 +143,7 @@ describe('List budget items', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Rental', total_price: 200 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
@@ -178,15 +190,11 @@ describe('Delete budget item', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/budget/${item.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete(`/api/trips/${trip.id}/budget/${item.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -195,14 +203,12 @@ describe('Delete budget item', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id);
|
||||
const result = testDb
|
||||
.prepare('INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id);
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/budget/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete(`/api/trips/${trip.id}/budget/${itemId}`).set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
|
||||
@@ -230,9 +236,7 @@ describe('Budget item members', () => {
|
||||
expect(res.body.members).toBeDefined();
|
||||
|
||||
// After assigning members, list items should include them (covers loadBudgetItems member loop)
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get(`/api/trips/${trip.id}/budget`).set('Cookie', authCookie(user.id));
|
||||
expect(listRes.status).toBe(200);
|
||||
const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id);
|
||||
expect(foundItem).toBeDefined();
|
||||
@@ -347,9 +351,7 @@ describe('Budget summary and settlement', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget/settlement`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.balances)).toBe(true);
|
||||
expect(Array.isArray(res.body.flows)).toBe(true);
|
||||
@@ -370,9 +372,7 @@ describe('Budget summary and settlement', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget/settlement`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.balances).toEqual([]);
|
||||
expect(res.body.flows).toEqual([]);
|
||||
@@ -478,9 +478,9 @@ describe('Reservation price sync on budget item update', () => {
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
// Create a budget item linked to the reservation
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel Cost', 'Accommodation', 200, reservation.id);
|
||||
const result = testDb
|
||||
.prepare('INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(trip.id, 'Hotel Cost', 'Accommodation', 200, reservation.id);
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
|
||||
const res = await request(app)
|
||||
@@ -491,7 +491,9 @@ describe('Reservation price sync on budget item update', () => {
|
||||
expect(res.body.item.total_price).toBe(350);
|
||||
|
||||
// Verify reservation metadata was synced
|
||||
const updatedReservation = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservation.id) as { metadata: string | null } | undefined;
|
||||
const updatedReservation = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservation.id) as
|
||||
| { metadata: string | null }
|
||||
| undefined;
|
||||
expect(updatedReservation).toBeDefined();
|
||||
const meta = JSON.parse(updatedReservation!.metadata || '{}');
|
||||
expect(meta.price).toBe('350');
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Categories integration tests — CAT-001 through CAT-009.
|
||||
* Covers GET/POST/PUT/DELETE /api/categories.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -18,7 +26,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -32,14 +44,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -60,14 +64,16 @@ afterAll(() => {
|
||||
describe('Categories', () => {
|
||||
it('CAT-001: GET /api/categories returns seeded default categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/categories').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
// 10 default categories are seeded on reset
|
||||
expect(res.body.categories.length).toBeGreaterThanOrEqual(10);
|
||||
expect(res.body.categories[0]).toMatchObject({ name: expect.any(String), color: expect.any(String), icon: expect.any(String) });
|
||||
expect(res.body.categories[0]).toMatchObject({
|
||||
name: expect.any(String),
|
||||
color: expect.any(String),
|
||||
icon: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('CAT-002: POST /api/categories - admin creates a new category', async () => {
|
||||
@@ -83,10 +89,7 @@ describe('Categories', () => {
|
||||
|
||||
it('CAT-003: POST /api/categories - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Museum' });
|
||||
const res = await request(app).post('/api/categories').set('Cookie', authCookie(user.id)).send({ name: 'Museum' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -148,9 +151,7 @@ describe('Categories', () => {
|
||||
.send({ name: 'To Delete' });
|
||||
const catId = createRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/categories/${catId}`).set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
@@ -162,9 +163,7 @@ describe('Categories', () => {
|
||||
it('CAT-009: DELETE /api/categories/:id - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${cat.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/categories/${cat.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
* Note: File upload to collab notes (COLLAB-005/006/007) requires physical file I/O.
|
||||
* Link preview (COLLAB-025/026) would need fetch mocking — skipped here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -22,13 +31,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -51,15 +76,6 @@ vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
@@ -136,9 +152,7 @@ describe('Collab notes', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Note B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notes).toHaveLength(2);
|
||||
});
|
||||
@@ -189,9 +203,7 @@ describe('Collab notes', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -296,9 +308,7 @@ describe('Collab notes', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_PDF);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/collab/notes`).set('Cookie', authCookie(user.id));
|
||||
expect(list.status).toBe(200);
|
||||
|
||||
const note = list.body.notes.find((n: any) => n.id === noteId);
|
||||
@@ -396,9 +406,7 @@ describe('Polls', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Beach or mountains?', options: ['Beach', 'Mountains'] });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/collab/polls`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.polls).toHaveLength(1);
|
||||
});
|
||||
@@ -448,9 +456,7 @@ describe('Polls', () => {
|
||||
.send({ question: 'Closed?', options: ['Yes', 'No'] });
|
||||
const pollId = create.body.poll.id;
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).put(`/api/trips/${trip.id}/collab/polls/${pollId}/close`).set('Cookie', authCookie(user.id));
|
||||
|
||||
const vote = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls/${pollId}/vote`)
|
||||
@@ -530,9 +536,7 @@ describe('Messages', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Second message' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/collab/messages`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.messages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -572,7 +576,7 @@ describe('Messages', () => {
|
||||
expect(del.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-017 — cannot delete another user\'s message', async () => {
|
||||
it("COLLAB-017 — cannot delete another user's message", async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -667,9 +671,7 @@ describe('Link preview', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/collab/link-preview`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/url/i);
|
||||
@@ -780,7 +782,8 @@ describe('Message reactions toggle', () => {
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
|
||||
// After toggling off, either the entry is absent or the user is no longer in it
|
||||
const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
|
||||
const userStillReacted =
|
||||
thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
|
||||
expect(userStillReacted).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Day Notes integration tests.
|
||||
* Covers NOTE-001 to NOTE-006.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -136,9 +152,7 @@ describe('List day notes', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Note B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/notes`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days/${day.id}/notes`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notes).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Days & Accommodations API integration tests.
|
||||
* Covers DAY-001 through DAY-006 and ACCOM-001 through ACCOM-003.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -20,13 +28,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -40,18 +64,19 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
|
||||
afterAll(() => { testDb.close(); });
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List days (DAY-001, DAY-002)
|
||||
@@ -62,9 +87,7 @@ describe('List days', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2026-06-01', end_date: '2026-06-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBeDefined();
|
||||
@@ -75,12 +98,14 @@ describe('List days', () => {
|
||||
it('DAY-001 — Member can list days for a shared trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip', start_date: '2026-07-01', end_date: '2026-07-02' });
|
||||
const trip = createTrip(testDb, owner.id, {
|
||||
title: 'Shared Trip',
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-02',
|
||||
});
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(2);
|
||||
@@ -91,9 +116,7 @@ describe('List days', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -232,9 +255,7 @@ describe('Reorder days', () => {
|
||||
end_date: '2026-09-03',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(3);
|
||||
@@ -254,9 +275,7 @@ describe('Delete day', () => {
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/${day.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}/days/${day.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -269,9 +288,7 @@ describe('Delete day', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/days/999999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}/days/999999`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
@@ -345,13 +362,11 @@ describe('Accommodations', () => {
|
||||
const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' });
|
||||
|
||||
// Seed accommodation directly
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)'
|
||||
).run(trip.id, place.id, day1.id, day2.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)')
|
||||
.run(trip.id, place.id, day1.id, day2.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/accommodations`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.accommodations).toBeDefined();
|
||||
@@ -365,9 +380,7 @@ describe('Accommodations', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/accommodations`).set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -426,9 +439,9 @@ describe('Accommodations', () => {
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Linked reservation should exist
|
||||
const reservation = testDb.prepare(
|
||||
'SELECT * FROM reservations WHERE accommodation_id = ?'
|
||||
).get(res.body.accommodation.id) as any;
|
||||
const reservation = testDb
|
||||
.prepare('SELECT * FROM reservations WHERE accommodation_id = ?')
|
||||
.get(res.body.accommodation.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
expect(reservation.type).toBe('hotel');
|
||||
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
||||
@@ -487,9 +500,9 @@ describe('Accommodations', () => {
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id });
|
||||
|
||||
const accommodationId = createRes.body.accommodation.id;
|
||||
const reservationBefore = testDb.prepare(
|
||||
'SELECT id FROM reservations WHERE accommodation_id = ?'
|
||||
).get(accommodationId) as any;
|
||||
const reservationBefore = testDb
|
||||
.prepare('SELECT id FROM reservations WHERE accommodation_id = ?')
|
||||
.get(accommodationId) as any;
|
||||
expect(reservationBefore).toBeDefined();
|
||||
|
||||
const deleteRes = await request(app)
|
||||
@@ -497,9 +510,7 @@ describe('Accommodations', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const reservationAfter = testDb.prepare(
|
||||
'SELECT id FROM reservations WHERE id = ?'
|
||||
).get(reservationBefore.id);
|
||||
const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservationBefore.id);
|
||||
expect(reservationAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -523,14 +534,10 @@ describe('Accommodations', () => {
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const accommodationId = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
const accommodationId = testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
|
||||
expect(accommodationId).toBeDefined();
|
||||
|
||||
const budgetBefore = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
const budgetBefore = testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').get(trip.id);
|
||||
expect(budgetBefore).toBeDefined();
|
||||
|
||||
// Delete via the accommodation endpoint (the primary bug path)
|
||||
@@ -539,9 +546,7 @@ describe('Accommodations', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const budgetAfter = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
const budgetAfter = testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').get(trip.id);
|
||||
expect(budgetAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
* - File uploads create real files in uploads/files/ — tests clean up after themselves where possible
|
||||
* - FILE-009 (ephemeral token download) is covered via the /api/auth/resource-token endpoint
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -24,13 +32,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -44,14 +68,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
@@ -147,9 +163,7 @@ describe('List files', () => {
|
||||
await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
await uploadFile(trip.id, user.id, FIXTURE_IMG);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/files`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.files.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -161,13 +175,9 @@ describe('List files', () => {
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
// Soft-delete it
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
const trash = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const trash = await request(app).get(`/api/trips/${trip.id}/files?trash=true`).set('Cookie', authCookie(user.id));
|
||||
expect(trash.status).toBe(200);
|
||||
const trashIds = (trash.body.files as any[]).map((f: any) => f.id);
|
||||
expect(trashIds).toContain(fileId);
|
||||
@@ -210,16 +220,12 @@ describe('Soft delete, restore, permanent delete', () => {
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
// Should not appear in normal list
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/files`).set('Cookie', authCookie(user.id));
|
||||
const ids = (list.body.files as any[]).map((f: any) => f.id);
|
||||
expect(ids).not.toContain(fileId);
|
||||
});
|
||||
@@ -230,9 +236,7 @@ describe('Soft delete, restore, permanent delete', () => {
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
const restore = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files/${fileId}/restore`)
|
||||
@@ -247,9 +251,7 @@ describe('Soft delete, restore, permanent delete', () => {
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).delete(`/api/trips/${trip.id}/files/${fileId}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
const perm = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/files/${fileId}/permanent`)
|
||||
@@ -285,9 +287,7 @@ describe('Soft delete, restore, permanent delete', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(empty.status).toBe(200);
|
||||
|
||||
const trash = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files?trash=true`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const trash = await request(app).get(`/api/trips/${trip.id}/files?trash=true`).set('Cookie', authCookie(user.id));
|
||||
expect(trash.body.files).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -360,8 +360,7 @@ describe('File download', () => {
|
||||
const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`);
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/files/${fileId}/download`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
* External Immich API calls are not made — tests focus on settings persistence
|
||||
* and input validation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -20,13 +28,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -64,14 +88,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -93,9 +109,7 @@ describe('Immich settings', () => {
|
||||
it('IMMICH-001 — GET /api/integrations/memories/immich/settings returns current settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/integrations/memories/immich/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/integrations/memories/immich/settings').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// Settings may be empty initially
|
||||
expect(res.body).toBeDefined();
|
||||
@@ -150,7 +164,9 @@ describe('Immich authentication', () => {
|
||||
describe('Immich album links', () => {
|
||||
it('IMMICH-020 — POST album-links creates a link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any;
|
||||
const trip = testDb
|
||||
.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *')
|
||||
.get(user.id, 'Test Trip') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
|
||||
@@ -160,7 +176,9 @@ describe('Immich album links', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as any;
|
||||
const link = testDb
|
||||
.prepare('SELECT * FROM trip_album_links WHERE trip_id = ? AND user_id = ?')
|
||||
.get(trip.id, user.id) as any;
|
||||
expect(link).toBeDefined();
|
||||
expect(link.album_id).toBe('album-uuid-123');
|
||||
expect(link.album_name).toBe('Vacation 2024');
|
||||
@@ -168,8 +186,12 @@ describe('Immich album links', () => {
|
||||
|
||||
it('IMMICH-021 — GET album-links returns linked albums', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any;
|
||||
testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)').run(trip.id, user.id, 'album-abc', 'My Album', 'immich');
|
||||
const trip = testDb
|
||||
.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *')
|
||||
.get(user.id, 'Test Trip') as any;
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(trip.id, user.id, 'album-abc', 'My Album', 'immich');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/integrations/memories/unified/trips/${trip.id}/album-links`)
|
||||
@@ -183,23 +205,40 @@ describe('Immich album links', () => {
|
||||
|
||||
it('IMMICH-022 — DELETE album-links removes associated photos but not individually-added ones', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(user.id, 'Test Trip') as any;
|
||||
const trip = testDb
|
||||
.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *')
|
||||
.get(user.id, 'Test Trip') as any;
|
||||
|
||||
// Create album link
|
||||
const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *')
|
||||
const linkResult = testDb
|
||||
.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *',
|
||||
)
|
||||
.get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any;
|
||||
|
||||
// Insert photos synced from the album
|
||||
for (const assetId of ['asset-001', 'asset-002']) {
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', assetId, user.id);
|
||||
const tkp = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', assetId, user.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, user.id, tkp.id, linkResult.id);
|
||||
testDb
|
||||
.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)')
|
||||
.run('immich', assetId, user.id);
|
||||
const tkp = testDb
|
||||
.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('immich', assetId, user.id) as any;
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)')
|
||||
.run(trip.id, user.id, tkp.id, linkResult.id);
|
||||
}
|
||||
|
||||
// Insert an individually-added photo (no album_link_id)
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-manual', user.id);
|
||||
const tkpManual = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-manual', user.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, tkpManual.id);
|
||||
testDb
|
||||
.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)')
|
||||
.run('immich', 'asset-manual', user.id);
|
||||
const tkpManual = testDb
|
||||
.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('immich', 'asset-manual', user.id) as any;
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)')
|
||||
.run(trip.id, user.id, tkpManual.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
@@ -209,11 +248,15 @@ describe('Immich album links', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Album-linked photos should be gone
|
||||
const remainingPhotos = testDb.prepare(`
|
||||
const remainingPhotos = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.*, tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
`,
|
||||
)
|
||||
.all(trip.id) as any[];
|
||||
expect(remainingPhotos.length).toBe(1);
|
||||
expect(remainingPhotos[0].asset_id).toBe('asset-manual');
|
||||
|
||||
@@ -225,13 +268,24 @@ describe('Immich album links', () => {
|
||||
it('IMMICH-023 — DELETE album-link by non-member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *').get(owner.id, 'Test Trip') as any;
|
||||
const trip = testDb
|
||||
.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?) RETURNING *')
|
||||
.get(owner.id, 'Test Trip') as any;
|
||||
|
||||
const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *')
|
||||
const linkResult = testDb
|
||||
.prepare(
|
||||
'INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *',
|
||||
)
|
||||
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-owned', owner.id);
|
||||
const tkpOwned = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-owned', owner.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, owner.id, tkpOwned.id, linkResult.id);
|
||||
testDb
|
||||
.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)')
|
||||
.run('immich', 'asset-owned', owner.id);
|
||||
const tkpOwned = testDb
|
||||
.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('immich', 'asset-owned', owner.id) as any;
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)')
|
||||
.run(trip.id, owner.id, tkpOwned.id, linkResult.id);
|
||||
|
||||
// Non-member tries to delete owner's album link — should be denied
|
||||
const res = await request(app)
|
||||
@@ -243,11 +297,15 @@ describe('Immich album links', () => {
|
||||
// Link and photos should still exist
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
|
||||
expect(link).toBeDefined();
|
||||
const photo = testDb.prepare(`
|
||||
const photo = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-owned');
|
||||
`,
|
||||
)
|
||||
.get('asset-owned');
|
||||
expect(photo).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
* Journey API integration tests.
|
||||
* Covers JOURNEY-INT-001 through JOURNEY-INT-020.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createTrip,
|
||||
createJourney,
|
||||
createJourneyEntry,
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -21,16 +37,32 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -55,36 +87,27 @@ vi.mock('../../src/services/memories/immichService', () => ({
|
||||
getImmichCredentials: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createTrip,
|
||||
createJourney,
|
||||
createJourneyEntry,
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
// Enable the journey addon
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)",
|
||||
)
|
||||
.run();
|
||||
});
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List journeys (JOURNEY-INT-001, 002)
|
||||
@@ -94,9 +117,7 @@ describe('List journeys', () => {
|
||||
it('JOURNEY-INT-001 — GET /api/journeys returns 200 with empty list initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/journeys').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.journeys).toEqual([]);
|
||||
@@ -127,9 +148,7 @@ describe('Create journey', () => {
|
||||
expect(res.body.id).toBeDefined();
|
||||
|
||||
// Should appear in listing now
|
||||
const list = await request(app)
|
||||
.get('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get('/api/journeys').set('Cookie', authCookie(user.id));
|
||||
expect(list.body.journeys).toHaveLength(1);
|
||||
expect(list.body.journeys[0].title).toBe('Japan 2026');
|
||||
});
|
||||
@@ -144,9 +163,7 @@ describe('Get journey detail', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id, { title: 'Iceland' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Iceland');
|
||||
@@ -158,9 +175,7 @@ describe('Get journey detail', () => {
|
||||
it('JOURNEY-INT-005 — GET /api/journeys/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/journeys/99999').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -195,17 +210,13 @@ describe('Delete journey', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -229,9 +240,7 @@ describe('Journey trips', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify trip appears in journey detail
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const detail = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(detail.body.trips).toHaveLength(1);
|
||||
expect(detail.body.trips[0].trip_id).toBe(trip.id);
|
||||
});
|
||||
@@ -265,16 +274,13 @@ describe('Journey entries', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'First day in Tokyo',
|
||||
story: 'Arrived at Narita airport.',
|
||||
entry_date: '2026-04-01',
|
||||
entry_time: '14:00',
|
||||
location_name: 'Narita Airport',
|
||||
});
|
||||
const res = await request(app).post(`/api/journeys/${journey.id}/entries`).set('Cookie', authCookie(user.id)).send({
|
||||
title: 'First day in Tokyo',
|
||||
story: 'Arrived at Narita airport.',
|
||||
entry_date: '2026-04-01',
|
||||
entry_time: '14:00',
|
||||
location_name: 'Narita Airport',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('First day in Tokyo');
|
||||
@@ -308,9 +314,7 @@ describe('Journey entries', () => {
|
||||
entry_date: '2026-04-02',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/entries/${entry.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/journeys/entries/${entry.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -336,9 +340,7 @@ describe('Journey contributors', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Contributor should now be able to access the journey
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(contributor.id));
|
||||
const detail = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(contributor.id));
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.title).toBeDefined();
|
||||
});
|
||||
@@ -357,9 +359,7 @@ describe('Journey contributors', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Contributor should no longer access the journey
|
||||
const detail = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(contributor.id));
|
||||
const detail = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(contributor.id));
|
||||
expect(detail.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -373,9 +373,7 @@ describe('Journey share link', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/journeys/${journey.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.link).toBeNull();
|
||||
@@ -396,9 +394,7 @@ describe('Journey share link', () => {
|
||||
expect(res.body.created).toBe(true);
|
||||
|
||||
// GET should now return the link
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get(`/api/journeys/${journey.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link).not.toBeNull();
|
||||
expect(get.body.link.token).toBe(res.body.token);
|
||||
expect(get.body.link.share_timeline).toBe(true);
|
||||
@@ -417,17 +413,13 @@ describe('Journey share link', () => {
|
||||
.send({ share_timeline: true, share_gallery: true, share_map: true });
|
||||
|
||||
// Delete
|
||||
const res = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/journeys/${journey.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get(`/api/journeys/${journey.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -445,16 +437,12 @@ describe('Journey permissions', () => {
|
||||
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
||||
|
||||
// Viewer can read
|
||||
const viewerRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(viewer.id));
|
||||
const viewerRes = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(viewer.id));
|
||||
expect(viewerRes.status).toBe(200);
|
||||
expect(viewerRes.body.title).toBe('Private Journey');
|
||||
|
||||
// Outsider cannot
|
||||
const outsiderRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
const outsiderRes = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(outsider.id));
|
||||
expect(outsiderRes.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -465,21 +453,15 @@ describe('Journey permissions', () => {
|
||||
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
||||
|
||||
// Editor can read
|
||||
const readRes = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(editor.id));
|
||||
const readRes = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(editor.id));
|
||||
expect(readRes.status).toBe(200);
|
||||
|
||||
// Editor cannot delete — only owner can
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(editor.id));
|
||||
const delRes = await request(app).delete(`/api/journeys/${journey.id}`).set('Cookie', authCookie(editor.id));
|
||||
expect(delRes.status).toBe(404);
|
||||
|
||||
// Journey still exists
|
||||
const verify = await request(app)
|
||||
.get(`/api/journeys/${journey.id}`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const verify = await request(app).get(`/api/journeys/${journey.id}`).set('Cookie', authCookie(owner.id));
|
||||
expect(verify.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -499,9 +481,7 @@ describe('Journey suggestions', () => {
|
||||
end_date: '2026-03-05',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/suggestions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/journeys/suggestions').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trips).toBeDefined();
|
||||
@@ -518,9 +498,7 @@ describe('Available trips', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'My Trip', start_date: '2026-05-01', end_date: '2026-05-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/journeys/available-trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/journeys/available-trips').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trips).toBeDefined();
|
||||
@@ -550,10 +528,7 @@ describe('Create journey validation', () => {
|
||||
it('JOURNEY-INT-023 — POST /api/journeys returns 400 for blank title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/journeys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: ' ' });
|
||||
const res = await request(app).post('/api/journeys').set('Cookie', authCookie(user.id)).send({ title: ' ' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Title is required');
|
||||
@@ -717,9 +692,7 @@ describe('Delete photo (route)', () => {
|
||||
it('JOURNEY-INT-032 — DELETE /api/journeys/photos/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/journeys/photos/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/journeys/photos/99999').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -736,9 +709,7 @@ describe('Journey entries sub-routes', () => {
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'Day 1', entry_date: '2026-04-01' });
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'Day 2', entry_date: '2026-04-02' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/journeys/${journey.id}/entries`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries).toHaveLength(2);
|
||||
@@ -749,9 +720,7 @@ describe('Journey entries sub-routes', () => {
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/entries`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
const res = await request(app).get(`/api/journeys/${journey.id}/entries`).set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -789,9 +758,7 @@ describe('Update entry edge cases', () => {
|
||||
it('JOURNEY-INT-037 — DELETE /api/journeys/entries/:id returns 404 for non-existent entry', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/journeys/entries/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/journeys/entries/99999').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -913,9 +880,7 @@ describe('Share link update', () => {
|
||||
expect(update.body.created).toBe(false);
|
||||
|
||||
// Verify updated permissions
|
||||
const get = await request(app)
|
||||
.get(`/api/journeys/${journey.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get(`/api/journeys/${journey.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(get.body.link.share_timeline).toBe(true);
|
||||
expect(get.body.link.share_gallery).toBe(false);
|
||||
expect(get.body.link.share_map).toBe(false);
|
||||
@@ -953,7 +918,8 @@ describe('Provider photos — passphrase persistence', () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
const row = testDb
|
||||
.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
expect(typeof row?.passphrase).toBe('string');
|
||||
@@ -973,7 +939,8 @@ describe('Provider photos — passphrase persistence', () => {
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
const row = testDb
|
||||
.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
}
|
||||
@@ -989,9 +956,7 @@ describe('Photo upload validation', () => {
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/photos`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post(`/api/journeys/entries/${entry.id}/photos`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('No files uploaded');
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
* External API calls (Nominatim, Google Places, Wikipedia) are tested at the
|
||||
* input validation level. Full integration tests would require live external APIs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -20,13 +29,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -48,20 +73,9 @@ vi.mock('../../src/services/mapsService', () => ({
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
|
||||
),
|
||||
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(Object.assign(new Error('SSRF or invalid URL'), { status: 400 })),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -81,15 +95,12 @@ afterAll(() => {
|
||||
|
||||
describe('Maps authentication', () => {
|
||||
it('POST /maps/search without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.send({ query: 'Paris' });
|
||||
const res = await request(app).post('/api/maps/search').send({ query: 'Paris' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /maps/reverse without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8566&lng=2.3522');
|
||||
const res = await request(app).get('/api/maps/reverse?lat=48.8566&lng=2.3522');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -98,29 +109,21 @@ describe('Maps validation', () => {
|
||||
it('MAPS-001 — POST /maps/search without query returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/maps/search').set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MAPS-006 — GET /maps/reverse without lat/lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/maps/reverse').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('MAPS-007 — POST /maps/resolve-url without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/maps/resolve-url').set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -155,10 +158,7 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
results: [{ address: 'Paris, France', source: 'nominatim' }],
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ query: 'Paris' });
|
||||
const res = await request(app).post('/api/maps/search').set('Cookie', authCookie(user.id)).send({ query: 'Paris' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
@@ -202,9 +202,7 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
address: 'Champ de Mars, Paris',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/maps/reverse?lat=48.8584&lng=2.2945').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Eiffel Tower');
|
||||
@@ -244,9 +242,7 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/details/some-place-id')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/maps/details/some-place-id').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
@@ -255,7 +251,7 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
it('MAPS-004 — getPlacePhoto error with status returns that status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Photo not found'), { status: 404 })
|
||||
Object.assign(new Error('Photo not found'), { status: 404 }),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -270,9 +266,7 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/maps/reverse?lat=48.8584&lng=2.2945').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBeNull();
|
||||
@@ -282,19 +276,14 @@ describe('Maps happy paths (mocked service)', () => {
|
||||
|
||||
describe('Maps autocomplete', () => {
|
||||
it('MAPS-009 — POST /maps/autocomplete without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/maps/autocomplete')
|
||||
.send({ input: 'Paris' });
|
||||
const res = await request(app).post('/api/maps/autocomplete').send({ input: 'Paris' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('MAPS-010 — POST /maps/autocomplete without input returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/autocomplete')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/maps/autocomplete').set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -321,9 +310,7 @@ describe('Maps autocomplete', () => {
|
||||
it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({
|
||||
suggestions: [
|
||||
{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' },
|
||||
],
|
||||
suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }],
|
||||
source: 'google',
|
||||
});
|
||||
|
||||
@@ -348,14 +335,16 @@ describe('Maps autocomplete', () => {
|
||||
await request(app)
|
||||
.post('/api/maps/autocomplete')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ input: 'test', lang: 'fr', locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } } });
|
||||
.send({
|
||||
input: 'test',
|
||||
lang: 'fr',
|
||||
locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
|
||||
});
|
||||
|
||||
expect(mapsService.autocompletePlaces).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
'test',
|
||||
'fr',
|
||||
{ low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
|
||||
);
|
||||
expect(mapsService.autocompletePlaces).toHaveBeenCalledWith(user.id, 'test', 'fr', {
|
||||
low: { lat: 48.5, lng: 2.0 },
|
||||
high: { lat: 49.0, lng: 2.8 },
|
||||
});
|
||||
});
|
||||
|
||||
it('MAPS-015 — autocomplete service error propagates correct status', async () => {
|
||||
|
||||
@@ -5,9 +5,19 @@
|
||||
* The MCP endpoint uses JWT auth and server-sent events / streaming HTTP.
|
||||
* Tests cover authentication, session management, rate limiting, and API token auth.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -20,13 +30,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -40,16 +66,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -73,9 +89,7 @@ describe('MCP authentication', () => {
|
||||
// then checks auth (401). In test DB the addon may be disabled.
|
||||
|
||||
it('MCP-001 — POST /mcp without auth returns 403 (addon disabled before auth check)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
const res = await request(app).post('/mcp').send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
// MCP handler checks addon enabled before verifying auth; addon is disabled in test DB
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -86,9 +100,7 @@ describe('MCP authentication', () => {
|
||||
});
|
||||
|
||||
it('MCP-001 — DELETE /mcp without auth returns 403 (addon disabled)', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/mcp')
|
||||
.set('Mcp-Session-Id', 'fake-session-id');
|
||||
const res = await request(app).delete('/mcp').set('Mcp-Session-Id', 'fake-session-id');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -105,7 +117,12 @@ describe('MCP session init', () => {
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
// Valid JWT + enabled addon → auth passes; SDK returns 200 with session headers
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -144,7 +161,12 @@ describe('MCP API token auth', () => {
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${rawToken}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -153,15 +175,24 @@ describe('MCP API token auth', () => {
|
||||
const { rawToken, id: tokenId } = createMcpToken(testDb, user.id);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const before = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
|
||||
const before = (
|
||||
testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }
|
||||
).last_used_at;
|
||||
|
||||
await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${rawToken}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
|
||||
const after = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
|
||||
const after = (
|
||||
testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }
|
||||
).last_used_at;
|
||||
expect(after).not.toBeNull();
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
@@ -179,9 +210,7 @@ describe('MCP API token auth', () => {
|
||||
it('MCP — POST /mcp with no Authorization header returns 401', async () => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
const res = await request(app).post('/mcp').send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -193,7 +222,12 @@ describe('MCP session management', () => {
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const sessionId = res.headers['mcp-session-id'];
|
||||
expect(sessionId).toBeTruthy();
|
||||
@@ -215,7 +249,12 @@ describe('MCP session management', () => {
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/session limit/i);
|
||||
});
|
||||
@@ -256,9 +295,7 @@ describe('MCP session management', () => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const res = await request(app).get('/mcp').set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -279,7 +316,12 @@ describe('MCP rate limiting', () => {
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: i + 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: i + 1,
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } },
|
||||
});
|
||||
// Each should pass (no rate limit hit yet since limit is read at module init,
|
||||
// but we can verify that the responses are not 429)
|
||||
expect(res.status).not.toBe(429);
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
* safeFetch is mocked to return fake Immich API responses based on URL patterns.
|
||||
* No real HTTP calls are made.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createTrip,
|
||||
addTripMember,
|
||||
addTripPhoto,
|
||||
addAlbumLink,
|
||||
setImmichCredentials,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +40,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -49,8 +69,9 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
// /api/users/me — used by status + test-connection
|
||||
if (u.includes('/api/users/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null },
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (h: string) => (h === 'content-type' ? 'application/json' : null) },
|
||||
json: () => Promise.resolve({ name: 'Test User', email: 'test@immich.local' }),
|
||||
body: null,
|
||||
});
|
||||
@@ -58,7 +79,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
// /api/timeline/buckets — browse
|
||||
if (u.includes('/api/timeline/buckets')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve([{ timeBucket: '2024-01-01T00:00:00.000Z', count: 3 }]),
|
||||
body: null,
|
||||
@@ -67,15 +89,21 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
// /api/search/metadata — search
|
||||
if (u.includes('/api/search/metadata')) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: [
|
||||
{ id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
assets: {
|
||||
items: [
|
||||
{
|
||||
id: 'asset-search-1',
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
@@ -83,57 +111,89 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
if (u.includes('/thumbnail')) {
|
||||
const imageBytes = Buffer.from('fake-thumbnail-data');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/webp' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (h: string) => (h === 'content-type' ? 'image/webp' : null) },
|
||||
body: new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(imageBytes);
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
// /api/assets/:id/original — original proxy
|
||||
if (u.includes('/original')) {
|
||||
const imageBytes = Buffer.from('fake-original-data');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (h: string) => (h === 'content-type' ? 'image/jpeg' : null) },
|
||||
body: new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(imageBytes);
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
// /api/assets/:id — asset info
|
||||
if (/\/api\/assets\/[^/]+$/.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
id: 'asset-info-1',
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
originalFileName: 'photo.jpg',
|
||||
exifInfo: {
|
||||
exifImageWidth: 4032, exifImageHeight: 3024,
|
||||
make: 'Apple', model: 'iPhone 15',
|
||||
lensModel: null, focalLength: 5.1, fNumber: 1.8,
|
||||
exposureTime: '1/500', iso: 100,
|
||||
city: 'Paris', state: 'Île-de-France', country: 'France',
|
||||
latitude: 48.8566, longitude: 2.3522,
|
||||
fileSizeInByte: 2048000,
|
||||
},
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'asset-info-1',
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
originalFileName: 'photo.jpg',
|
||||
exifInfo: {
|
||||
exifImageWidth: 4032,
|
||||
exifImageHeight: 3024,
|
||||
make: 'Apple',
|
||||
model: 'iPhone 15',
|
||||
lensModel: null,
|
||||
focalLength: 5.1,
|
||||
fNumber: 1.8,
|
||||
exposureTime: '1/500',
|
||||
iso: 100,
|
||||
city: 'Paris',
|
||||
state: 'Île-de-France',
|
||||
country: 'France',
|
||||
latitude: 48.8566,
|
||||
longitude: 2.3522,
|
||||
fileSizeInByte: 2048000,
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/albums — list albums (owned and shared?=true variant)
|
||||
if (/\/api\/albums(\?.*)?$/.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve([
|
||||
{ id: 'album-uuid-1', albumName: 'Vacation 2024', assetCount: 42, startDate: '2024-06-01', endDate: '2024-06-14', albumThumbnailAssetId: null },
|
||||
]),
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'album-uuid-1',
|
||||
albumName: 'Vacation 2024',
|
||||
assetCount: 42,
|
||||
startDate: '2024-06-01',
|
||||
endDate: '2024-06-14',
|
||||
albumThumbnailAssetId: null,
|
||||
},
|
||||
]),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/albums/:id — album detail (for sync)
|
||||
if (/\/api\/albums\//.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({ assets: [{ id: 'asset-sync-1', type: 'IMAGE' }] }),
|
||||
body: null,
|
||||
@@ -164,15 +224,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
const IMMICH = '/api/integrations/memories/immich';
|
||||
@@ -196,9 +247,7 @@ describe('Immich connection status', () => {
|
||||
it('IMMICH-030 — GET /status when not configured returns { connected: false }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/status`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
@@ -208,9 +257,7 @@ describe('Immich connection status', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/status`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
@@ -253,9 +300,7 @@ describe('Immich browse and search', () => {
|
||||
it('IMMICH-040 — GET /browse when not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/browse`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/browse`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
@@ -264,9 +309,7 @@ describe('Immich browse and search', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/browse`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/browse`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.buckets)).toBe(true);
|
||||
@@ -294,10 +337,7 @@ describe('Immich browse and search', () => {
|
||||
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('upstream unreachable'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post(`${IMMICH}/search`).set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBeDefined();
|
||||
@@ -381,7 +421,7 @@ describe('Immich asset proxy', () => {
|
||||
expect(res.body).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-055 — GET /assets/thumbnail for other\'s unshared photo returns 403', async () => {
|
||||
it("IMMICH-055 — GET /assets/thumbnail for other's unshared photo returns 403", async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -416,11 +456,15 @@ describe('Immich asset proxy', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id);
|
||||
const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
|
||||
).run(9999, owner.id, tkpNotrip.id, 1);
|
||||
testDb
|
||||
.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)')
|
||||
.run('immich', 'asset-notrip', owner.id);
|
||||
const tkpNotrip = testDb
|
||||
.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('immich', 'asset-notrip', owner.id) as any;
|
||||
testDb
|
||||
.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)')
|
||||
.run(9999, owner.id, tkpNotrip.id, 1);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
@@ -438,7 +482,8 @@ describe('Immich asset proxy', () => {
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-upstream-err', 'immich', { shared: false });
|
||||
|
||||
vi.mocked(safeFetch).mockResolvedValueOnce({
|
||||
ok: false, status: 503,
|
||||
ok: false,
|
||||
status: 503,
|
||||
headers: { get: () => null } as any,
|
||||
json: async () => ({}),
|
||||
} as any);
|
||||
@@ -458,9 +503,7 @@ describe('Immich albums', () => {
|
||||
it('IMMICH-060 — GET /albums when not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/albums`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
@@ -469,9 +512,7 @@ describe('Immich albums', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${IMMICH}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`${IMMICH}/albums`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
@@ -534,11 +575,15 @@ describe('Immich syncAlbumAssets', () => {
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare(`
|
||||
const photos = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.*, tkp.provider FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||
`).all(trip.id, user.id) as any[];
|
||||
`,
|
||||
)
|
||||
.all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('immich');
|
||||
});
|
||||
@@ -619,17 +664,19 @@ describe('Immich searchPhotos pagination pass-through', () => {
|
||||
|
||||
// Return a full page so hasMore=true (items.length >= size)
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `asset-p2-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `asset-p2-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
@@ -659,17 +706,19 @@ describe('Immich searchPhotos pagination pass-through', () => {
|
||||
|
||||
// Partial page → hasMore=false
|
||||
const partialPageResponse = {
|
||||
ok: true, status: 200,
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `asset-last-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `asset-last-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
@@ -734,7 +783,7 @@ describe('Immich testConnection canonical URL detection', () => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: 'https://immich.example.com/api/users/me',
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any,
|
||||
headers: { get: (h: string) => (h === 'content-type' ? 'application/json' : null) } as any,
|
||||
json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,17 @@
|
||||
* No real HTTP is made — safeFetch is mocked to never be called.
|
||||
* The broadcast WebSocket call is no-op mocked.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +32,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -47,14 +59,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
const BASE = '/api/integrations/memories/unified';
|
||||
@@ -74,7 +78,9 @@ afterAll(() => testDb.close());
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function photosUrl(tripId: number) { return `${BASE}/trips/${tripId}/photos`; }
|
||||
function photosUrl(tripId: number) {
|
||||
return `${BASE}/trips/${tripId}/photos`;
|
||||
}
|
||||
function albumLinksUrl(tripId: number, linkId?: number) {
|
||||
return linkId ? `${BASE}/trips/${tripId}/album-links/${linkId}` : `${BASE}/trips/${tripId}/album-links`;
|
||||
}
|
||||
@@ -92,9 +98,7 @@ describe('Unified photo management', () => {
|
||||
addTripPhoto(testDb, trip.id, owner.id, 'asset-own', 'immich', { shared: false });
|
||||
addTripPhoto(testDb, trip.id, member.id, 'asset-shared', 'immich', { shared: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const res = await request(app).get(photosUrl(trip.id)).set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||||
@@ -102,7 +106,7 @@ describe('Unified photo management', () => {
|
||||
expect(ids).toContain('asset-shared');
|
||||
});
|
||||
|
||||
it('UNIFIED-002 — GET photos excludes other members\' private photos', async () => {
|
||||
it("UNIFIED-002 — GET photos excludes other members' private photos", async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
@@ -110,9 +114,7 @@ describe('Unified photo management', () => {
|
||||
|
||||
addTripPhoto(testDb, trip.id, member.id, 'asset-private', 'immich', { shared: false });
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const res = await request(app).get(photosUrl(trip.id)).set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
|
||||
@@ -124,9 +126,7 @@ describe('Unified photo management', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(photosUrl(trip.id)).set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -146,11 +146,15 @@ describe('Unified photo management', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
const rows = testDb.prepare(`
|
||||
const rows = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
`,
|
||||
)
|
||||
.all(trip.id) as any[];
|
||||
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||||
});
|
||||
|
||||
@@ -158,10 +162,7 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ selections: [] });
|
||||
const res = await request(app).post(photosUrl(trip.id)).set('Cookie', authCookie(user.id)).send({ selections: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
@@ -182,11 +183,15 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
|
||||
const trekRef = testDb.prepare(`
|
||||
const trekRef = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-tog') as any;
|
||||
`,
|
||||
)
|
||||
.get(trip.id, 'asset-tog') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${photosUrl(trip.id)}/sharing`)
|
||||
@@ -194,11 +199,15 @@ describe('Unified photo management', () => {
|
||||
.send({ photo_id: trekRef.photo_id, shared: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare(`
|
||||
const row = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.shared FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-tog') as any;
|
||||
`,
|
||||
)
|
||||
.get('asset-tog') as any;
|
||||
expect(row.shared).toBe(1);
|
||||
});
|
||||
|
||||
@@ -219,11 +228,15 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
|
||||
const trekRef = testDb.prepare(`
|
||||
const trekRef = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-del') as any;
|
||||
`,
|
||||
)
|
||||
.get(trip.id, 'asset-del') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(photosUrl(trip.id))
|
||||
@@ -231,11 +244,15 @@ describe('Unified photo management', () => {
|
||||
.send({ photo_id: trekRef.photo_id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare(`
|
||||
const row = testDb
|
||||
.prepare(
|
||||
`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-del');
|
||||
`,
|
||||
)
|
||||
.get('asset-del');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -305,9 +322,7 @@ describe('Unified album-link management', () => {
|
||||
// Disable the immich provider
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich');
|
||||
|
||||
const res = await request(app)
|
||||
.get(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(albumLinksUrl(trip.id)).set('Cookie', authCookie(user.id));
|
||||
|
||||
// Re-enable for future tests
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Miscellaneous integration tests.
|
||||
* Covers MISC-001, 002, 004, 007, 008, 013, 015.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -74,9 +90,7 @@ describe('Addons list', () => {
|
||||
it('MISC-002 — GET /api/addons returns enabled addons', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
// Should only return enabled addons
|
||||
@@ -103,9 +117,7 @@ describe('Force HTTPS redirect', () => {
|
||||
} finally {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
const res = await request(httpsApp).get('/api/addons').set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
@@ -117,9 +129,7 @@ describe('Force HTTPS redirect', () => {
|
||||
} finally {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
const res = await request(httpsApp).get('/api/health').set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
@@ -127,10 +137,7 @@ describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
const res = await request(app).get('/api/health').set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
* External SMTP / webhook calls are not made — tests focus on preferences,
|
||||
* in-app notification CRUD, and authentication.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -20,13 +28,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -49,14 +73,6 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -78,9 +94,7 @@ describe('Notification preferences', () => {
|
||||
it('NOTIF-001 — GET /api/notifications/preferences returns defaults', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
});
|
||||
@@ -106,9 +120,7 @@ describe('In-app notifications', () => {
|
||||
it('NOTIF-008 — GET /api/notifications/in-app returns notifications array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/in-app').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.notifications)).toBe(true);
|
||||
});
|
||||
@@ -116,9 +128,7 @@ describe('In-app notifications', () => {
|
||||
it('NOTIF-008 — GET /api/notifications/in-app/unread-count returns count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/in-app/unread-count').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('count');
|
||||
expect(typeof res.body.count).toBe('number');
|
||||
@@ -127,9 +137,7 @@ describe('In-app notifications', () => {
|
||||
it('NOTIF-009 — PUT /api/notifications/in-app/read-all marks all read', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/in-app/read-all')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).put('/api/notifications/in-app/read-all').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -137,9 +145,7 @@ describe('In-app notifications', () => {
|
||||
it('NOTIF-010 — DELETE /api/notifications/in-app/all deletes all notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/notifications/in-app/all')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/notifications/in-app/all').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -147,18 +153,14 @@ describe('In-app notifications', () => {
|
||||
it('NOTIF-011 — PUT /api/notifications/in-app/:id/read on non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/notifications/in-app/99999/read')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).put('/api/notifications/in-app/99999/read').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('NOTIF-012 — DELETE /api/notifications/in-app/:id on non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/notifications/in-app/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/notifications/in-app/99999').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -170,9 +172,7 @@ describe('In-app notifications', () => {
|
||||
describe('GET /api/notifications/preferences — matrix format', () => {
|
||||
it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('preferences');
|
||||
expect(res.body).toHaveProperty('available_channels');
|
||||
@@ -183,36 +183,28 @@ describe('GET /api/notifications/preferences — matrix format', () => {
|
||||
|
||||
it('NROUTE-003 — regular user does not see version_available in event_types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004 — user preferences endpoint excludes version_available even for admins', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).not.toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-004b — admin notification preferences endpoint returns version_available', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/admin/notification-preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_types).toContain('version_available');
|
||||
});
|
||||
|
||||
it('NROUTE-005 — all preferences default to true for new user with no stored prefs', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { preferences } = res.body;
|
||||
for (const [, channels] of Object.entries(preferences)) {
|
||||
@@ -235,9 +227,7 @@ describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const getRes = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
});
|
||||
|
||||
@@ -253,9 +243,11 @@ describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.preferences['trip_invite']['email']).toBe(true);
|
||||
|
||||
const row = testDb.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
|
||||
).get(user.id, 'trip_invite', 'email');
|
||||
const row = testDb
|
||||
.prepare(
|
||||
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
|
||||
)
|
||||
.get(user.id, 'trip_invite', 'email');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -268,9 +260,7 @@ describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ trip_invite: { email: false } });
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const getRes = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.preferences['booking_change']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_invite']['email']).toBe(false);
|
||||
expect(getRes.body.preferences['trip_reminder']['email']).toBe(true);
|
||||
@@ -280,12 +270,18 @@ describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
describe('implemented_combos — in-app channel coverage', () => {
|
||||
it('NROUTE-010 — implemented_combos includes inapp for all event types', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/preferences').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const { implemented_combos } = res.body as { implemented_combos: Record<string, string[]> };
|
||||
const eventTypes = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
|
||||
const eventTypes = [
|
||||
'trip_invite',
|
||||
'booking_change',
|
||||
'trip_reminder',
|
||||
'vacay_invite',
|
||||
'photos_shared',
|
||||
'collab_message',
|
||||
'packing_tagged',
|
||||
];
|
||||
for (const event of eventTypes) {
|
||||
expect(implemented_combos[event], `${event} should support inapp`).toContain('inapp');
|
||||
expect(implemented_combos[event], `${event} should support email`).toContain('email');
|
||||
@@ -298,9 +294,7 @@ describe('Notification test endpoints', () => {
|
||||
it('NOTIF-005 — POST /api/notifications/test-smtp requires admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-smtp')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post('/api/notifications/test-smtp').set('Cookie', authCookie(user.id));
|
||||
// Non-admin gets 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -308,10 +302,7 @@ describe('Notification test endpoints', () => {
|
||||
it('NOTIF-006 — POST /api/notifications/test-webhook returns 400 when url is missing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/notifications/test-webhook').set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -352,10 +343,7 @@ describe('Notification test endpoints', () => {
|
||||
it('NOTIF-007 — POST /api/notifications/test-ntfy returns 400 when no topic configured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-ntfy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/notifications/test-ntfy').set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
@@ -375,12 +363,11 @@ describe('Notification test endpoints', () => {
|
||||
|
||||
it('NOTIF-009 — POST /api/notifications/test-ntfy falls back to user saved topic', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')").run(user.id);
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')")
|
||||
.run(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-ntfy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/notifications/test-ntfy').set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('success');
|
||||
@@ -392,7 +379,9 @@ describe('Notification test endpoints', () => {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
const result = testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
@@ -401,17 +390,23 @@ function insertBooleanNotification(recipientId: number): number {
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, recipientId);
|
||||
`,
|
||||
)
|
||||
.run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
const result = testDb
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
`,
|
||||
)
|
||||
.run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
@@ -493,9 +488,7 @@ describe('PUT /api/admin/notification-preferences', () => {
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(putRes.body.preferences['version_available']['email']).toBe(false);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/notification-preferences')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const getRes = await request(app).get('/api/admin/notification-preferences').set('Cookie', authCookie(user.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.preferences['version_available']['email']).toBe(false);
|
||||
});
|
||||
@@ -522,9 +515,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/in-app').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(2);
|
||||
@@ -537,9 +528,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/in-app/unread-count').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
@@ -549,9 +538,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const markRes = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/read`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const markRes = await request(app).put(`/api/notifications/in-app/${id}/read`).set('Cookie', authCookie(user.id));
|
||||
expect(markRes.status).toBe(200);
|
||||
expect(markRes.body.success).toBe(true);
|
||||
|
||||
@@ -567,9 +554,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
// Mark read first
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/unread`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).put(`/api/notifications/in-app/${id}/unread`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -581,9 +566,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notifications/in-app/${id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/notifications/in-app/${id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -598,9 +581,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
// Mark first one read
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app?unread_only=true')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/notifications/in-app?unread_only=true').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.length).toBe(1);
|
||||
|
||||
+1101
-1149
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,17 @@
|
||||
* HTTP calls (discover, exchangeCodeForToken, getUserInfo) are mocked.
|
||||
* State management, auth codes, and findOrCreateUser run against the real test DB.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
||||
|
||||
@@ -22,7 +30,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -52,14 +64,6 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
|
||||
const mockDiscover = vi.mocked(oidcService.discover);
|
||||
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
|
||||
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
|
||||
@@ -170,7 +174,12 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
|
||||
it('OIDC-005: new user gets created when registration is open', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
mockExchangeCode.mockResolvedValueOnce({
|
||||
access_token: 'new-token',
|
||||
id_token: 'fake.id.token',
|
||||
_ok: true,
|
||||
_status: 200,
|
||||
});
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub: 'sub-newuser-999' } });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-newuser-999',
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Packing List integration tests.
|
||||
* Covers PACK-001 to PACK-014.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -116,9 +132,7 @@ describe('List packing items', () => {
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
createPackingItem(testDb, trip.id, { name: 'Shirt', category: 'Clothing' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(2);
|
||||
});
|
||||
@@ -130,9 +144,7 @@ describe('List packing items', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Jacket' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
});
|
||||
@@ -184,9 +196,7 @@ describe('Delete packing item', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/packing`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -290,9 +300,7 @@ describe('Bags', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Main Bag' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/packing/bags`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bags).toHaveLength(1);
|
||||
});
|
||||
@@ -373,8 +381,12 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
|
||||
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
|
||||
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
|
||||
const cat = testDb
|
||||
.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)")
|
||||
.run(tpl.lastInsertRowid);
|
||||
testDb
|
||||
.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)")
|
||||
.run(cat.lastInsertRowid);
|
||||
const templateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
* - PLACE-014: reordering within a day is tested in assignments.test.ts
|
||||
* - PLACE-019: GPX bulk import tested here using the test fixture
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -23,13 +33,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -51,16 +77,6 @@ vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
|
||||
@@ -163,9 +179,7 @@ describe('List places', () => {
|
||||
createPlace(testDb, trip.id, { name: 'Place A' });
|
||||
createPlace(testDb, trip.id, { name: 'Place B' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(2);
|
||||
});
|
||||
@@ -177,9 +191,7 @@ describe('List places', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
createPlace(testDb, trip.id, { name: 'Shared Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places`).set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
});
|
||||
@@ -189,9 +201,7 @@ describe('List places', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places`).set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -225,9 +235,7 @@ describe('Get place', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Test Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.place.id).toBe(place.id);
|
||||
expect(Array.isArray(res.body.place.tags)).toBe(true);
|
||||
@@ -237,9 +245,7 @@ describe('Get place', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places/99999`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -291,9 +297,7 @@ describe('Delete place', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const get = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -321,9 +325,7 @@ describe('Tags', () => {
|
||||
// Create a tag in DB
|
||||
testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('Must-see', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toBeDefined();
|
||||
const names = (res.body.tags as any[]).map((t: any) => t.name);
|
||||
@@ -356,9 +358,7 @@ describe('Tags', () => {
|
||||
const tagResult = testDb.prepare('INSERT INTO tags (name, user_id) VALUES (?, ?)').run('OldTag', user.id);
|
||||
const tagId = tagResult.lastInsertRowid as number;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/tags/${tagId}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const tags = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||
@@ -463,9 +463,7 @@ describe('Search places', () => {
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places?search=Eiffel`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places?search=Eiffel`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
expect(res.body.places[0].name).toBe('Eiffel Tower');
|
||||
@@ -487,9 +485,7 @@ describe('Search places', () => {
|
||||
|
||||
createPlace(testDb, trip.id, { name: 'Plain Place' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places?tag=${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places?tag=${tagId}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.places).toHaveLength(1);
|
||||
expect(res.body.places[0].name).toBe('Scenic Place');
|
||||
@@ -503,9 +499,7 @@ describe('Search places', () => {
|
||||
describe('Categories', () => {
|
||||
it('PLACE-015 — GET /api/categories returns all categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/categories').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
@@ -532,7 +526,8 @@ describe('Naver list import', () => {
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}`,
|
||||
@@ -543,7 +538,13 @@ describe('Naver list import', () => {
|
||||
folder: { name: 'Seoul Food', bookmarkCount: 22 },
|
||||
bookmarkList: [
|
||||
{ name: 'SINSAJEON', px: 127.0226195, py: 37.5186363, memo: null, address: 'Sinsa-dong Seoul' },
|
||||
{ name: 'Ilpyeondeungsim', px: 126.9852986, py: 37.5629334, memo: 'Try lunch set', address: 'Myeong-dong Seoul' },
|
||||
{
|
||||
name: 'Ilpyeondeungsim',
|
||||
px: 126.9852986,
|
||||
py: 37.5629334,
|
||||
memo: 'Try lunch set',
|
||||
address: 'Myeong-dong Seoul',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
@@ -600,8 +601,7 @@ describe('Naver list import', () => {
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: false });
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: false });
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -677,7 +677,7 @@ describe('Naver list import', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
folder: { name: 'Seoul', bookmarkCount: 1 },
|
||||
bookmarkList: [{ name: 'Gyeongbokgung', px: 126.9770, py: 37.5796, memo: null, address: 'Sejongno Seoul' }],
|
||||
bookmarkList: [{ name: 'Gyeongbokgung', px: 126.977, py: 37.5796, memo: null, address: 'Sejongno Seoul' }],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -716,9 +716,7 @@ describe('GPX Import', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/places/import/gpx`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -732,7 +730,8 @@ describe('KML/KMZ Import', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
|
||||
testDb
|
||||
.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
|
||||
.run('Museums', '#3b82f6', 'Landmark', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -758,7 +757,8 @@ describe('KML/KMZ Import', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
|
||||
testDb
|
||||
.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)')
|
||||
.run('Parks', '#22c55e', 'Trees', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -800,7 +800,9 @@ describe('KML/KMZ Import', () => {
|
||||
|
||||
const prefix = Buffer.from('<?xml version="1.0"?><kml><Document><Placemark><name>Caf');
|
||||
const invalidByte = Buffer.from([0xe9]); // invalid UTF-8 sequence when used standalone
|
||||
const suffix = Buffer.from('</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark></Document></kml>');
|
||||
const suffix = Buffer.from(
|
||||
'</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark></Document></kml>',
|
||||
);
|
||||
const nonUtf8Kml = Buffer.concat([prefix, invalidByte, suffix]);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -853,8 +855,7 @@ describe('GPX Import — edge cases', () => {
|
||||
|
||||
// Minimal valid GPX with no waypoints, tracks, or routes
|
||||
const emptyGpx = Buffer.from(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>'
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' + '<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>',
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -1011,9 +1012,7 @@ describe('Delete place — not found', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}/places/99999`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
* User Profile & Settings integration tests.
|
||||
* Covers PROFILE-001 to PROFILE-015.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -18,13 +26,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -38,14 +62,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
@@ -72,9 +88,7 @@ afterAll(() => {
|
||||
describe('PROFILE-001 — Get current user profile', () => {
|
||||
it('returns user object with expected fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({
|
||||
id: user.id,
|
||||
@@ -113,19 +127,12 @@ describe('Avatar', () => {
|
||||
it('PROFILE-005 — DELETE /api/auth/avatar clears avatar_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Upload first
|
||||
await request(app)
|
||||
.post('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('avatar', FIXTURE_JPEG);
|
||||
await request(app).post('/api/auth/avatar').set('Cookie', authCookie(user.id)).attach('avatar', FIXTURE_JPEG);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/avatar')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/auth/avatar').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const me = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(me.body.user.avatar_url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -170,19 +177,14 @@ describe('Settings', () => {
|
||||
.send({ key: 'dark_mode', value: 'dark' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
const get = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get('/api/settings').set('Cookie', authCookie(user.id));
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.settings).toHaveProperty('dark_mode', 'dark');
|
||||
});
|
||||
|
||||
it('PROFILE-009 — PUT /api/settings without key returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ value: 'dark' });
|
||||
const res = await request(app).put('/api/settings').set('Cookie', authCookie(user.id)).send({ value: 'dark' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -196,9 +198,7 @@ describe('Settings', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const get = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const get = await request(app).get('/api/settings').set('Cookie', authCookie(user.id));
|
||||
expect(get.body.settings).toHaveProperty('theme', 'dark');
|
||||
expect(get.body.settings).toHaveProperty('language', 'fr');
|
||||
expect(get.body.settings).toHaveProperty('timezone', 'Europe/Paris');
|
||||
@@ -209,24 +209,18 @@ describe('Account deletion', () => {
|
||||
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
// Should not be able to log in
|
||||
const login = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const login = await request(app).post('/api/auth/login').send({ email: user.email, password });
|
||||
expect(login.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PROFILE-013 — admin cannot delete their own account', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
// Admins are protected from self-deletion
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete('/api/auth/me').set('Cookie', authCookie(admin.id));
|
||||
// deleteAccount returns 400 when the user is the last admin
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
@@ -241,9 +235,7 @@ describe('Travel stats', () => {
|
||||
end_date: '2024-06-05',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/travel-stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/auth/travel-stats').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('totalTrips');
|
||||
expect(res.body.totalTrips).toBeGreaterThanOrEqual(1);
|
||||
@@ -253,9 +245,11 @@ describe('Travel stats', () => {
|
||||
describe('Demo mode protections', () => {
|
||||
it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => {
|
||||
// demoUploadBlock checks for email === 'demo@nomad.app'
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')"
|
||||
).run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')",
|
||||
)
|
||||
.run();
|
||||
const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number };
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Reservations integration tests.
|
||||
* Covers RESV-001 to RESV-007.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +61,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -128,9 +144,7 @@ describe('List reservations', () => {
|
||||
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight' });
|
||||
createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/reservations`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservations).toHaveLength(2);
|
||||
});
|
||||
@@ -139,9 +153,7 @@ describe('List reservations', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/reservations`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservations).toHaveLength(0);
|
||||
});
|
||||
@@ -151,9 +163,7 @@ describe('List reservations', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/reservations`).set('Cookie', authCookie(other.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -242,9 +252,7 @@ describe('Delete reservation', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const list = await request(app).get(`/api/trips/${trip.id}/reservations`).set('Cookie', authCookie(user.id));
|
||||
expect(list.body.reservations).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -273,7 +281,12 @@ describe('Batch update positions', () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/positions`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ positions: [{ id: r2.id, position: 0 }, { id: r1.id, position: 1 }] });
|
||||
.send({
|
||||
positions: [
|
||||
{ id: r2.id, position: 0 },
|
||||
{ id: r1.id, position: 1 },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
@@ -320,9 +333,7 @@ describe('Reservation budget entry integration', () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItems = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ?')
|
||||
.all(trip.id) as any[];
|
||||
const budgetItems = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(trip.id) as any[];
|
||||
expect(budgetItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -436,9 +447,7 @@ describe('Reservation accommodation delete', () => {
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
// Verify accommodation was created
|
||||
const accom = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
const accom = testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
|
||||
expect(accom).toBeDefined();
|
||||
|
||||
// Delete reservation — should also remove the accommodation
|
||||
@@ -447,9 +456,7 @@ describe('Reservation accommodation delete', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const accomAfter = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE id = ?'
|
||||
).get(accom.id);
|
||||
const accomAfter = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accom.id);
|
||||
expect(accomAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -473,9 +480,9 @@ describe('Reservation accommodation delete', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
const budgetBefore = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?'
|
||||
).get(trip.id, reservationId);
|
||||
const budgetBefore = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, reservationId);
|
||||
expect(budgetBefore).toBeDefined();
|
||||
|
||||
// Delete via the reservation endpoint
|
||||
@@ -484,9 +491,7 @@ describe('Reservation accommodation delete', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const budgetAfter = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
const budgetAfter = testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').get(trip.id);
|
||||
expect(budgetAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
* - SEC-015 (MFA backup codes) is covered in auth.test.ts
|
||||
* - These tests focus on HTTP-level security: headers, auth, injection protection, etc.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -24,13 +32,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -44,14 +68,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
@@ -83,9 +99,7 @@ describe('Authentication security', () => {
|
||||
// The file download endpoint accepts bearer auth
|
||||
// Other endpoints use cookie auth — but /api/auth/me works with cookie auth
|
||||
// Test that a forged/invalid JWT is rejected
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid.token.here');
|
||||
const res = await request(app).get('/api/auth/me').set('Authorization', 'Bearer invalid.token.here');
|
||||
// Should return 401 (auth fails)
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -96,9 +110,7 @@ describe('Authentication security', () => {
|
||||
});
|
||||
|
||||
it('expired/invalid JWT cookie returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', 'trek_session=invalid.jwt.token');
|
||||
const res = await request(app).get('/api/trips').set('Cookie', 'trek_session=invalid.jwt.token');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -135,9 +147,7 @@ describe('API key encryption', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'secret-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const me = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(me.body.user.openweather_api_key).not.toBe('secret-key');
|
||||
});
|
||||
});
|
||||
@@ -146,9 +156,7 @@ describe('MFA secret protection', () => {
|
||||
it('SEC-009 — GET /api/auth/me does not expose mfa_secret', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(res.body.user.mfa_secret).toBeUndefined();
|
||||
expect(res.body.user.password_hash).toBeUndefined();
|
||||
});
|
||||
@@ -159,9 +167,7 @@ describe('Request body size limit', () => {
|
||||
// Send a large body (2MB+) to exceed the default limit
|
||||
const bigData = { data: 'x'.repeat(2 * 1024 * 1024) };
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send(bigData);
|
||||
const res = await request(app).post('/api/auth/login').send(bigData);
|
||||
// body-parser rejects oversized payloads with 413
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
@@ -181,9 +187,7 @@ describe('File download path traversal', () => {
|
||||
|
||||
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
.set(authHeader(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/files/${fileId}/download`).set(authHeader(user.id));
|
||||
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Settings integration tests — SET-001 through SET-008.
|
||||
* Covers GET /api/settings, PUT /api/settings, POST /api/settings/bulk.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -18,7 +26,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -32,14 +44,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -60,9 +64,7 @@ afterAll(() => {
|
||||
describe('Settings', () => {
|
||||
it('SET-001: GET /api/settings returns empty object for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/settings').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings).toBeDefined();
|
||||
expect(typeof res.body.settings).toBe('object');
|
||||
@@ -84,10 +86,7 @@ describe('Settings', () => {
|
||||
|
||||
it('SET-003: PUT /api/settings updates an existing key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'dark' });
|
||||
await request(app).put('/api/settings').set('Cookie', authCookie(user.id)).send({ key: 'theme', value: 'dark' });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
@@ -98,9 +97,7 @@ describe('Settings', () => {
|
||||
expect(res.body.value).toBe('light');
|
||||
|
||||
// Verify the GET reflects the updated value
|
||||
const getRes = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const getRes = await request(app).get('/api/settings').set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.settings.theme).toBe('light');
|
||||
});
|
||||
|
||||
@@ -122,9 +119,7 @@ describe('Settings', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'fr' } });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/settings').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.theme).toBe('dark');
|
||||
expect(res.body.settings.language).toBe('fr');
|
||||
@@ -137,10 +132,7 @@ describe('Settings', () => {
|
||||
|
||||
it('SET-007: PUT /api/settings without key returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ value: 'dark' });
|
||||
const res = await request(app).put('/api/settings').set('Cookie', authCookie(user.id)).send({ value: 'dark' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
@@ -180,9 +172,7 @@ describe('Settings', () => {
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ key: 'secret_setting', value: 'user_a_secret' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
const res = await request(app).get('/api/settings').set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.secret_setting).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
* Share link integration tests.
|
||||
* Covers SHARE-001 to SHARE-009.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createTrip,
|
||||
addTripMember,
|
||||
createDay,
|
||||
createPlace,
|
||||
createDayAssignment,
|
||||
createDayNote,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +33,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -37,14 +69,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -67,10 +91,7 @@ describe('Share link CRUD', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toBeDefined();
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
@@ -109,14 +130,9 @@ describe('Share link CRUD', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
@@ -125,9 +141,7 @@ describe('Share link CRUD', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBeNull();
|
||||
});
|
||||
@@ -136,20 +150,13 @@ describe('Share link CRUD', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const del = await request(app).delete(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const status = await request(app)
|
||||
.get(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const status = await request(app).get(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id));
|
||||
expect(status.body.token).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -253,7 +260,9 @@ describe('Shared trip — day assignments and notes', () => {
|
||||
it('SHARE-012 — share_collab=true includes collab messages in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!');
|
||||
testDb
|
||||
.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)')
|
||||
.run(trip.id, user.id, 'Hello team!');
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
@@ -295,17 +304,20 @@ describe('Shared trip — ordering parity (issue #981)', () => {
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Second Created' });
|
||||
|
||||
// Both with order_index = 0 (schema default) but different created_at
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
|
||||
).run(day.id, place1.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
|
||||
).run(day.id, place2.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')",
|
||||
)
|
||||
.run(day.id, place1.id);
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')",
|
||||
)
|
||||
.run(day.id, place2.id);
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const {
|
||||
body: { token },
|
||||
} = await request(app).post(`/api/trips/${trip.id}/share-link`).set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -320,17 +332,19 @@ describe('Shared trip — ordering parity (issue #981)', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||
|
||||
const res1 = testDb.prepare(
|
||||
"INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
|
||||
const res1 = testDb
|
||||
.prepare('INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
|
||||
const reservationId = Number(res1.lastInsertRowid);
|
||||
|
||||
// Insert a per-day position
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
|
||||
).run(reservationId, day.id, 1.5);
|
||||
testDb
|
||||
.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)')
|
||||
.run(reservationId, day.id, 1.5);
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
const {
|
||||
body: { token },
|
||||
} = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_bookings: true });
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
* System Notices API integration tests.
|
||||
* Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
|
||||
import type { SystemNotice } from '../../src/systemNotices/types';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -35,15 +44,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
|
||||
import type { SystemNotice } from '../../src/systemNotices/types';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Test notice injected into the registry for notice-specific tests
|
||||
@@ -87,9 +87,7 @@ describe('GET /api/system-notices/active', () => {
|
||||
// login_count > 1 means firstLogin condition does not match for any notice;
|
||||
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
@@ -101,9 +99,7 @@ describe('GET /api/system-notices/active', () => {
|
||||
// Set login_count to 1 (first login)
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
|
||||
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
|
||||
@@ -125,9 +121,7 @@ describe('GET /api/system-notices/active', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
} finally {
|
||||
@@ -143,13 +137,11 @@ describe('GET /api/system-notices/active', () => {
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
// Dismiss the notice directly in DB
|
||||
testDb.prepare(
|
||||
'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)'
|
||||
).run(user.id, TEST_NOTICE.id, Date.now());
|
||||
testDb
|
||||
.prepare('INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)')
|
||||
.run(user.id, TEST_NOTICE.id, Date.now());
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// TEST_NOTICE should be filtered out; welcome-v1 may still appear
|
||||
const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
|
||||
@@ -220,20 +212,14 @@ describe('POST /api/system-notices/:id/dismiss', () => {
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
// Confirm TEST_NOTICE is visible before dismiss
|
||||
const before = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const before = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(before.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeDefined();
|
||||
|
||||
// Dismiss it
|
||||
await request(app)
|
||||
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
await request(app).post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`).set('Cookie', authCookie(user.id));
|
||||
|
||||
// Confirm TEST_NOTICE is gone; other notices (e.g. welcome-v1) may still appear
|
||||
const after = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const after = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(after.status).toBe(200);
|
||||
expect(after.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined();
|
||||
} finally {
|
||||
@@ -276,11 +262,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
|
||||
it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => {
|
||||
const user = setupCollisionAdmin();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')")
|
||||
.run();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
|
||||
@@ -289,9 +275,7 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
it('SN-COLLISION-2 — hidden when collision flag is absent', async () => {
|
||||
const user = setupCollisionAdmin();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
@@ -299,11 +283,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
|
||||
it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => {
|
||||
const user = setupCollisionAdmin();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')")
|
||||
.run();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
@@ -312,11 +296,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')")
|
||||
.run();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
@@ -325,11 +309,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')")
|
||||
.run();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
@@ -338,11 +322,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => {
|
||||
process.env.APP_VERSION = '3.0.13';
|
||||
const user = setupCollisionAdmin();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')")
|
||||
.run();
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
@@ -350,11 +334,11 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
|
||||
it('SN-COLLISION-7 — hidden after admin dismisses it', async () => {
|
||||
const user = setupCollisionAdmin();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')")
|
||||
.run();
|
||||
|
||||
const before = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const before = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(before.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
|
||||
|
||||
const dismiss = await request(app)
|
||||
@@ -362,9 +346,7 @@ describe('v3014-whitespace-collision notice', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(dismiss.status).toBe(204);
|
||||
|
||||
const after = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const after = await request(app).get('/api/system-notices/active').set('Cookie', authCookie(user.id));
|
||||
expect(after.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Tags integration tests — TAG-001 through TAG-010.
|
||||
* Covers GET/POST/PUT/DELETE /api/tags.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -18,7 +26,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -32,14 +44,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -60,19 +64,14 @@ afterAll(() => {
|
||||
describe('Tags', () => {
|
||||
it('TAG-001: GET /api/tags returns empty array for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('TAG-002: POST /api/tags creates a tag with default color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Must See' });
|
||||
const res = await request(app).post('/api/tags').set('Cookie', authCookie(user.id)).send({ name: 'Must See' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tag).toMatchObject({ name: 'Must See', user_id: user.id });
|
||||
expect(res.body.tag.id).toBeDefined();
|
||||
@@ -91,10 +90,7 @@ describe('Tags', () => {
|
||||
|
||||
it('TAG-004: POST /api/tags without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ color: '#ff0000' });
|
||||
const res = await request(app).post('/api/tags').set('Cookie', authCookie(user.id)).send({ color: '#ff0000' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
@@ -141,16 +137,12 @@ describe('Tags', () => {
|
||||
.send({ name: 'To Delete' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/tags/${tagId}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone
|
||||
const listRes = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get('/api/tags').set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -163,23 +155,16 @@ describe('Tags', () => {
|
||||
.send({ name: 'User A Tag' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
const res = await request(app).delete(`/api/tags/${tagId}`).set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TAG-009: Tags are user-scoped — user A cannot see user B tags', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Private Tag' });
|
||||
await request(app).post('/api/tags').set('Cookie', authCookie(userA.id)).send({ name: 'User A Private Tag' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
const res = await request(app).get('/api/tags').set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
* Todo integration tests — TODO-001 through TODO-012.
|
||||
* Covers all endpoints at /api/trips/:tripId/todo.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -18,7 +27,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -32,15 +45,6 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -63,9 +67,7 @@ describe('Todo items', () => {
|
||||
it('TODO-001: GET /api/trips/:id/todo returns empty items for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/todo`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toEqual([]);
|
||||
});
|
||||
@@ -85,15 +87,12 @@ describe('Todo items', () => {
|
||||
it('TODO-003: POST /api/trips/:id/todo creates a todo with all optional fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/todo`).set('Cookie', authCookie(user.id)).send({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item).toMatchObject({
|
||||
name: 'Pack suitcase',
|
||||
@@ -166,16 +165,12 @@ describe('Todo items', () => {
|
||||
.send({ name: 'To Delete' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}/todo/${itemId}`).set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone from list
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get(`/api/trips/${trip.id}/todo`).set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -210,7 +205,9 @@ describe('Todo items', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify the new order in the DB
|
||||
const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
|
||||
const items = testDb
|
||||
.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(trip.id) as any[];
|
||||
expect(items[0].id).toBe(id3);
|
||||
expect(items[1].id).toBe(id2);
|
||||
expect(items[2].id).toBe(id1);
|
||||
@@ -221,9 +218,7 @@ describe('Todo items', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/todo`).set('Cookie', authCookie(stranger.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -234,9 +229,7 @@ describe('Todo items', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Member can read
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const getRes = await request(app).get(`/api/trips/${trip.id}/todo`).set('Cookie', authCookie(member.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
// Member can create
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
* Trips API integration tests.
|
||||
* Covers TRIP-001 through TRIP-022.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
createTrip,
|
||||
addTripMember,
|
||||
createPlace,
|
||||
createReservation,
|
||||
createTag,
|
||||
createDayAccommodation,
|
||||
createBudgetItem,
|
||||
createPackingItem,
|
||||
createDayNote,
|
||||
createDayAssignment,
|
||||
} from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -21,16 +43,32 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -45,25 +83,21 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create trip (TRIP-001, TRIP-002, TRIP-003)
|
||||
@@ -103,7 +137,9 @@ describe('Create trip', () => {
|
||||
expect(res.body.trip.end_date).toBeNull();
|
||||
|
||||
// Should have 7 dateless placeholder days
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(res.body.trip.id) as any[];
|
||||
const days = testDb
|
||||
.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number')
|
||||
.all(res.body.trip.id) as any[];
|
||||
expect(days).toHaveLength(7);
|
||||
expect(days[0].date).toBeNull();
|
||||
});
|
||||
@@ -170,10 +206,7 @@ describe('Create trip', () => {
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ title: 'Admin Trip' });
|
||||
const res = await request(app).post('/api/trips').set('Cookie', authCookie(admin.id)).send({ title: 'Admin Trip' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
@@ -201,18 +234,14 @@ describe('List trips', () => {
|
||||
// Add member to one of stranger's trips
|
||||
addTripMember(testDb, memberTrip.id, member.id);
|
||||
|
||||
const ownerRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const ownerRes = await request(app).get('/api/trips').set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(ownerRes.status).toBe(200);
|
||||
const ownerTripIds = ownerRes.body.trips.map((t: any) => t.id);
|
||||
expect(ownerTripIds).toContain(ownTrip.id);
|
||||
expect(ownerTripIds).not.toContain(memberTrip.id);
|
||||
|
||||
const memberRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const memberRes = await request(app).get('/api/trips').set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(memberRes.status).toBe(200);
|
||||
const memberTripIds = memberRes.body.trips.map((t: any) => t.id);
|
||||
@@ -229,9 +258,7 @@ describe('List trips', () => {
|
||||
// Archive the second trip directly in the DB
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||
@@ -247,9 +274,7 @@ describe('List trips', () => {
|
||||
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archivedTrip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips?archived=1')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/trips?archived=1').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tripIds = res.body.trips.map((t: any) => t.id);
|
||||
@@ -267,9 +292,7 @@ describe('Get trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A lovely trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
@@ -283,24 +306,19 @@ describe('Get trip', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(other.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
|
||||
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
@@ -310,9 +328,7 @@ describe('Get trip', () => {
|
||||
it('TRIP-006 — GET /api/trips/:id for non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips/999999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/trips/999999').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -350,9 +366,7 @@ describe('Update trip', () => {
|
||||
expect(archiveRes.body.trip.is_archived).toBe(1);
|
||||
|
||||
// Should not appear in the normal list
|
||||
const listRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
|
||||
|
||||
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).not.toContain(trip.id);
|
||||
@@ -375,9 +389,7 @@ describe('Update trip', () => {
|
||||
expect(unarchiveRes.body.trip.is_archived).toBe(0);
|
||||
|
||||
// Should appear in the normal list again
|
||||
const listRes = await request(app)
|
||||
.get('/api/trips')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const listRes = await request(app).get('/api/trips').set('Cookie', authCookie(user.id));
|
||||
|
||||
const tripIds = listRes.body.trips.map((t: any) => t.id);
|
||||
expect(tripIds).toContain(trip.id);
|
||||
@@ -435,7 +447,10 @@ describe('Update trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-05' });
|
||||
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[];
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as {
|
||||
id: number;
|
||||
date: string;
|
||||
}[];
|
||||
expect(days).toHaveLength(5);
|
||||
|
||||
const place = createPlace(testDb, trip.id);
|
||||
@@ -450,15 +465,28 @@ describe('Update trip', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as {
|
||||
id: number;
|
||||
date: string | null;
|
||||
}[];
|
||||
expect(daysAfter).toHaveLength(5);
|
||||
expect(daysAfter.map(d => d.date)).toEqual(['2026-08-11', '2026-08-12', '2026-08-13', '2026-08-14', '2026-08-15']);
|
||||
expect(daysAfter.map((d) => d.date)).toEqual([
|
||||
'2026-08-11',
|
||||
'2026-08-12',
|
||||
'2026-08-13',
|
||||
'2026-08-14',
|
||||
'2026-08-15',
|
||||
]);
|
||||
|
||||
const assignmentsAfter = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(assignment.id) as { day_id: number } | undefined;
|
||||
const assignmentsAfter = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(assignment.id) as
|
||||
| { day_id: number }
|
||||
| undefined;
|
||||
expect(assignmentsAfter).toBeDefined();
|
||||
expect(assignmentsAfter!.day_id).toBe(daysAfter[0].id);
|
||||
|
||||
const notesAfter = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id) as { day_id: number } | undefined;
|
||||
const notesAfter = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id) as
|
||||
| { day_id: number }
|
||||
| undefined;
|
||||
expect(notesAfter).toBeDefined();
|
||||
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
|
||||
});
|
||||
@@ -467,7 +495,9 @@ describe('Update trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
|
||||
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as {
|
||||
id: number;
|
||||
}[];
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const a4 = createDayAssignment(testDb, days[3].id, place.id);
|
||||
const a5 = createDayAssignment(testDb, days[4].id, place.id);
|
||||
@@ -480,12 +510,17 @@ describe('Update trip', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as {
|
||||
id: number;
|
||||
date: string | null;
|
||||
}[];
|
||||
expect(daysAfter).toHaveLength(3);
|
||||
expect(daysAfter.every(d => d.date !== null)).toBe(true);
|
||||
expect(daysAfter.every((d) => d.date !== null)).toBe(true);
|
||||
|
||||
// Overflow days and their assignments deleted
|
||||
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
|
||||
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as {
|
||||
id: number;
|
||||
}[];
|
||||
expect(all).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -499,17 +534,13 @@ describe('Delete trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'To Delete' });
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const deleteRes = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.body.success).toBe(true);
|
||||
|
||||
// Trip should no longer be accessible
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const getRes = await request(app).get(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
@@ -519,9 +550,7 @@ describe('Delete trip', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "Owner's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(other.id));
|
||||
|
||||
// getTripOwner finds the trip (it exists); checkPermission fails for non-members → 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -537,9 +566,7 @@ describe('Delete trip', () => {
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/permission/i);
|
||||
@@ -553,9 +580,7 @@ describe('Delete trip', () => {
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const deleteRes = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.body.success).toBe(true);
|
||||
@@ -573,9 +598,7 @@ describe('Delete trip', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: "User's Trip" });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const res = await request(app).delete(`/api/trips/${trip.id}`).set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
@@ -584,9 +607,7 @@ describe('Delete trip', () => {
|
||||
it('TRIP-018 — DELETE /api/trips/:id for non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/trips/999999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/trips/999999').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -603,9 +624,7 @@ describe('Trip members', () => {
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/members`).set('Cookie', authCookie(owner.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.owner).toBeDefined();
|
||||
@@ -631,7 +650,9 @@ describe('Trip members', () => {
|
||||
expect(res.body.member.role).toBe('member');
|
||||
|
||||
// Verify in DB
|
||||
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id);
|
||||
const dbEntry = testDb
|
||||
.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(trip.id, invitee.id);
|
||||
expect(dbEntry).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -705,7 +726,9 @@ describe('Trip members', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify removal in DB
|
||||
const dbEntry = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||||
const dbEntry = testDb
|
||||
.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(trip.id, member.id);
|
||||
expect(dbEntry).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -731,7 +754,9 @@ describe('Trip members', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Restrict member management to trip_owner (default)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')").run();
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_member_manage', 'trip_owner')")
|
||||
.run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
@@ -748,9 +773,7 @@ describe('Trip members', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/members`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/members`).set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -765,10 +788,7 @@ describe('Copy trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
@@ -795,10 +815,7 @@ describe('Copy trip', () => {
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({});
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(member.id)).send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
|
||||
@@ -810,10 +827,7 @@ describe('Copy trip', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({});
|
||||
const res = await request(app).post(`/api/trips/${trip.id}/copy`).set('Cookie', authCookie(stranger.id)).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -821,10 +835,7 @@ describe('Copy trip', () => {
|
||||
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips/999999/copy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const res = await request(app).post('/api/trips/999999/copy').set('Cookie', authCookie(user.id)).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -839,9 +850,7 @@ describe('ICS export', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
||||
@@ -854,9 +863,7 @@ describe('ICS export', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`).set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -892,9 +899,9 @@ describe('Copy trip with data', () => {
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Day assignment
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
|
||||
).run(days[0].id, place.id, 'Visit in morning');
|
||||
testDb
|
||||
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)')
|
||||
.run(days[0].id, place.id, 'Visit in morning');
|
||||
|
||||
// Accommodation spanning days 0→1
|
||||
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
@@ -930,15 +937,15 @@ describe('Copy trip with data', () => {
|
||||
expect(newPlaces[0].name).toBe('Tower Bridge');
|
||||
|
||||
// Place tag copied
|
||||
const newTags = testDb.prepare(
|
||||
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
const newTags = testDb
|
||||
.prepare('SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?')
|
||||
.all(newId) as any[];
|
||||
expect(newTags).toHaveLength(1);
|
||||
|
||||
// Assignment copied
|
||||
const newAssignments = testDb.prepare(
|
||||
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
const newAssignments = testDb
|
||||
.prepare('SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?')
|
||||
.all(newId) as any[];
|
||||
expect(newAssignments).toHaveLength(1);
|
||||
|
||||
// Accommodation copied
|
||||
@@ -969,15 +976,21 @@ describe('Copy trip with data', () => {
|
||||
const trip = createTrip(testDb, user.id, { title: 'Todo Trip' });
|
||||
|
||||
// Two todos: one checked and assigned — both should arrive unchecked and unassigned
|
||||
testDb.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
|
||||
testDb.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
|
||||
testDb
|
||||
.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(trip.id, 'Buy tickets', 0, 'Transport', 0, '2026-06-01', 'Check Ryanair', 1);
|
||||
testDb
|
||||
.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, assigned_user_id, priority) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(trip.id, 'Book hotel', 1, 'Accommodation', 1, user.id, 0);
|
||||
|
||||
// Two budget category order rows
|
||||
const insOrder = testDb.prepare('INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
|
||||
const insOrder = testDb.prepare(
|
||||
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)',
|
||||
);
|
||||
insOrder.run(trip.id, 'Transport', 0);
|
||||
insOrder.run(trip.id, 'Accommodation', 1);
|
||||
|
||||
@@ -990,7 +1003,9 @@ describe('Copy trip with data', () => {
|
||||
const newId = res.body.trip.id;
|
||||
|
||||
// Todos copied with checked reset and assigned_user_id nulled
|
||||
const newTodos = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
|
||||
const newTodos = testDb
|
||||
.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(newId) as any[];
|
||||
expect(newTodos).toHaveLength(2);
|
||||
expect(newTodos[0].name).toBe('Buy tickets');
|
||||
expect(newTodos[0].category).toBe('Transport');
|
||||
@@ -1004,7 +1019,9 @@ describe('Copy trip with data', () => {
|
||||
expect(newTodos[1].assigned_user_id).toBeNull();
|
||||
|
||||
// Budget category order copied
|
||||
const newOrder = testDb.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order').all(newId) as any[];
|
||||
const newOrder = testDb
|
||||
.prepare('SELECT category, sort_order FROM budget_category_order WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(newId) as any[];
|
||||
expect(newOrder).toHaveLength(2);
|
||||
expect(newOrder[0]).toMatchObject({ category: 'Transport', sort_order: 0 });
|
||||
expect(newOrder[1]).toMatchObject({ category: 'Accommodation', sort_order: 1 });
|
||||
@@ -1020,9 +1037,7 @@ describe('Trip bundle', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
@@ -1040,9 +1055,7 @@ describe('Trip bundle', () => {
|
||||
it('BUNDLE-002 — returns 404 for trip that does not exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/trips/999999/bundle')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/trips/999999/bundle').set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -1052,9 +1065,7 @@ describe('Trip bundle', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(other.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(other.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
@@ -1065,9 +1076,7 @@ describe('Trip bundle', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/bundle`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/bundle`).set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.trip.id).toBe(trip.id);
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* Vacay integration tests.
|
||||
* Covers VACAY-001 to VACAY-025.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../src/app';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
|
||||
import type { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -17,13 +25,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
const place: any = db
|
||||
.prepare(
|
||||
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
|
||||
)
|
||||
.get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
const tags = db
|
||||
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
|
||||
.all(placeId);
|
||||
return {
|
||||
...place,
|
||||
category: place.category_id
|
||||
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
|
||||
: null,
|
||||
tags,
|
||||
};
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
@@ -38,32 +62,30 @@ vi.mock('../../src/config', () => ({
|
||||
}));
|
||||
|
||||
// Prevent real HTTP calls (holiday API etc.)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([
|
||||
{ date: '2025-01-01', name: 'New Year\'s Day', countryCode: 'DE' },
|
||||
]),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([{ date: '2025-01-01', name: "New Year's Day", countryCode: 'DE' }]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
|
||||
vi.mock('../../src/services/vacayService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>('../../src/services/vacayService');
|
||||
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>(
|
||||
'../../src/services/vacayService',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getCountries: vi.fn().mockResolvedValue({
|
||||
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
|
||||
data: [
|
||||
{ countryCode: 'DE', name: 'Germany' },
|
||||
{ countryCode: 'FR', name: 'France' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -86,9 +108,7 @@ describe('Vacay plan', () => {
|
||||
it('VACAY-001 — GET /api/addons/vacay/plan auto-creates plan on first access', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/plan')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toBeDefined();
|
||||
expect(res.body.plan.owner_id).toBe(user.id);
|
||||
@@ -135,9 +155,7 @@ describe('Vacay years', () => {
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/years')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/years').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.years)).toBe(true);
|
||||
expect(res.body.years.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -148,9 +166,7 @@ describe('Vacay years', () => {
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2026 });
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/vacay/years/2026')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).delete('/api/addons/vacay/years/2026').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.years).toBeDefined();
|
||||
});
|
||||
@@ -199,9 +215,7 @@ describe('Vacay entries', () => {
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/entries/2025')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/entries/2025').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
});
|
||||
@@ -211,9 +225,7 @@ describe('Vacay entries', () => {
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/stats/2025')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/stats/2025').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('stats');
|
||||
});
|
||||
@@ -261,9 +273,7 @@ describe('Vacay invite flow', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/available-users')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/available-users').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.users)).toBe(true);
|
||||
});
|
||||
@@ -273,9 +283,7 @@ describe('Vacay holidays', () => {
|
||||
it('VACAY-014 — GET /api/addons/vacay/holidays/countries returns available countries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/countries')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/holidays/countries').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
@@ -297,9 +305,7 @@ describe('Vacay dissolve plan', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/dissolve')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).post('/api/addons/vacay/dissolve').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -315,8 +321,9 @@ describe('Vacay holiday calendar CRUD', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'US', label: 'US Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
|
||||
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
const calId =
|
||||
createRes.body.plan?.holiday_calendars?.at(-1)?.id ??
|
||||
(testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
@@ -466,9 +473,7 @@ describe('Vacay holidays error path', () => {
|
||||
// Use an unusual country/year to avoid cache hits from other tests
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2099/ZZ')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/holidays/2099/ZZ').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -496,9 +501,7 @@ describe('Vacay holidays success path', () => {
|
||||
json: () => Promise.resolve([{ date: '2025-05-01', name: 'Labour Day', countryCode: 'AT' }]),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2025/AT')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
const res = await request(app).get('/api/addons/vacay/holidays/2025/AT').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user