refactor(server): plan 4 — convert the program's orphaned raw-SQL callers

This commit is contained in:
jubnl
2026-09-24 04:59:54 +02:00
parent fe69a3b9a7
commit 88fa13d55a
67 changed files with 1812 additions and 671 deletions
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { BucketList } from '../../../../src/db/entities/BucketList.entity';
import type { BucketListRepository } from '../../../../src/db/repositories/BucketList.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let bucketList: BucketListRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
bucketList = t.repo(BucketList);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('BucketListRepository.listForPublicApi (Plan 4 Task 1, public-api.service.ts::listBucketList)', () => {
it('BUCKETREPO-001: name/lat/lng/country_code/notes/target_date only, ordered by created_at DESC then id DESC, scoped to the caller', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
const insert = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
insert.run(user.id, 'Older', 1, 2, 'JP', 'note', null, '2026-01-01T00:00:00.000Z');
insert.run(user.id, 'Newer', null, null, null, null, '2027-03-01', '2026-02-01T00:00:00.000Z');
insert.run(other.id, 'Not mine', 9, 9, null, null, null, '2026-03-01T00:00:00.000Z');
const legacy = testDb.prepare(
'SELECT name, lat, lng, country_code, notes, target_date FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC, id DESC',
).all(user.id);
const rows = await bucketList.listForPublicApi(user.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.name)).toEqual(['Newer', 'Older']);
});
it('BUCKETREPO-002: same-instant rows tiebreak on id DESC (the second ORDER BY key)', async () => {
const { user } = createUser(testDb);
const insert = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, created_at) VALUES (?, ?, ?)',
);
insert.run(user.id, 'First inserted', '2026-01-01T00:00:00.000Z');
insert.run(user.id, 'Second inserted', '2026-01-01T00:00:00.000Z');
const rows = await bucketList.listForPublicApi(user.id);
expect(rows.map((r) => r.name)).toEqual(['Second inserted', 'First inserted']);
});
it('BUCKETREPO-003: empty array for a user with nothing on their list', async () => {
const { user } = createUser(testDb);
expect(await bucketList.listForPublicApi(user.id)).toEqual([]);
});
});
@@ -174,3 +174,105 @@ describe('DayNotesRepository.insertNoteCopy (TP71)', () => {
expect(row).toEqual({ day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, color: null });
});
});
describe('DayNotesRepository.listByDayAndTrip (Plan 4 Task 1, day-notes.service.ts::list)', () => {
it('NOTEREPO-012: scoped to BOTH day and trip, ordered by sort_order then created_at', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const otherDay = createDay(testDb, trip.id);
const second = createDayNote(testDb, day.id, trip.id, { sort_order: 5 });
const first = createDayNote(testDb, day.id, trip.id, { sort_order: 1 });
createDayNote(testDb, otherDay.id, trip.id);
const legacy = testDb.prepare('SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC').all(day.id, trip.id);
const rows = await notes.listByDayAndTrip(day.id, trip.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([first.id, second.id]);
});
it('NOTEREPO-013: a day with no notes returns []', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
expect(await notes.listByDayAndTrip(day.id, trip.id)).toEqual([]);
});
});
describe('DayNotesRepository.findByIdDayTrip (Plan 4 Task 1, day-notes.service.ts::getNote)', () => {
it('NOTEREPO-014: found only under its own id/day/trip triple', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const otherDay = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Lunch' });
expect(await notes.findByIdDayTrip(note.id, day.id, trip.id)).toEqual(note);
expect(await notes.findByIdDayTrip(note.id, otherDay.id, trip.id)).toBeUndefined();
expect(await notes.findByIdDayTrip(note.id, day.id, trip.id + 1)).toBeUndefined();
});
});
describe('DayNotesRepository.updateNote (Plan 4 Task 1, day-notes.service.ts::update)', () => {
it('NOTEREPO-015: updates every bound column and returns the re-selected row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Lunch', time: '12:00', icon: '🍜', sort_order: 2 });
const updated = await notes.updateNote(note.id, { text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' });
expect(updated).toMatchObject({ id: note.id, text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' });
expect(testDb.prepare('SELECT text, time, icon, sort_order, color FROM day_notes WHERE id = ?').get(note.id))
.toEqual({ text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' });
});
it('NOTEREPO-016: undefined for an id that does not exist', async () => {
expect(await notes.updateNote(999999, { text: 'x', time: null, icon: null, sort_order: null, color: null })).toBeUndefined();
});
});
describe('DayNotesRepository.deleteById (Plan 4 Task 1, day-notes.service.ts::remove)', () => {
it('NOTEREPO-017: deletes by bare id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await notes.deleteById(note.id);
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
});
it('NOTEREPO-018: deleting an id that does not exist is a no-op, not an error', async () => {
await expect(notes.deleteById(999999)).resolves.toBeUndefined();
});
});
describe('DayNotesRepository.listForPublicApi (Plan 4 Task 1, public-api.service.ts::dayNotesByDay)', () => {
it('NOTEREPO-019: day_id/text/time only, ordered by day_id then sort_order, scoped to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const dayA = createDay(testDb, trip.id);
const dayB = createDay(testDb, trip.id);
createDayNote(testDb, dayB.id, trip.id, { text: 'B-second', sort_order: 5 });
createDayNote(testDb, dayA.id, trip.id, { text: 'A-first', sort_order: 1 });
createDayNote(testDb, dayB.id, trip.id, { text: 'B-first', sort_order: 1 });
createDayNote(testDb, createDay(testDb, other.id).id, other.id, { text: 'Not this trip' });
const rows = await notes.listForPublicApi(trip.id);
expect(rows).toEqual([
{ day_id: dayA.id, text: 'A-first', time: null },
{ day_id: dayB.id, text: 'B-first', time: null },
{ day_id: dayB.id, text: 'B-second', time: null },
]);
});
it('NOTEREPO-020: empty array for a trip with no notes', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await notes.listForPublicApi(trip.id)).toEqual([]);
});
});
@@ -331,3 +331,25 @@ describe('DaysRepository.insertDayCopy (TP39)', () => {
expect(row).toEqual({ date: null, notes: null, title: null });
});
});
describe('DaysRepository.listForPublicApi (Plan 4 Task 1, public-api.service.ts::buildDays)', () => {
it('DAYREPO-027: id/day_number/date/title/notes only, ordered by day_number, scoped to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const day2 = createDay(testDb, trip.id, { day_number: 2, date: '2026-06-02' });
const day1 = createDay(testDb, trip.id, { day_number: 1, date: '2026-06-01' });
createDay(testDb, other.id, { day_number: 1 });
const legacy = testDb.prepare('SELECT id, day_number, date, title, notes FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id);
const rows = await days.listForPublicApi(trip.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([day1.id, day2.id]);
});
it('DAYREPO-028: empty array for a trip with no days', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await days.listForPublicApi(trip.id)).toEqual([]);
});
});
@@ -0,0 +1,103 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { IdempotencyKeys } from '../../../../src/db/entities/IdempotencyKeys.entity';
import type { IdempotencyKeysRepository } from '../../../../src/db/repositories/IdempotencyKeys.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let idempotencyKeys: IdempotencyKeysRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
idempotencyKeys = t.repo(IdempotencyKeys);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('IdempotencyKeysRepository.findResponse (Plan 4 Task 1)', () => {
it('IDEMPREPO-001: returns status_code/response_body only, scoped by the full (key, user, method, path) composite key', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES (?, ?, ?, ?, ?, ?)')
.run('k', user.id, 'POST', '/api/categories', 201, '{"id":1}');
expect(await idempotencyKeys.findResponse('k', user.id, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"id":1}' });
// Same key, different user — a different row entirely, not this one.
expect(await idempotencyKeys.findResponse('k', other.id, 'POST', '/api/categories')).toBeNull();
// Same key/user, different method or path — must not cross-match.
expect(await idempotencyKeys.findResponse('k', user.id, 'PUT', '/api/categories')).toBeNull();
expect(await idempotencyKeys.findResponse('k', user.id, 'POST', '/api/tags')).toBeNull();
});
it('IDEMPREPO-002: null when no row matches at all', async () => {
const { user } = createUser(testDb);
expect(await idempotencyKeys.findResponse('missing', user.id, 'POST', '/x')).toBeNull();
});
});
describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => {
it('IDEMPREPO-003: inserts every bound column, including the caller-supplied created_at', async () => {
const { user } = createUser(testDb);
await idempotencyKeys.insertIfAbsent({
key: 'k', user_id: user.id, method: 'POST', path: '/api/places',
status_code: 201, response_body: '{"ok":true}', created_at: 1_700_000_000,
});
const row = testDb.prepare('SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys WHERE key = ?').get('k');
expect(row).toEqual({
key: 'k', user_id: user.id, method: 'POST', path: '/api/places',
status_code: 201, response_body: '{"ok":true}', created_at: 1_700_000_000,
});
});
it('IDEMPREPO-004: a second insert on the SAME (key, user, method, path) is ignored — the first write wins (INSERT OR IGNORE parity)', async () => {
const { user } = createUser(testDb);
await idempotencyKeys.insertIfAbsent({
key: 'k', user_id: user.id, method: 'POST', path: '/api/places',
status_code: 201, response_body: '{"first":true}', created_at: 1_700_000_000,
});
await idempotencyKeys.insertIfAbsent({
key: 'k', user_id: user.id, method: 'POST', path: '/api/places',
status_code: 500, response_body: '{"second":true}', created_at: 1_800_000_000,
});
expect(await idempotencyKeys.findResponse('k', user.id, 'POST', '/api/places')).toEqual({ status_code: 201, response_body: '{"first":true}' });
expect(testDb.prepare('SELECT COUNT(*) AS c FROM idempotency_keys').get()).toEqual({ c: 1 });
});
it('IDEMPREPO-005: the SAME key for a DIFFERENT user is a separate row, not a conflict', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb, { username: 'b' });
await idempotencyKeys.insertIfAbsent({ key: 'k', user_id: a.id, method: 'POST', path: '/x', status_code: 200, response_body: '{}', created_at: 1 });
await idempotencyKeys.insertIfAbsent({ key: 'k', user_id: b.id, method: 'POST', path: '/x', status_code: 200, response_body: '{}', created_at: 2 });
expect(testDb.prepare('SELECT COUNT(*) AS c FROM idempotency_keys').get()).toEqual({ c: 2 });
});
});
describe('IdempotencyKeysRepository.deleteExpired (Plan 4 Task 1)', () => {
it('IDEMPREPO-006: deletes rows strictly older than the cutoff, returns the row count, leaves the rest', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run('old', user.id, 'POST', '/x', 200, '{}', 100);
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run('boundary', user.id, 'POST', '/y', 200, '{}', 200);
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run('fresh', user.id, 'POST', '/z', 200, '{}', 300);
const removed = await idempotencyKeys.deleteExpired(200);
expect(removed).toBe(1);
const keys = testDb.prepare('SELECT key FROM idempotency_keys ORDER BY key').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['boundary', 'fresh']);
});
it('IDEMPREPO-007: zero rows removed when nothing is old enough', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run('fresh', user.id, 'POST', '/z', 200, '{}', 1000);
expect(await idempotencyKeys.deleteExpired(500)).toBe(0);
});
});
@@ -1217,3 +1217,42 @@ describe('PlacesRepository.setImageUrlIfUnset (MAP9)', () => {
expect((testDb.prepare('SELECT image_url FROM places WHERE id = ?').get(placeB.id) as { image_url: string }).image_url).toBe('/uploads/photo-cache/both.jpg');
});
});
describe('PlacesRepository.listAssignedForPublicApi (Plan 4 Task 1, public-api.service.ts::placesByDay)', () => {
it('PLACEREPO-030: day_id + place fields + category name, scoped to the trip, ordered by day then order_index — a booked-night stop is excluded', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const category = createCategory(testDb, { name: 'Museum' });
const dayA = createDay(testDb, trip.id);
const dayB = createDay(testDb, trip.id);
const placeA1 = createPlace(testDb, trip.id, { name: 'A-first', category_id: category.id });
const placeA2 = createPlace(testDb, trip.id, { name: 'A-second' });
testDb.prepare('UPDATE places SET category_id = NULL WHERE id = ?').run(placeA2.id); // no category, not the factory's default
const placeB1 = createPlace(testDb, trip.id, { name: 'B-first' });
const hotelPlace = createPlace(testDb, trip.id, { name: 'Hotel' });
createDayAssignment(testDb, dayA.id, placeA2.id, { order_index: 5 });
createDayAssignment(testDb, dayA.id, placeA1.id, { order_index: 1 });
createDayAssignment(testDb, dayB.id, placeB1.id, { order_index: 0 });
// A booked-night stop: da.accommodation_id IS NOT NULL, must be excluded.
testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) VALUES (?, ?, 0, 999)').run(dayA.id, hotelPlace.id);
// Unassigned place in the same trip — never a candidate at all.
createPlace(testDb, trip.id, { name: 'Unassigned' });
// A place assigned on a DIFFERENT trip — must not leak in.
const otherDay = createDay(testDb, other.id);
createDayAssignment(testDb, otherDay.id, createPlace(testDb, other.id, { name: 'Elsewhere' }).id);
const rows = await places.listAssignedForPublicApi(trip.id);
expect(rows.map((r) => r.name)).toEqual(['A-first', 'A-second', 'B-first']);
expect(rows[0]).toMatchObject({ day_id: dayA.id, name: 'A-first', category: 'Museum' });
expect(rows[1]).toMatchObject({ day_id: dayA.id, name: 'A-second', category: null });
expect(rows[2]).toMatchObject({ day_id: dayB.id, name: 'B-first' });
});
it('PLACEREPO-031: empty array when nothing is assigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await places.listAssignedForPublicApi(trip.id)).toEqual([]);
});
});
@@ -0,0 +1,114 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTrip, createUser } from '../../../helpers/factories';
import { TripInviteTokens } from '../../../../src/db/entities/TripInviteTokens.entity';
import type { TripInviteTokensRepository } from '../../../../src/db/repositories/TripInviteTokens.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let tripInviteTokens: TripInviteTokensRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
tripInviteTokens = t.repo(TripInviteTokens);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('TripInviteTokensRepository.findInfoByTrip / existsForTrip (Plan 4 Task 1)', () => {
it('TIREPO-001: findInfoByTrip returns token/expires_at/created_at only, undefined when no row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await tripInviteTokens.findInfoByTrip(trip.id)).toBeUndefined();
expect(await tripInviteTokens.existsForTrip(trip.id)).toBe(false);
testDb.prepare("INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, 'tok', ?, NULL)").run(trip.id, user.id);
const legacy = testDb.prepare('SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id);
expect(await tripInviteTokens.findInfoByTrip(trip.id)).toEqual(legacy);
expect(await tripInviteTokens.existsForTrip(trip.id)).toBe(true);
});
});
describe('TripInviteTokensRepository.insertForTrip / updateForTrip (Plan 4 Task 1)', () => {
it('TIREPO-002: insertForTrip writes the 4-column set, created_at defaults', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'abc', created_by: user.id, expires_at: null });
const row = testDb.prepare('SELECT trip_id, token, created_by, expires_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id);
expect(row).toEqual({ trip_id: trip.id, token: 'abc', created_by: user.id, expires_at: null });
expect((testDb.prepare('SELECT created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { created_at: string }).created_at).toBeTruthy();
});
it('TIREPO-003: updateForTrip rewrites token/expires_at/created_by AND bumps created_at, on the SAME row (single row per trip)', async () => {
const { user: owner } = createUser(testDb);
const { user: rotator } = createUser(testDb, { username: 'rotator' });
const trip = createTrip(testDb, owner.id);
await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'first', created_by: owner.id, expires_at: null });
const before = testDb.prepare('SELECT id, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { id: number; created_at: string };
await tripInviteTokens.updateForTrip(trip.id, { token: 'second', expires_at: '2030-01-01T00:00:00.000Z', created_by: rotator.id });
const after = testDb.prepare('SELECT id, token, expires_at, created_by, created_at FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as {
id: number; token: string; expires_at: string; created_by: number; created_at: string;
};
expect(after.id).toBe(before.id); // same row, not a second insert
expect(after.token).toBe('second');
expect(after.expires_at).toBe('2030-01-01T00:00:00.000Z');
expect(after.created_by).toBe(rotator.id);
expect(testDb.prepare('SELECT COUNT(*) AS c FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id)).toEqual({ c: 1 });
});
});
describe('TripInviteTokensRepository.deleteByTrip (Plan 4 Task 1)', () => {
it('TIREPO-004: removes the trip\'s row and only that trip\'s row', async () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id);
const tripB = createTrip(testDb, user.id);
await tripInviteTokens.insertForTrip({ trip_id: tripA.id, token: 'a', created_by: user.id, expires_at: null });
await tripInviteTokens.insertForTrip({ trip_id: tripB.id, token: 'b', created_by: user.id, expires_at: null });
await tripInviteTokens.deleteByTrip(tripA.id);
expect(await tripInviteTokens.existsForTrip(tripA.id)).toBe(false);
expect(await tripInviteTokens.existsForTrip(tripB.id)).toBe(true);
});
it('TIREPO-005: deleting a trip with no row is a no-op', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await expect(tripInviteTokens.deleteByTrip(trip.id)).resolves.toBeUndefined();
});
});
describe('TripInviteTokensRepository.resolveTokenToTrip (Plan 4 Task 1, mutation proof for token validation)', () => {
it('TIREPO-006: resolves a valid token to its trip id/title/expires_at', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Kyoto Loop' });
await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'real-token', created_by: user.id, expires_at: '2030-01-01T00:00:00.000Z' });
expect(await tripInviteTokens.resolveTokenToTrip('real-token')).toEqual({ trip_id: trip.id, title: 'Kyoto Loop', expires_at: '2030-01-01T00:00:00.000Z' });
});
it('TIREPO-007: an unknown token resolves to undefined — never a row from a different trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'real-token', created_by: user.id, expires_at: null });
expect(await tripInviteTokens.resolveTokenToTrip('forged-token')).toBeUndefined();
});
it("TIREPO-008: a token that used to resolve stops resolving once it's rotated away (mutation proof)", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await tripInviteTokens.insertForTrip({ trip_id: trip.id, token: 'old-token', created_by: user.id, expires_at: null });
await tripInviteTokens.updateForTrip(trip.id, { token: 'new-token', expires_at: null, created_by: user.id });
expect(await tripInviteTokens.resolveTokenToTrip('old-token')).toBeUndefined();
expect((await tripInviteTokens.resolveTokenToTrip('new-token'))?.trip_id).toBe(trip.id);
});
});
@@ -380,3 +380,37 @@ describe('TripMembersRepository.listRawUsernameAndDisplayName (RP3)', () => {
expect((await tripMembers.listRawUsernameAndDisplayName(String(trip.id))).map((r) => r.id)).toEqual([member.id]);
});
});
describe('TripMembersRepository.clearInvitedBy (Plan 4 Task 1, UC4)', () => {
it('TMEMREPO-029: nulls invited_by on every row the departing user invited, across trips, leaving other rows untouched', async () => {
const { user: owner } = createUser(testDb);
const { user: departing } = createUser(testDb, { username: 'departing' });
const { user: other } = createUser(testDb, { username: 'other' });
const tripA = createTrip(testDb, owner.id);
const tripB = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripA.id, other.id, departing.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripB.id, owner.id, departing.id);
// Control row: invited by someone else entirely — must survive untouched.
const { user: controlMember } = createUser(testDb, { username: 'control-member' });
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripA.id, controlMember.id, owner.id);
await tripMembers.clearInvitedBy(departing.id);
const rows = testDb.prepare('SELECT trip_id, user_id, invited_by FROM trip_members ORDER BY trip_id, user_id').all();
expect(rows).toEqual([
{ trip_id: tripA.id, user_id: other.id, invited_by: null },
{ trip_id: tripA.id, user_id: controlMember.id, invited_by: owner.id },
{ trip_id: tripB.id, user_id: owner.id, invited_by: null },
]);
});
it('TMEMREPO-030: a user who never invited anyone is a no-op', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await expect(tripMembers.clearInvitedBy(999999)).resolves.toBeUndefined();
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id) as { invited_by: number | null }).invited_by).toBeNull();
});
});
@@ -943,3 +943,22 @@ describe('TripsRepository.findDatesById (DTR1)', () => {
expect(await trips.findDatesById(999999)).toBeUndefined();
});
});
describe('TripsRepository.listIdTitleOrderedByTitle (Plan 4 Task 1, RI2)', () => {
it('TRIPREPO-064: id/title only, case-insensitively ordered by title, matching the legacy statement byte-for-byte', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'zulu' });
createTrip(testDb, user.id, { title: 'Alpha' });
createTrip(testDb, user.id, { title: 'bravo' });
const legacy = testDb
.prepare('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC')
.all();
expect(await trips.listIdTitleOrderedByTitle()).toEqual(legacy);
});
it('TRIPREPO-065: empty array when there are no trips', async () => {
expect(await trips.listIdTitleOrderedByTitle()).toEqual([]);
});
});
@@ -112,6 +112,20 @@ describe('UsersRepository', () => {
expect(await users.findByIdWithPasswordVersion(999999)).toBeNull();
});
it('USERSREPO-009b: findForWsHandshake (Plan 4 Task 1, realtime.gateway.ts) reads id/username/email/role/mfa_enabled/password_version', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET password_version = 3, mfa_enabled = 1 WHERE id = ?').run(user.id);
const row = await users.findForWsHandshake(user.id);
expect(row).toEqual({ id: user.id, username: user.username, email: user.email, role: 'user', mfa_enabled: 1, password_version: 3 });
expect(await users.findForWsHandshake(999999)).toBeNull();
});
it('USERSREPO-009c: findForWsHandshake reports mfa_enabled as null, not 0, for a user who never had it set (Plan 4 Task 1)', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET mfa_enabled = NULL WHERE id = ?').run(user.id);
expect((await users.findForWsHandshake(user.id))?.mfa_enabled).toBeNull();
});
it('USERSREPO-010: getPasswordVersion reads password_version, null for a missing user', async () => {
const { user } = createUser(testDb);
expect(await users.getPasswordVersion(user.id)).toBe(0);
+35 -27
View File
@@ -6,32 +6,45 @@
* offline window — otherwise a key GC'd before the device returns lets the
* replay create a duplicate. The TTL was raised from 24h to 30d (overridable).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { db } from '../../src/db/database';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
import { purgeExpiredIdempotencyKeys } from '../../src/nest/common/idempotency-cleanup';
import { IdempotencyCleanupJob } from '../../src/nest/common/idempotency-cleanup.job';
import { DatabaseService } from '../../src/nest/database/database.service';
import { IdempotencyKeys } from '../../src/db/entities/IdempotencyKeys.entity';
import type { IdempotencyKeysRepository } from '../../src/db/repositories/IdempotencyKeys.repository';
import type { CronRegistrarService } from '../../src/nest/scheduling/cron-registrar.service';
const DAY = 24 * 60 * 60;
const NOW = 2_000_000_000_000; // fixed ms so the test is deterministic
const NOW_SEC = Math.floor(NOW / 1000);
function insertKey(key: string, ageSeconds: number): void {
db.prepare(
const testDb = createSnapshotTestDb();
let t: TestOrm;
let idempotencyKeys: IdempotencyKeysRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
idempotencyKeys = t.repo(IdempotencyKeys);
});
afterAll(async () => { await t.close(); testDb.close(); });
function insertKey(key: string, ageSeconds: number, nowSec = NOW_SEC): void {
testDb.prepare(
`INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`,
).run(key, NOW_SEC - ageSeconds);
).run(key, nowSec - ageSeconds);
}
beforeEach(() => {
db.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
db.prepare('DELETE FROM idempotency_keys').run();
testDb.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
testDb.prepare('DELETE FROM idempotency_keys').run();
t.clear();
});
afterEach(() => {
db.prepare('DELETE FROM idempotency_keys').run();
db.pragma('foreign_keys = ON');
testDb.prepare('DELETE FROM idempotency_keys').run();
testDb.pragma('foreign_keys = ON');
delete process.env.IDEMPOTENCY_TTL_SECONDS;
});
@@ -40,23 +53,23 @@ describe('purgeExpiredIdempotencyKeys', () => {
insertKey('old', 31 * DAY);
insertKey('fresh', 5 * DAY);
const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, db);
const removed = await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys);
expect(removed).toBe(1);
const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['fresh']);
});
it('keeps a 25-day-old key that the old 24h TTL would have dropped', async () => {
insertKey('offline-trip', 25 * DAY);
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(0);
expect(db.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 });
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(0);
expect(testDb.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 });
});
it('respects the IDEMPOTENCY_TTL_SECONDS override', async () => {
process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY);
insertKey('twoDays', 2 * DAY);
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(1);
expect(await purgeExpiredIdempotencyKeys(NOW, undefined, idempotencyKeys)).toBe(1);
});
});
@@ -70,7 +83,7 @@ describe('IdempotencyCleanupJob', () => {
register: vi.fn((_name: string, _expression: string, _onTick: () => void | Promise<void>) => enabled),
unregister: vi.fn(),
};
const job = new IdempotencyCleanupJob(new DatabaseService(db), registrar as unknown as CronRegistrarService);
const job = new IdempotencyCleanupJob(idempotencyKeys, registrar as unknown as CronRegistrarService);
return { job, registrar };
}
@@ -89,31 +102,26 @@ describe('IdempotencyCleanupJob', () => {
expect(off.registrar.register).not.toHaveBeenCalled();
});
it('the tick purges through the injected DatabaseService', async () => {
it('the tick purges through the injected IdempotencyKeysRepository', async () => {
// The tick uses the live clock, so these fixtures age against Date.now()
// (the pure-function cases above pin their own fixed NOW instead).
const liveNowSec = Math.floor(Date.now() / 1000);
const liveInsert = (key: string, ageSeconds: number) =>
db.prepare(
`INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`,
).run(key, liveNowSec - ageSeconds);
liveInsert('old', 31 * DAY);
liveInsert('fresh', 5 * DAY);
insertKey('old', 31 * DAY, liveNowSec);
insertKey('fresh', 5 * DAY, liveNowSec);
const { job } = makeJob();
await job.tick();
const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
const keys = testDb.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['fresh']);
});
it('a failing purge is contained to the Idempotency cleanup log line', async () => {
const broken = { prepare: () => { throw new Error('db gone'); } } as unknown as DatabaseService;
const broken = { deleteExpired: () => { throw new Error('db gone'); } } as unknown as IdempotencyKeysRepository;
const job = new IdempotencyCleanupJob(broken, { isEnabled: () => true } as unknown as CronRegistrarService);
await expect(job.tick()).resolves.toBeUndefined();
// Non-Error throws are stringified rather than crashing the catch itself.
const brokenString = { prepare: () => { throw 'db string'; } } as unknown as DatabaseService;
const brokenString = { deleteExpired: () => { throw 'db string'; } } as unknown as IdempotencyKeysRepository;
const job2 = new IdempotencyCleanupJob(brokenString, { isEnabled: () => true } as unknown as CronRegistrarService);
await expect(job2.tick()).resolves.toBeUndefined();
});
+8 -17
View File
@@ -14,12 +14,10 @@ import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -28,10 +26,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -54,8 +52,7 @@ vi.mock('../../../src/demo/demo-reset', () => ({
saveBaseline: vi.fn(),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createUserWithMfa, createAdmin, createInviteToken } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
@@ -131,7 +128,7 @@ let auditLogRepo: AuditLogRepository;
beforeAll(async () => {
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(dbs.connection));
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection));
auth = new AuthService(
permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(await createTestUsersRepo(dbs.connection), await createTestSettingsRepo(dbs.connection), await createTestAppSettingsRepo(dbs.connection)), new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(dbs.connection)), await createTestUnitOfWork(dbs.connection),
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
@@ -180,11 +177,6 @@ const checkVersion = () => svc.checkVersion();
const listAddons = () => svc.listAddons();
const updateAddon = (id: string, d: Parameters<AdminService['updateAddon']>[1]) => svc.updateAddon(id, d);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
@@ -703,7 +695,6 @@ describe('admin quirk fixes (post-fold)', () => {
expect(row.username).toBe(user.username);
});
});
// ── What an admin password reset ends, and what an ordinary edit must not ─────
@@ -6,19 +6,16 @@
* case, which would leak into the ADMIN-SVC-* suite. The notification path runs
* for real against the temp db's notifications table.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers';
import { createAdmin } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -27,10 +24,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -41,6 +38,7 @@ vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn(), invalidateMcpSessions: vi.fn() }));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn() }));
import { db as testDb } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { createTestAddonsService } from '../../helpers/test-addons';
@@ -105,7 +103,7 @@ let svc: AdminService;
beforeAll(async () => {
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(dbs.connection));
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection));
auth = new AuthService(
permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(await createTestUsersRepo(dbs.connection), await createTestSettingsRepo(dbs.connection), await createTestAppSettingsRepo(dbs.connection)), new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(dbs.connection)), await createTestUnitOfWork(dbs.connection),
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
@@ -166,11 +164,6 @@ function getNotificationCount(): number {
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
__clearVersionCacheForTests();
+9 -17
View File
@@ -13,12 +13,10 @@
// vi.hoisted: build the real in-memory DB and the module mock before any import
// ---------------------------------------------------------------------------
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -32,10 +30,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -60,9 +58,8 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn()
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { db as testDb } from '../../../src/db/database';
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories';
import { AuthService } from '../../../src/nest/auth/auth.service';
@@ -88,6 +85,7 @@ import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestTripMembersRepo,
createTestMcpTokensRepo,
createTestInviteTokensRepo,
createTestOauthTokensRepo,
@@ -119,7 +117,7 @@ beforeAll(async () => {
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membershipStub,
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb),
@@ -133,11 +131,6 @@ beforeAll(async () => {
// Lifecycle
// ---------------------------------------------------------------------------
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
@@ -1078,7 +1071,6 @@ describe('ephemeral + demo helpers', () => {
});
});
// ---------------------------------------------------------------------------
// Quirk fixes after the DI fold (trailing fix(server) commit): AUTH-DB-089+.
// The relocation carried these verbatim; the fixes land on top with a pin each.
@@ -9,12 +9,10 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -23,10 +21,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -52,8 +50,7 @@ vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { BudgetService } from '../../../src/nest/budget/budget.service';
@@ -80,8 +77,6 @@ import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourney
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
// Guest fixtures come from TripMembersService since the trip split (they were on
// TripsService before, and on the deleted services/tripService before that);
// deleteGuest routes through the SAME BudgetService domain SQL
@@ -95,7 +90,6 @@ import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.en
let dbsEm: import('@mikro-orm/core').EntityManager | undefined;
const dbs = () => new DatabaseService(testDb, dbsEm);
let budget: BudgetService;
let membersSvc: TripMembersService;
let createGuest: typeof membersSvc.createGuest;
@@ -121,7 +115,7 @@ beforeAll(async () => {
membersSvc = new TripMembersService(
dbs(),
budget,
new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
new RealtimeService(),
notificationsStub(),
@@ -134,11 +128,6 @@ beforeAll(async () => {
deleteGuest = (...args) => membersSvc.deleteGuest(...args);
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
@@ -51,11 +51,17 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se
import { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
import type { DayNote } from '../../../src/types';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDatabaseService } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDatabaseService, createTestDayNotesRepo, createTestDaysRepo } from '../../helpers/test-uow';
let svc: DayNotesService;
beforeAll(async () => {
svc = new DayNotesService(await createTestDatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService());
svc = new DayNotesService(
await createTestDatabaseService(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
await createTestDayNotesRepo(testDb),
await createTestDaysRepo(testDb),
);
});
beforeAll(() => {
@@ -288,7 +294,7 @@ describe('DayNotesService.canEdit', () => {
it('DAYNOTE-SVC-090 asks for day_edit and flags a non-owner as shared', async () => {
const checkPermission = vi.fn(() => true);
const permissions = { checkPermission } as unknown as PermissionsService;
const withStub = new DayNotesService(new DatabaseService(testDb), permissions, new RealtimeService());
const withStub = new DayNotesService(new DatabaseService(testDb), permissions, new RealtimeService(), await createTestDayNotesRepo(testDb), await createTestDaysRepo(testDb));
const trip = { id: 1, user_id: 1 } as never;
expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true);
@@ -16,7 +16,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleTransitProvider, clearGoogleTransitCache } from '../../../src/nest/transit/google-transit.provider';
import { decodePolyline, encodePolyline } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
@@ -32,36 +31,25 @@ vi.mock('../../../src/app-config', async (importOriginal) => {
const fetchMock = vi.fn();
/** app_settings reads only — the transit provider name and the instance Places key. */
function stubDb(settings: Record<string, string | undefined>): DatabaseService {
return {
get: (_sql: string, ...params: unknown[]) => {
const value = settings[String(params[0])];
return value === undefined ? undefined : { value };
},
run: () => undefined,
} as unknown as DatabaseService;
}
const GOOGLE_SETTINGS = { transit_provider: 'google', maps_api_key: 'test-key' };
/**
* resolveKey (google-transit.provider.ts) now reads AppSettingsRepository/
* resolveKey (google-transit.provider.ts) reads AppSettingsRepository/
* UsersRepository directly (instance-api-keys.ts's resolveApiKey, Plan 3a
* Task 5) instead of a raw `this.database.get(...)` call through the stub
* above. `stubDb`'s settings object already carries exactly the instance-tier
* value every test here configures, so `appSettingsStub.getValue` wraps it
* directly; the per-user fallback tier was never exercised by this file even
* before this conversion (the old stub's `.get` matched on the bound
* parameter, and none of these cases seed a per-user row), so it always
* answers null.
* Task 5); isActive's own `readTransitProvider` call does too, since Plan 4
* Task 1 — both go through the SAME `appSettingsStub` below now that
* `GoogleTransitProvider` no longer takes a `DatabaseService` at all. The
* settings object already carries exactly the instance-tier value every test
* here configures, so `appSettingsStub.getValue` wraps it directly; the
* per-user fallback tier was never exercised by this file (none of these
* cases seed a per-user row), so it always answers null.
*/
function makeProvider(settings: Record<string, string | undefined>): GoogleTransitProvider {
const appSettingsStub = {
getValue: async (key: string) => settings[key] ?? null,
} as unknown as AppSettingsRepository;
const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
return new GoogleTransitProvider(stubDb(settings), appSettingsStub, usersStub);
return new GoogleTransitProvider(appSettingsStub, usersStub);
}
beforeEach(() => {
@@ -1,9 +1,14 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { CallHandler, ExecutionContext } from '@nestjs/common';
import { from, of, lastValueFrom } from 'rxjs';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createUser } from '../../helpers/factories';
import { IdempotencyKeys } from '../../../src/db/entities/IdempotencyKeys.entity';
import type { IdempotencyKeysRepository } from '../../../src/db/repositories/IdempotencyKeys.repository';
import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor';
import type { DatabaseService } from '../../../src/nest/database/database.service';
type ReqShape = {
method: string;
@@ -34,51 +39,69 @@ function handler(result: unknown): CallHandler & { handle: ReturnType<typeof vi.
return { handle: vi.fn(() => of(result)) };
}
function makeDb(overrides: Partial<DatabaseService> = {}): DatabaseService {
return { get: vi.fn(), run: vi.fn(), ...overrides } as unknown as DatabaseService;
const testDb = createSnapshotTestDb();
let t: TestOrm;
let idempotencyKeys: IdempotencyKeysRepository;
let userId: number;
beforeAll(async () => {
t = await createTestOrm(testDb);
idempotencyKeys = t.repo(IdempotencyKeys);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
const { user } = createUser(testDb);
userId = user.id;
});
afterAll(async () => { await t.close(); testDb.close(); });
const findRow = (key: string, user: number, method: string, path: string) =>
testDb.prepare('SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?').get(key, user, method, path);
// A few `await Promise.resolve()` hops give the repository's async write
// chain (and its `.finally(release)`) the same room it gets under a real
// event loop, matching the timing this suite already needed before the
// conversion — `insertIfAbsent` is now a genuine repository call, not a
// synchronous `better-sqlite3` write wrapped in a resolved Promise.
async function settle(hops = 8): Promise<void> {
for (let i = 0; i < hops; i++) await Promise.resolve();
}
describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middleware)', () => {
it('passes a GET through without touching the store', async () => {
const db = makeDb();
const h = handler('weather');
const out = await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h),
await new IdempotencyInterceptor(idempotencyKeys).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h),
);
expect(out).toBe('weather');
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('passes a mutating request without a key through', async () => {
const db = makeDb();
const h = handler('done');
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h),
await new IdempotencyInterceptor(idempotencyKeys).intercept(ctx({ method: 'POST', headers: {}, user: { id: userId } }, makeRes()), h),
);
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('passes through when there is no authenticated user', async () => {
const db = makeDb();
const h = handler('done');
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h),
await new IdempotencyInterceptor(idempotencyKeys).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h),
);
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('rejects an over-long key with the exact legacy 400 body', async () => {
const db = makeDb();
const h = handler('done');
// `intercept` is async now, so the cap is reported as a rejection. Nest's
// InterceptorsConsumer already awaited the interceptor's result, so a
// synchronous throw reached it as a rejected promise too: same 400 body.
const run = () =>
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: 1 } }, makeRes()),
new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: userId } }, makeRes()),
h,
);
await expect(run()).rejects.toThrow(HttpException);
@@ -93,119 +116,103 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
});
it('replays a cached response and skips the handler', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue({ status_code: 201, response_body: '{"id":5}' }) });
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES (?, ?, ?, ?, ?, ?)')
.run('k', userId, 'POST', '/api/categories', 201, '{"id":5}');
const res = makeRes();
const h = handler('should-not-run');
const out = await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
await new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: userId } }, res),
h,
),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(out).toEqual({ id: 5 });
expect(h.handle).not.toHaveBeenCalled();
expect(db.get).toHaveBeenCalledWith(
expect.stringContaining('idempotency_keys'),
'k', 1, 'POST', '/api/categories',
);
});
it('captures a successful JSON response under the key', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const h = handler({ created: true });
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
await new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: userId } }, res),
h,
),
);
// Simulate Nest serialising the handler result through the wrapped res.json.
res.statusCode = 201;
res.json({ created: true });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO idempotency_keys'),
'k', 1, 'POST', '/api/categories', 201, '{"created":true}', expect.any(Number),
);
await settle();
expect(findRow('k', userId, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"created":true}' });
});
it('does not cache a non-2xx response', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const h = handler({ error: 'bad' });
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
await new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: userId } }, res),
h,
),
);
res.statusCode = 400;
res.json({ error: 'bad' });
expect(run).not.toHaveBeenCalled();
await settle();
expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
});
it('does not cache a body that exceeds the 256 KiB cap', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const big = { blob: 'x'.repeat(300 * 1024) };
const h = handler(big);
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
await new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: userId } }, res),
h,
),
);
res.statusCode = 200;
res.json(big);
expect(run).not.toHaveBeenCalled();
await settle();
expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
});
it('swallows a storage failure so the response still succeeds', async () => {
const run = vi.fn(() => {
throw new Error('db is locked');
});
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const failing = { findResponse: vi.fn().mockResolvedValue(null), insertIfAbsent: vi.fn().mockRejectedValue(new Error('db is locked')) } as unknown as IdempotencyKeysRepository;
const res = makeRes();
const h = handler({ ok: true });
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
await new IdempotencyInterceptor(failing).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: userId } }, res),
h,
),
);
res.statusCode = 201;
const returned = res.json({ ok: true });
expect(run).toHaveBeenCalledTimes(1);
await settle();
expect(failing.insertIfAbsent).toHaveBeenCalledTimes(1);
expect(returned).toEqual({ ok: true });
});
it('makes an overlapping replay of the same key wait, then answers it from the first response', async () => {
// The row only appears once the first request answers, so without the
// in-flight map both of these would miss the SELECT and both would run.
const rows = new Map<string, { status_code: number; response_body: string }>();
const db = makeDb({
get: vi.fn((_sql: string, ...params: unknown[]) => rows.get(String(params[0]))) as unknown as DatabaseService['get'],
run: vi.fn((_sql: string, ...params: unknown[]) => {
rows.set(String(params[0]), { status_code: Number(params[4]), response_body: String(params[5]) });
return {} as never;
}) as unknown as DatabaseService['run'],
});
const interceptor = new IdempotencyInterceptor(db);
const interceptor = new IdempotencyInterceptor(idempotencyKeys);
// The first handler is still running when the second request arrives.
let finish!: (value: unknown) => void;
const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) };
const firstRes = makeRes();
const first = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, firstRes), slow));
const first = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: userId } }, firstRes), slow));
const secondHandler = handler({ id: 'second' });
const secondRes = makeRes();
const second = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, secondRes), secondHandler));
const second = lastValueFrom(await interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: userId } }, secondRes), secondHandler));
// The order Nest uses, and the one that makes this test worth having: the
// handler's observable completes FIRST, and the response - which is what
@@ -214,10 +221,11 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
finish({ id: 'first' });
// Nest is several microtask hops from the completion to the write
// (transformToResult -> lastValueFrom -> apply), so give the waiter the same
// room it gets in production to wake up too early.
for (let i = 0; i < 5; i++) await Promise.resolve();
// room it gets in production to wake up too early — plus the repository
// write's own async chain (`insertIfAbsent`'s promise + its `.finally`).
firstRes.statusCode = 201;
firstRes.json({ id: 'first' });
await settle(12);
expect(await first).toEqual({ id: 'first' });
expect(await second).toEqual({ id: 'first' });
@@ -226,19 +234,18 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
});
it('runs the waiting request itself when the first one cached nothing', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
const interceptor = new IdempotencyInterceptor(db);
const interceptor = new IdempotencyInterceptor(idempotencyKeys);
let finish!: (value: unknown) => void;
const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) };
const first = lastValueFrom(await interceptor.intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()),
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: userId } }, makeRes()),
slow,
));
const secondHandler = handler({ id: 'second' });
const second = lastValueFrom(await interceptor.intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()),
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: userId } }, makeRes()),
secondHandler,
));
@@ -251,16 +258,47 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
});
it('treats a PATCH as a mutating method', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
const res = makeRes();
const h = handler('done');
await lastValueFrom(
await new IdempotencyInterceptor(db).intercept(
ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res),
await new IdempotencyInterceptor(idempotencyKeys).intercept(
ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: userId } }, res),
h,
),
);
expect(db.get).toHaveBeenCalled();
expect(h.handle).toHaveBeenCalled();
});
it('IDEMP-RACE-001 (rule 11 mutation proof): the in-flight slot is claimed BEFORE the first await, so a concurrent request for the same key never issues its own lookup', async () => {
// Regression guard for the exact bug rule 11 warns about: if
// `inFlight.set()` (this.claim) were moved back to AFTER `await
// this.lookup(...)`, the SECOND call below would run its synchronous
// prefix (which now includes NO claim yet) before the first call's
// lookup — gated open here by `gate` — resolves, and would issue its own
// `findResponse` call instead of finding `pending` and waiting. Holding
// the repository's lookup open with a controlled promise makes the race
// window deterministic instead of timing-dependent.
let resolveLookup!: () => void;
const gate = new Promise<void>((resolve) => { resolveLookup = resolve; });
const findResponse = vi.fn(async () => { await gate; return null; });
const gatedRepo = { findResponse, insertIfAbsent: vi.fn().mockResolvedValue(undefined) } as unknown as IdempotencyKeysRepository;
const interceptor = new IdempotencyInterceptor(gatedRepo);
const firstHandler = handler({ id: 'a' });
const secondHandler = handler({ id: 'b' });
const reqOpts = { method: 'POST', headers: { 'x-idempotency-key': 'race' }, path: '/api/places', user: { id: userId } };
// Fired back to back, synchronously — exactly how two nearly-simultaneous
// HTTP requests reach the interceptor.
const firstPromise = interceptor.intercept(ctx(reqOpts, makeRes()), firstHandler);
const secondPromise = interceptor.intercept(ctx(reqOpts, makeRes()), secondHandler);
// Both `intercept()` calls have now run their full synchronous prefix
// (each up to its own first genuine await) while `gate` is still open.
await Promise.resolve();
expect(findResponse).toHaveBeenCalledTimes(1);
resolveLookup();
await Promise.all([firstPromise, secondPromise]);
});
});
@@ -1,57 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// prepare() has to answer per statement now: the resolver reads the addon row
// AND the caller's role, and a single shared stub would hand the role lookup the
// addon row (silently green tests for the #1772 gate).
const { dbMock } = vi.hoisted(() => {
const addonStmt = { get: vi.fn() };
const roleStmt = { get: vi.fn() };
const other = { get: vi.fn(), all: vi.fn(), run: vi.fn() };
return {
dbMock: {
prepare: vi.fn((sql: string) => {
if (sql.includes('FROM addons')) return addonStmt;
if (sql.includes('FROM users')) return roleStmt;
return other;
}),
_addon: addonStmt,
_role: roleStmt,
},
};
});
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
const isAddonEnabled = vi.fn();
const findById = vi.fn();
import { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
import type { SettingsService } from '../../../../src/nest/settings/settings.service';
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
// The resolver injects SettingsService — a stub instance instead of the old
// legacy-module path mock (same behaviors as before the DI move). The
// DatabaseService rides the same prepare/get mock the module-level db used.
// The resolver injects SettingsService — a stub instance (same behaviors as
// before the DI move). Plan 4 Task 1: the addon-row read is now
// `AddonsRepository.findById`, stubbed the same way — `config` comes back
// already-parsed (the repository's own `p.json()` column), never a JSON
// string this test has to stringify.
const getUserSettings = vi.fn(() => ({}) as Record<string, unknown>);
const getAdminUserDefaults = vi.fn(() => ({}) as Record<string, unknown>);
const getDecryptedUserSetting = vi.fn(() => null as string | null);
const settingsStub = { getUserSettings, getAdminUserDefaults, getDecryptedUserSetting } as unknown as SettingsService;
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never), addonsStub);
const addonsRepoStub = { findById } as unknown as AddonsRepository;
const resolver = new LlmConfigResolver(settingsStub, addonsRepoStub, addonsStub);
function setInstanceConfig(config: unknown) {
dbMock._addon.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
}
function setRole(role: 'user' | 'admin' | undefined) {
dbMock._role.get.mockReturnValue(role === undefined ? undefined : { role });
function setInstanceConfig(config: Record<string, unknown> | undefined) {
findById.mockResolvedValue(config === undefined ? null : { id: 'llm_parsing', config });
}
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
setInstanceConfig(undefined);
setRole('user');
getUserSettings.mockReturnValue({});
getAdminUserDefaults.mockReturnValue({});
getDecryptedUserSetting.mockReturnValue(null);
@@ -61,6 +39,8 @@ describe('resolveLlmConfig', () => {
it('returns null when the addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
expect(await resolver.resolve(1)).toBeNull();
// Gated BEFORE the addon-row read: a disabled addon never even queries it.
expect(findById).not.toHaveBeenCalled();
});
it('uses instance config when present (and decrypts the key)', async () => {
@@ -72,11 +52,11 @@ describe('resolveLlmConfig', () => {
apiKey: 'sk-plain',
multimodal: true,
});
expect(findById).toHaveBeenCalledWith('llm_parsing');
});
it('instance config with a base URL still wins for a plain user (#1772 does not touch it)', async () => {
setInstanceConfig({ provider: 'local', model: 'nuextract', baseUrl: 'http://ollama:11434' });
setRole('user');
expect(await resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama:11434' });
});
@@ -99,6 +79,11 @@ describe('resolveLlmConfig', () => {
expect(await resolver.resolve(1)).toBeNull();
});
it('returns null when the addon row has no config at all', async () => {
setInstanceConfig(undefined);
expect(await resolver.resolve(1)).toBeNull();
});
// #1772: the endpoint is instance configuration, so it may only come from an
// admin-controlled source, whoever is asking.
it('#1772: picking local personally gets no config at all (no silent reroute)', async () => {
@@ -126,13 +111,14 @@ describe('resolveLlmConfig', () => {
expect(await resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama.internal:11434' });
});
it('#1772: the caller\'s role does not change the answer, and no role is looked up', async () => {
it('#1772: the caller\'s identity does not change the answer — the resolver never reads anything keyed by it besides the settings service', async () => {
// An instance has one endpoint. An admin who parked one in their own row is
// in exactly the same position as anyone else, and the resolver no longer
// reads the users table at all.
setRole('admin');
// in exactly the same position as anyone else.
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
expect(await resolver.resolve(7)).toBeNull();
expect(dbMock._role.get).not.toHaveBeenCalled();
// The addon-row read is a single, userId-independent lookup — called once,
// with no user-scoped argument.
expect(findById).toHaveBeenCalledTimes(1);
expect(findById).toHaveBeenCalledWith('llm_parsing');
});
});
@@ -6,31 +6,35 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
const { testDb, dbMock, broadcastMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {} }, broadcastMock: vi.fn() };
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
return { db, closeDb: () => {}, reinitialize: () => {} };
});
vi.mock('../../../src/db/database', () => dbMock);
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { db as testDb } from '../../../src/db/database';
import { createUser } from '../../helpers/factories';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { McpSharedModule } from '../../../src/nest/mcp-shared/mcp-shared.module';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestUsersRepo } from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
let svc: McpToolGuardsService;
beforeAll(async () => {
svc = new McpToolGuardsService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)), new RealtimeService());
// Plan 4 Task 1 constructor-ripple: the trip `user_id` and user `role`
// reads moved off `DatabaseService` onto `TripsRepository`/`UsersRepository`.
svc = new McpToolGuardsService(
await createTestTripsRepo(testDb),
await createTestUsersRepo(testDb),
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
new RealtimeService(),
);
});
function createTrip(ownerId: number): number {
@@ -38,11 +42,6 @@ function createTrip(ownerId: number): number {
return Number(r.lastInsertRowid);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
testDb.prepare('DELETE FROM trip_members').run();
+8 -17
View File
@@ -15,12 +15,10 @@ import jwtLib from 'jsonwebtoken';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -35,10 +33,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -75,7 +73,6 @@ vi.mock('../../../src/utils/ssrfGuard', async (importOriginal) => {
};
});
const { getAppUrlMock } = vi.hoisted(() => ({ getAppUrlMock: vi.fn() }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
@@ -88,9 +85,8 @@ vi.mock('../../../src/nest/common/cookie', async (importOriginal) => {
return { ...actual, setAuthCookie: setAuthCookieMock };
});
import { db as testDb } from '../../../src/db/database';
import type { Request, Response } from 'express';
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 { DatabaseService } from '../../../src/nest/database/database.service';
@@ -142,7 +138,7 @@ beforeAll(async () => {
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membership,
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb),
@@ -161,11 +157,6 @@ const MOCK_CONFIG = {
discoveryUrl: null,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
+8 -16
View File
@@ -17,12 +17,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
// Passkeys are trip-free, so the trip-access helpers are inert stubs.
const mock = {
db,
@@ -32,10 +30,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
canAccessTrip: () => undefined,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -71,9 +69,8 @@ const { swMock } = vi.hoisted(() => ({
}));
vi.mock('@simplewebauthn/server', () => swMock);
import { db as testDb } from '../../../src/db/database';
import jwtLib from 'jsonwebtoken';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
@@ -135,7 +132,7 @@ beforeAll(async () => {
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)),
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb),
@@ -158,11 +155,6 @@ const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.
const LOCALHOST_CFG = { rpID: 'localhost', rpName: 'TREK', origins: ['http://localhost:5173', 'http://localhost:3001'], explicitOrigins: false };
const NOT_CONFIGURED_ERROR = 'Passkey login is not configured for this server.';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
vi.clearAllMocks();
resolveWebauthnConfigMock.mockReturnValue(CFG);
@@ -202,19 +202,24 @@ describe('applyPlatformUploads', () => {
expect(res.statusCode).toBe(401);
});
// R1/R5 (Plan 3h Task 6): the share-token half of this handler now reads
// through `ShareTokensRepository.findTripIdByToken` — `orm.em.getRepository
// (ShareTokens)` inside the SAME `withRequestContext` wrap `applyPlatformUploads`
// already uses for the JWT half — so these cases seed REAL rows in
// `uploadsTestDb` (bound to `uploadsOrm.orm`) rather than mocking
// `db.prepare`'s return value the way the (still-unconverted) sibling
// `photos` read below continues to. `photoStmt` stays a `db.prepare` mock
// (untouched domain) throughout.
// R1/R5 (Plan 3h Task 6, R1 (Plan 4 Task 1)): both the share-token lookup
// AND the sibling `photos` read now go through the SAME real ORM
// (`ShareTokensRepository.findTripIdByToken` / `PhotosRepository
// .findTripIdByFilename`, `orm.em.getRepository(...)` inside the SAME
// `withRequestContext` wrap `applyPlatformUploads` already uses) — so
// every case here seeds REAL rows in `uploadsTestDb` (bound to
// `uploadsOrm.orm`) rather than mocking `db.prepare`'s return value; the
// `h.dbPrepare` stub is dead for this whole describe block now.
function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) {
uploadsTestDb.prepare('INSERT INTO share_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)')
.run(tripId, token, userId, expiresAt);
}
function insertPhoto(tripId: number, filename: string) {
uploadsTestDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, filename, filename);
}
it('401 when a share token does not cover the photo trip', async () => {
const { user } = createUser(uploadsTestDb);
const photoTrip = createTrip(uploadsTestDb, user.id);
@@ -222,9 +227,9 @@ describe('applyPlatformUploads', () => {
insertShareToken(otherTrip.id, user.id, 'share-mismatch');
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-mismatch.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share-mismatch' } }, res, next);
await photoHandler()({ params: { filename: 'photo-mismatch.jpg' }, headers: {}, query: { token: 'share-mismatch' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -233,9 +238,9 @@ describe('applyPlatformUploads', () => {
const photoTrip = createTrip(uploadsTestDb, user.id);
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-unknown-token.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'never-issued' } }, res, next);
await photoHandler()({ params: { filename: 'photo-unknown-token.jpg' }, headers: {}, query: { token: 'never-issued' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -246,9 +251,9 @@ describe('applyPlatformUploads', () => {
uploadsTestDb.prepare('DELETE FROM share_tokens WHERE token = ?').run('share-revoked');
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-revoked.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share-revoked' } }, res, next);
await photoHandler()({ params: { filename: 'photo-revoked.jpg' }, headers: {}, query: { token: 'share-revoked' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -258,9 +263,9 @@ describe('applyPlatformUploads', () => {
insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00');
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-expired.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share-expired' } }, res, next);
await photoHandler()({ params: { filename: 'photo-expired.jpg' }, headers: {}, query: { token: 'share-expired' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -270,9 +275,9 @@ describe('applyPlatformUploads', () => {
insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive');
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-case.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share-casesensitive' } }, res, next);
await photoHandler()({ params: { filename: 'photo-case.jpg' }, headers: {}, query: { token: 'share-casesensitive' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -282,9 +287,9 @@ describe('applyPlatformUploads', () => {
insertShareToken(photoTrip.id, user.id, 'share-nul');
h.exists.mockResolvedValue(true);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-nul.jpg');
const res = makeRes();
await photoHandler()({ params: { filename: 'a.jpg' }, headers: {}, query: { token: 'share-nul\0extra' } }, res, next);
await photoHandler()({ params: { filename: 'photo-nul.jpg' }, headers: {}, query: { token: 'share-nul\0extra' } }, res, next);
expect(res.statusCode).toBe(401);
});
@@ -295,14 +300,14 @@ describe('applyPlatformUploads', () => {
h.exists.mockResolvedValue(true);
h.sendToResponse.mockResolvedValue(undefined);
h.verifyJwtAndLoadUser.mockReturnValue(null);
h.dbPrepare.mockReturnValue({ get: vi.fn().mockReturnValue({ trip_id: photoTrip.id }) });
insertPhoto(photoTrip.id, 'photo-valid.jpg');
const res = makeRes();
await photoHandler()(
{ params: { filename: 'a.jpg' }, headers: { authorization: 'Bearer share-valid' }, query: {} },
{ params: { filename: 'photo-valid.jpg' }, headers: { authorization: 'Bearer share-valid' }, query: {} },
res,
next,
);
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'a.jpg', res);
expect(h.sendToResponse).toHaveBeenCalledWith('photos', 'photo-valid.jpg', res);
});
it('404 when the object vanishes between the exists check and the send', async () => {
+40 -25
View File
@@ -39,6 +39,8 @@ import { emitPluginEvent } from '../../../src/plugin-event-sink';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { User } from '../../../src/types';
interface FakeSocket extends TrekWebSocket {
@@ -64,10 +66,19 @@ function socket(): FakeSocket {
const rows = new Map<string, unknown>();
const db = {
get: (sql: string) => (sql.includes('app_settings') ? rows.get('mfa') : rows.get('user')),
canAccessTrip: (tripId: number) => tripId === 7,
} as unknown as DatabaseService;
const users = {
findForWsHandshake: vi.fn(async () => rows.get('user') ?? null),
} as unknown as UsersRepository;
const appSettings = {
getValue: vi.fn(async (key: string) =>
key === 'require_mfa' ? ((rows.get('mfa') as { value: string } | undefined)?.value ?? null) : null,
),
} as unknown as AppSettingsRepository;
const consumeWithMeta = vi.fn();
const tokens = { consumeWithMeta } as unknown as EphemeralTokenService;
@@ -76,7 +87,7 @@ const canAccessJourney = vi.fn((journeyId: number) => (journeyId === 4 ? null :
const journeys = { canAccessJourney } as unknown as JourneyDomainService;
async function connect(url: string) {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
await gw.handleConnection(ws, { url } as never);
return { gw, ws };
@@ -137,7 +148,7 @@ describe('RealtimeGateway handshake', () => {
});
it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
await expect(gw.handleConnection(ws, {} as never)).resolves.not.toThrow();
expect(ws.closedWith).toEqual([4001, 'Authentication required']);
@@ -151,6 +162,12 @@ describe('RealtimeGateway handshake', () => {
expect((await connect('/ws?token=x')).ws.closedWith).toBeNull();
});
it('WSGW-007d: reads the user row and the require_mfa setting through the repositories, by the right keys (Plan 4 Task 1)', async () => {
await connect('/ws?token=x');
expect(users.findForWsHandshake).toHaveBeenCalledWith(3);
expect(appSettings.getValue).toHaveBeenCalledWith('require_mfa');
});
it('WSGW-008: never leaks password_version past the handshake', async () => {
// Asserted on what the socket registry HOLDS, not on the frames it sends.
// The frames never carry the user object at all, so the earlier version of
@@ -168,11 +185,10 @@ describe('RealtimeGateway handshake', () => {
// Anything thrown inside the try — here, the user-row lookup blowing up —
// must still answer the callback rather than escape as an uncaught
// exception (recipe R1.5's catch site).
const throwing = {
get: () => { throw new Error('db exploded'); },
canAccessTrip: (tripId: number) => tripId === 7,
} as unknown as DatabaseService;
const gw = new RealtimeGateway(throwing, tokens, journeys);
const throwingUsers = {
findForWsHandshake: () => { throw new Error('db exploded'); },
} as unknown as UsersRepository;
const gw = new RealtimeGateway(db, tokens, journeys, throwingUsers, appSettings);
const ws = socket();
await gw.handleConnection(ws, { url: '/ws?token=x' } as never);
@@ -182,11 +198,10 @@ describe('RealtimeGateway handshake', () => {
});
it('WSGW-009b: a non-Error thrown during the handshake is stringified rather than crashing the catch itself', async () => {
const throwing = {
get: () => { throw 'db string'; }, // non-Error throw, pinning the ternary's String(err) fallback
canAccessTrip: (tripId: number) => tripId === 7,
} as unknown as DatabaseService;
const gw = new RealtimeGateway(throwing, tokens, journeys);
const throwingUsers = {
findForWsHandshake: () => { throw 'db string'; }, // non-Error throw, pinning the ternary's String(err) fallback
} as unknown as UsersRepository;
const gw = new RealtimeGateway(db, tokens, journeys, throwingUsers, appSettings);
const ws = socket();
await gw.handleConnection(ws, { url: '/ws?token=x' } as never);
@@ -223,7 +238,7 @@ describe('RealtimeGateway heartbeat', () => {
alive.isAlive = true;
const stale = socket();
stale.isAlive = false;
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
gw.afterInit({ clients: new Set([alive, stale]) } as never);
vi.advanceTimersByTime(30_000);
@@ -244,7 +259,7 @@ describe('RealtimeGateway heartbeat', () => {
try {
const ws = socket();
ws.isAlive = true;
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
gw.afterInit({ clients: new Set([ws]) } as never);
gw.onModuleDestroy();
@@ -353,7 +368,7 @@ describe('book rooms', () => {
let nextJourney = 100;
async function joined(journeyId: number) {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const reply = await gw.handleBookJoin({ journeyId }, ws);
@@ -369,7 +384,7 @@ describe('book rooms', () => {
/* Same shape as the trip room's refusal, and for the same reason. */
it('WSGW-BOOK-002: refuses a journey the user cannot see, and adds nobody', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
@@ -420,7 +435,7 @@ describe('book pointers', () => {
async function pair() {
const journeyId = nextJourney++;
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
@@ -485,7 +500,7 @@ describe('book messages that are refused', () => {
let nextJourney = 300;
it('WSGW-BOOK-006: a join without a journey id is ignored', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
@@ -495,7 +510,7 @@ describe('book messages that are refused', () => {
/* An unauthenticated socket never got as far as the registry. */
it('WSGW-BOOK-007: a join from a socket with no user is ignored', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const j = nextJourney++;
expect(await gw.handleBookJoin({ journeyId: j }, socket())).toBeUndefined();
@@ -503,7 +518,7 @@ describe('book messages that are refused', () => {
});
it('WSGW-BOOK-008: a journey id that is not a number is refused, not joined', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
@@ -512,7 +527,7 @@ describe('book messages that are refused', () => {
});
it('WSGW-BOOK-009: a leave without a journey id is ignored', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
@@ -521,7 +536,7 @@ describe('book messages that are refused', () => {
/* Leaving a room nobody is in must not create one on the way out. */
it('WSGW-BOOK-010: leaving a book that was never joined does nothing', () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const ws = socket();
registerSocket(ws, { id: 3, username: 'm' } as User);
const j = nextJourney++;
@@ -531,7 +546,7 @@ describe('book messages that are refused', () => {
});
it('WSGW-CUR-005: a pointer without a journey id reaches nobody', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const mine = socket();
const theirs = socket();
registerSocket(mine, { id: 3, username: 'm' } as User);
@@ -546,7 +561,7 @@ describe('book messages that are refused', () => {
});
it('WSGW-CUR-006: a pointer from a socket with no user reaches nobody', async () => {
const gw = new RealtimeGateway(db, tokens, journeys);
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
const theirs = socket();
registerSocket(theirs, { id: 4, username: 'o' } as User);
const j = nextJourney++;
@@ -25,15 +25,14 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createTrip, createInviteToken } from '../../helpers/factories';
import { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createTestInviteTokensRepo } from '../../helpers/test-uow';
import { createTestInviteTokensRepo, createTestTripsRepo } from '../../helpers/test-uow';
let svc: RegistrationInvitesService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new RegistrationInvitesService(new DatabaseService(testDb), await createTestInviteTokensRepo(testDb));
svc = new RegistrationInvitesService(await createTestInviteTokensRepo(testDb), await createTestTripsRepo(testDb));
});
beforeEach(() => { resetTestDb(testDb); vi.clearAllMocks(); });
afterAll(() => { testDb.close(); });
@@ -12,7 +12,6 @@
import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers';
import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.provider';
import { TransitService } from '../../../src/nest/transit/transit.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
@@ -27,10 +26,9 @@ vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'T
const fetchMock = vi.fn();
// No `transit_provider` row means Transitous, so every case below keeps
// exercising the MOTIS path — the Google branch has its own suite.
const db = { get: () => undefined, run: () => undefined } as unknown as DatabaseService;
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const svc = new TransitService(new GoogleTransitProvider(db, noAppSettings, noUsers));
const svc = new TransitService(new GoogleTransitProvider(noAppSettings, noUsers));
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
@@ -40,7 +40,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTripInviteTokensRepo } from '../../helpers/test-uow';
// One DatabaseService over the shared in-memory handle, so every collaborator
// reads and writes the same rows.
@@ -48,7 +48,13 @@ const dbs = new DatabaseService(testDb);
let svc: TripInviteService;
beforeAll(async () => {
const uow = await createTestUnitOfWork(dbs.connection);
svc = new TripInviteService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), uow), new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), uow);
svc = new TripInviteService(
dbs,
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), uow),
new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)),
uow,
await createTestTripInviteTokensRepo(dbs.connection),
);
});
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
@@ -15,13 +15,15 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock, broadcast, notifySend } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
const { broadcast, notifySend } = vi.hoisted(() => ({
broadcast: vi.fn(),
notifySend: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
@@ -35,15 +37,9 @@ const { testDb, dbMock, broadcast, notifySend } = vi.hoisted(() => {
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return {
testDb: db,
dbMock: mock,
broadcast: vi.fn(),
notifySend: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('../../../src/db/database', () => dbMock);
import { db as testDb } from '../../../src/db/database';
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -53,8 +49,6 @@ vi.mock('../../../src/websocket', () => ({ broadcast }));
// notifyInvite reaches the bridge through a dynamic import — keep the send in scope
// but out of the transports.
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 { DatabaseService } from '../../../src/nest/database/database.service';
@@ -105,18 +99,13 @@ beforeAll(async () => {
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection), ...(await budgetRepoArgs(dbs().connection)));
roster = new TripMembersService(
dbs(), budgetSvc,
new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), usersRepo, await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)),
new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), usersRepo, await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)),
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection),
tripsRepo, tripMembersRepo, usersRepo,
);
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcast.mockClear();
@@ -12,12 +12,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -32,10 +30,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -44,8 +42,7 @@ vi.mock('../../../src/config', () => ({
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
@@ -177,7 +174,7 @@ beforeAll(async () => {
await createTestBudgetItemsRepo(dbs().connection),
await createTestCollectionPlacesRepo(dbs().connection),
);
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection));
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection));
});
const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersService = membersSvc) =>
@@ -225,11 +222,6 @@ beforeAll(async () => {
svc = await buildReadModel(tripsRepo);
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
+8 -21
View File
@@ -17,12 +17,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
const db = createSnapshotTestDb();
const mock = {
db,
closeDb: () => {},
@@ -37,10 +35,10 @@ const { testDb, dbMock } = vi.hoisted(() => {
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
return mock;
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -50,8 +48,7 @@ const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { db as testDb } from '../../../src/db/database';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
import { MAX_TRIP_DAYS } from '@trek/shared';
@@ -142,7 +139,6 @@ import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
let dbsEm: import('@mikro-orm/core').EntityManager | undefined;
const dbs = () => new DatabaseService(testDb, dbsEm);
// Same collaborator set the container hands PlacesService (see places.service.test.ts).
// Only the read-model aggregation reaches into places here, but the photo cache,
// Unsplash and journey domain are real instances over the same in-memory DB
@@ -155,7 +151,6 @@ const dbs = () => new DatabaseService(testDb, dbsEm);
let photoCache: PlacePhotoCacheService;
const coversFx = makeStorageFixture('covers/');
let accommodationsSvc: Awaited<ReturnType<typeof makeAccommodationsService>>;
let createAccommodation: (...args: Parameters<typeof accommodationsSvc.createAccommodation>) => ReturnType<typeof accommodationsSvc.createAccommodation>;
beforeAll(async () => {
@@ -163,8 +158,6 @@ beforeAll(async () => {
createAccommodation = (...args) => accommodationsSvc.createAccommodation(...args);
});
let budgetSvc: BudgetService;
let daysSvc: DaysService;
let placesSvc: PlacesService;
@@ -243,7 +236,7 @@ beforeAll(async () => {
await createTestUnitOfWork(dbs().connection),
(await sharedTestOrm(testDb)).em,
);
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection));
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection));
readModelSvc = new TripReadModelService(
await createTestTripsRepo(dbs().connection), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(
@@ -282,12 +275,6 @@ beforeAll(async () => {
);
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
@@ -53,8 +53,9 @@ import type { JourneyShareTokensRepository } from '../../../src/db/repositories/
import type { JourneysRepository } from '../../../src/db/repositories/Journeys.repository';
import type { JourneyEntriesRepository } from '../../../src/db/repositories/JourneyEntries.repository';
import type { JourneyContributorsRepository } from '../../../src/db/repositories/JourneyContributors.repository';
import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
@@ -77,7 +78,10 @@ beforeAll(async () => {
vi.spyOn(dbs, 'getPlaceWithTags').mockImplementation((...a) => real.getPlaceWithTags(...a));
budget = new BudgetService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection)));
svc = new UserCleanupService(
dbs, budget, await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection),
dbs, budget, await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection),
// Plan 4 Task 1 constructor-ripple: UC4's repository.
await createTestTripMembersRepo(dbs.connection),
await createTestBudgetItemsRepo(dbs.connection),
// Plan 3g Task 4 constructor-ripple: UC7-10's repositories.
await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection),
await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
@@ -178,11 +182,13 @@ describe('erasePluginUserData', () => {
const slim = new (require('better-sqlite3'))(':memory:');
slim.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)');
slim.prepare('INSERT INTO users (id) VALUES (1)').run();
// `erasePluginUserData` (this test's only call) never reaches `budgetItemsRepo`
// (UC5's own method) or the Plan 3g Task 4 journey repositories (UC7-10) —
// stubs are enough, and the slim schema has no journey tables to bind against.
// `erasePluginUserData` (this test's only call) never reaches `tripMembersRepo`
// (UC4), `budgetItemsRepo` (UC5's own method) or the Plan 3g Task 4 journey
// repositories (UC7-10) — stubs are enough, and the slim schema has no
// trip_members/journey tables to bind against.
const slimSvc = new UserCleanupService(
new DatabaseService(slim), budget, await createTestUnitOfWork(slim), await createTestUsersRepo(slim), {} as unknown as BudgetItemsRepository,
new DatabaseService(slim), budget, await createTestUnitOfWork(slim), await createTestUsersRepo(slim),
{} as unknown as TripMembersRepository, {} as unknown as BudgetItemsRepository,
{} as unknown as JourneyShareTokensRepository, {} as unknown as JourneysRepository,
{} as unknown as JourneyEntriesRepository, {} as unknown as JourneyContributorsRepository,
// Plan 3h Task 6: a real repository (never a stub cast — `erasePluginUserData`
@@ -320,7 +326,9 @@ describe('deleteUserCompletely', () => {
const deleteByIdSpy = vi.spyOn(usersRepo, 'deleteById').mockRejectedValue(new Error('boom'));
try {
await expect(new UserCleanupService(
dbs, budget, await createTestUnitOfWork(testDb), usersRepo, await createTestBudgetItemsRepo(testDb),
dbs, budget, await createTestUnitOfWork(testDb), usersRepo,
await createTestTripMembersRepo(testDb),
await createTestBudgetItemsRepo(testDb),
await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb),
await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb),
await createTestShareTokensRepo(testDb),