mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
refactor(server): plan 4 — convert the program's orphaned raw-SQL callers
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user