test(server): seed the repository specs through the ORM factories

The repository suites for assignments, notifications, document sync,
tags, photo metadata, vacay, day notes, notice dismissals, idempotency
keys, settings, travellers, invites, providers, hidden regions, road
trip tracks and the collection role check seeded their fixtures and
read back what a write left behind with raw statements. Fixtures now go
through the ORM factories and insertRow/updateRows, and the state reads
through findRow/findRows/countRows.

What stays raw is raw on purpose and carries a test-sql-allow marker:
the legacy statements every parity case compares a repository against,
the full raw rows used as such an oracle, PRAGMA reads, typeof() and
date() storage checks, and the out-of-band writes the identity map
freshness cases are about, which have to bypass every EntityManager.
This commit is contained in:
Maurice
2026-10-08 16:15:56 +02:00
parent 57404dee17
commit 514d5159ce
22 changed files with 522 additions and 412 deletions
@@ -13,6 +13,10 @@ import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entit
import type { DayAssignmentsRepository } from '../../../../src/db/repositories/DayAssignments.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import type { DayAssignmentRow } from '../../../helpers/factories/itinerary';
import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import { DayAccommodations } from '../../../../src/db/entities/DayAccommodations.entity';
import { Places } from '../../../../src/db/entities/Places.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -27,8 +31,21 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
/** The named columns of the assignment, or undefined once it is gone: what a `SELECT <cols> FROM day_assignments` read back. */
async function assignmentCols<K extends keyof DayAssignmentRow>(id: number, ...cols: K[]): Promise<Pick<DayAssignmentRow, K> | undefined> {
const row = await findRow(t, DayAssignments, { id });
if (!row) return undefined;
return Object.fromEntries(cols.map((c) => [c, row[c]])) as Pick<DayAssignmentRow, K>;
}
/** The assignment's order_index. */
async function orderOf(id: number) {
return (await findRow(t, DayAssignments, { id }))!.order_index;
}
/** The DY1/DY3/AS1/AS3 statement, run raw — the parity oracle every assertion below is checked against. */
function legacyProjectionRow(id: number): unknown {
// test-sql-allow: the legacy DY1/DY3/AS1/AS3 statement is the parity oracle the repository is compared against.
return testDb.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -101,8 +118,8 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
const day = createDay(testDb, trip.id);
const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id);
await updateRows(t, Places, { id: tourPlace.id }, { route_geometry: '[[47,11],[47.1,11.1]]' });
await updateRows(t, Places, { id: trackPlace.id }, { route_geometry: '[[1,2],[3,4]]' });
createTour(testDb, tourPlace.id);
const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 });
const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 });
@@ -124,9 +141,9 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '09:00', end_time: '10:00' });
const withOwnTime = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', withOwnTime.id);
await updateRows(t, DayAssignments, { id: withOwnTime.id }, { assignment_time: '14:00', assignment_end_time: '15:00' });
const withoutOwnTime = createDayAssignment(testDb, day.id, place.id);
const rows = await assignments.listForDay(day.id);
@@ -143,7 +160,7 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET category_id = NULL WHERE id = ?').run(place.id);
await updateRows(t, Places, { id: place.id }, { category: null });
const assignment = createDayAssignment(testDb, day.id, place.id);
const [row] = await assignments.listForDay(day.id);
@@ -204,6 +221,7 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
const assignment = createDayAssignment(testDb, day.id, place.id);
await t.repo(DayAssignments).find({}, { disableIdentityMap: false }); // populate the identity map with the managed assignment entity
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Renamed', place.id);
const [row] = await assignments.listForDay(day.id);
@@ -247,10 +265,10 @@ describe('DayAssignmentsRepository — AC20/AC21 (the stop a booking owns, carri
await assignments.relocate(stop.id, toDay.id, stopPlace.id, 6);
const read = testDb.prepare('SELECT day_id, place_id, order_index FROM day_assignments WHERE id = ?');
expect(read.get(stop.id)).toEqual({ day_id: toDay.id, place_id: stopPlace.id, order_index: 6 });
expect(read.get(neighbour.id)).toEqual({ day_id: fromDay.id, place_id: otherPlace.id, order_index: 1 });
expect(read.get(existing.id)).toEqual({ day_id: toDay.id, place_id: otherPlace.id, order_index: 5 });
const read = (id: number) => assignmentCols(id, 'day_id', 'place_id', 'order_index');
expect(await read(stop.id)).toEqual({ day_id: toDay.id, place_id: stopPlace.id, order_index: 6 });
expect(await read(neighbour.id)).toEqual({ day_id: fromDay.id, place_id: otherPlace.id, order_index: 1 });
expect(await read(existing.id)).toEqual({ day_id: toDay.id, place_id: otherPlace.id, order_index: 5 });
});
});
@@ -287,9 +305,10 @@ describe('DayAssignmentsRepository — AS4/AS9/AS12 existence + trip-scoped read
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, notes = ? WHERE id = ?').run('09:00', 'a note', a.id);
await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '09:00', notes: 'a note' });
const row = await assignments.findInTrip(a.id, trip.id);
// test-sql-allow: the legacy AS12 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`SELECT da.* FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`).get(a.id, trip.id);
expect(row).toStrictEqual(legacy);
expect(row).toMatchObject({ id: a.id, day_id: day.id, order_index: 3, assignment_time: '09:00', notes: 'a note' });
@@ -323,11 +342,10 @@ describe('DayAssignmentsRepository — AS6/AS7/AS8 (createAssignment)', () => {
await withRequestContext(t.orm, async () => { await assignments.shiftOrderFrom(day.id, 1); });
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
expect(order(before.id)).toBe(0);
expect(order(at.id)).toBe(2);
expect(order(after.id)).toBe(3);
expect(order(foreign.id)).toBe(1);
expect(await orderOf(before.id)).toBe(0);
expect(await orderOf(at.id)).toBe(2);
expect(await orderOf(after.id)).toBe(3);
expect(await orderOf(foreign.id)).toBe(1);
});
it('ASSIGNREPO-006 (AS8, insertAssignment): writes every column and returns the inserted id, notes/accommodation_id nullable', async () => {
@@ -340,13 +358,13 @@ describe('DayAssignmentsRepository — AS6/AS7/AS8 (createAssignment)', () => {
day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null,
}));
expect(typeof id).toBe('number');
const row = testDb.prepare('SELECT day_id, place_id, order_index, notes, accommodation_id FROM day_assignments WHERE id = ?').get(id);
const row = await assignmentCols(id, 'day_id', 'place_id', 'order_index', 'notes', 'accommodation_id');
expect(row).toEqual({ day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null });
const id2 = await withRequestContext(t.orm, () => assignments.insertAssignment({
day_id: day.id, place_id: place.id, order_index: 0, notes: 'skip the line', accommodation_id: 42,
}));
const row2 = testDb.prepare('SELECT notes, accommodation_id FROM day_assignments WHERE id = ?').get(id2);
const row2 = await assignmentCols(id2, 'notes', 'accommodation_id');
expect(row2).toEqual({ notes: 'skip the line', accommodation_id: 42 });
});
});
@@ -359,7 +377,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.deleteById(a.id));
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined();
expect(await findRow(t, DayAssignments, { id: a.id })).toBeNull();
});
it('ASSIGNREPO-008 (AS11, setOrderIndex day-scoped): writes only the row matching BOTH id and day_id', async () => {
@@ -375,9 +393,8 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
// A day_id mismatch (this id does not sit on otherDay) writes nothing.
await withRequestContext(t.orm, () => assignments.setOrderIndex(foreign.id, day.id, 9));
const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index;
expect(order(a.id)).toBe(7);
expect(order(foreign.id)).toBe(0);
expect(await orderOf(a.id)).toBe(7);
expect(await orderOf(foreign.id)).toBe(0);
});
it('ASSIGNREPO-009 (AS19, setOrderIndex unscoped): day_id undefined writes by id alone', async () => {
@@ -387,7 +404,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
await withRequestContext(t.orm, () => assignments.setOrderIndex(a.id, undefined, 3));
expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3);
expect(await orderOf(a.id)).toBe(3);
});
it('ASSIGNREPO-010 (AS13, getDayId): the assignment\'s day_id, undefined for a missing id', async () => {
@@ -418,7 +435,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
await withRequestContext(t.orm, () => assignments.moveToDay(a.id, target.id, 4));
const row = testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id);
const row = await assignmentCols(a.id, 'day_id', 'order_index');
expect(row).toEqual({ day_id: target.id, order_index: 4 });
});
@@ -446,7 +463,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
const row = testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id);
const row = await assignmentCols(a.id, 'day_id', 'order_index');
expect(row).toEqual({ day_id: day.id, order_index: 0 });
});
});
@@ -457,11 +474,12 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '08:00' });
const a = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('14:00', a.id);
await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '14:00' });
const row = await withRequestContext(t.orm, () => assignments.effectiveStart(a.id));
// test-sql-allow: the legacy AS16 three-way COALESCE is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
FROM day_assignments da JOIN places p ON da.place_id = p.id
@@ -477,7 +495,7 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '08:00' });
const a = createDayAssignment(testDb, day.id, place.id);
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(a.id))).toEqual({ day_id: day.id, start: '08:00' });
});
@@ -488,10 +506,8 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => {
const day = createDay(testDb, trip.id);
const hotel = createPlace(testDb, trip.id);
const night = createDayAssignment(testDb, day.id, hotel.id);
const stay = testDb.prepare(
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (?, ?, ?, ?, '17:00')",
).run(trip.id, hotel.id, day.id, day.id);
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id);
const stayId = await insertRow(t, DayAccommodations, { trip: trip.id, place: hotel.id, startDay: day.id, endDay: day.id, check_in: '17:00' });
await updateRows(t, DayAssignments, { id: night.id }, { accommodation_id: stayId });
expect(await withRequestContext(t.orm, () => assignments.effectiveStart(night.id))).toEqual({ day_id: day.id, start: '17:00' });
});
@@ -517,12 +533,13 @@ describe('DayAssignmentsRepository.listForTimeSort (AS18, Kysely)', () => {
const day = createDay(testDb, trip.id);
const located = createPlace(testDb, trip.id, { name: 'Has coords' });
const unlocated = createPlace(testDb, trip.id, { name: 'No coords' });
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unlocated.id);
await updateRows(t, Places, { id: unlocated.id }, { lat: null, lng: null });
const a1 = createDayAssignment(testDb, day.id, located.id, { order_index: 1 });
const a2 = createDayAssignment(testDb, day.id, unlocated.id, { order_index: 0 });
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('09:00', a1.id);
await updateRows(t, DayAssignments, { id: a1.id }, { assignment_time: '09:00' });
const rows = await withRequestContext(t.orm, () => assignments.listForTimeSort(day.id));
// test-sql-allow: the legacy AS18 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT da.id, da.order_index, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time,
(p.lat IS NOT NULL AND p.lng IS NOT NULL) as located
@@ -554,18 +571,19 @@ describe('DayAssignmentsRepository.listSeatRows (AC7, Kysely)', () => {
const museum = createPlace(testDb, trip.id, { name: 'Museum' });
const cafe = createPlace(testDb, trip.id, { name: 'Cafe' });
const unlocated = createPlace(testDb, trip.id, { name: 'No coords' });
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', hotel.id);
testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('11:00', cafe.id);
testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unlocated.id);
await updateRows(t, Places, { id: hotel.id }, { place_time: '08:00' });
await updateRows(t, Places, { id: cafe.id }, { place_time: '11:00' });
await updateRows(t, Places, { id: unlocated.id }, { lat: null, lng: null });
const night = createDayAssignment(testDb, day.id, hotel.id, { order_index: 3 });
const ownTime = createDayAssignment(testDb, day.id, museum.id, { order_index: 1 });
const placeTime = createDayAssignment(testDb, day.id, cafe.id, { order_index: 0 });
const noCoords = createDayAssignment(testDb, day.id, unlocated.id, { order_index: 2 });
const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id, { check_in: '17:00' });
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stay.id, night.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('09:00', ownTime.id);
await updateRows(t, DayAssignments, { id: night.id }, { accommodation_id: stay.id });
await updateRows(t, DayAssignments, { id: ownTime.id }, { assignment_time: '09:00' });
const rows = await withRequestContext(t.orm, () => assignments.listSeatRows(day.id));
// test-sql-allow: the legacy AC7 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT da.id, da.order_index,
CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at,
@@ -593,10 +611,10 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setTimes(a.id, '09:00', '10:30'));
expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id))
expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time'))
.toEqual({ assignment_time: '09:00', assignment_end_time: '10:30' });
await withRequestContext(t.orm, () => assignments.setTimes(a.id, null, null));
expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id))
expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time'))
.toEqual({ assignment_time: null, assignment_end_time: null });
});
@@ -621,7 +639,7 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id))
expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time'))
.toEqual({ assignment_time: null, assignment_end_time: null });
});
@@ -632,7 +650,7 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setEndDay(a.id, 1));
expect((testDb.prepare('SELECT end_day FROM day_assignments WHERE id = ?').get(a.id) as { end_day: number }).end_day).toBe(1);
expect((await findRow(t, DayAssignments, { id: a.id }))!.end_day).toBe(1);
});
it('ASSIGNREPO-021 (AS25, setNotes): null-clearable', async () => {
@@ -642,9 +660,9 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setNotes(a.id, 'a note'));
expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBe('a note');
expect((await findRow(t, DayAssignments, { id: a.id }))!.notes).toBe('a note');
await withRequestContext(t.orm, () => assignments.setNotes(a.id, null));
expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBeNull();
expect((await findRow(t, DayAssignments, { id: a.id }))!.notes).toBeNull();
});
it('ASSIGNREPO-022 (AS26/AS27, setLegMode/setIncomingLegMode): each writes its own column', async () => {
@@ -655,10 +673,10 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
const a = createDayAssignment(testDb, day.id, place.id);
await withRequestContext(t.orm, () => assignments.setLegMode(a.id, 'cycling'));
await withRequestContext(t.orm, () => assignments.setIncomingLegMode(a.id, 'walking'));
expect(testDb.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id))
expect(await assignmentCols(a.id, 'leg_transport_mode', 'incoming_leg_transport_mode'))
.toEqual({ leg_transport_mode: 'cycling', incoming_leg_transport_mode: 'walking' });
await withRequestContext(t.orm, () => assignments.setLegMode(a.id, null));
expect((testDb.prepare('SELECT leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id) as { leg_transport_mode: string | null }).leg_transport_mode).toBeNull();
expect((await findRow(t, DayAssignments, { id: a.id }))!.leg_transport_mode).toBeNull();
});
it('ASSIGNREPO-039 (AS32, listIdsToClear): exactly the day\'s ids, [] for an empty day', async () => {
@@ -685,8 +703,8 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
createDayAssignment(testDb, day.id, place.id);
const kept = createDayAssignment(testDb, otherDay.id, place.id);
await withRequestContext(t.orm, () => assignments.deleteForDay(day.id));
expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]);
expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(otherDay.id)).toEqual([{ id: kept.id }]);
expect(await findRows(t, DayAssignments, { day: day.id })).toEqual([]);
expect((await findRows(t, DayAssignments, { day: otherDay.id }, { id: 'asc' })).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]);
});
it('ASSIGNREPO-041 (AS34, setRouteExcluded): writes 1 and then 0 on exactly the given row', async () => {
@@ -696,13 +714,13 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 (
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
const other = createDayAssignment(testDb, day.id, place.id);
const routeExcluded = (id: number) => (testDb.prepare('SELECT route_excluded FROM day_assignments WHERE id = ?').get(id) as { route_excluded: number }).route_excluded;
const routeExcluded = async (id: number) => (await findRow(t, DayAssignments, { id }))!.route_excluded;
await withRequestContext(t.orm, () => assignments.setRouteExcluded(a.id, 1));
expect(routeExcluded(a.id)).toBe(1);
expect(routeExcluded(other.id)).toBe(0);
expect(await routeExcluded(a.id)).toBe(1);
expect(await routeExcluded(other.id)).toBe(0);
await withRequestContext(t.orm, () => assignments.setRouteExcluded(a.id, 0));
expect(routeExcluded(a.id)).toBe(0);
expect(routeExcluded(other.id)).toBe(0);
expect(await routeExcluded(a.id)).toBe(0);
expect(await routeExcluded(other.id)).toBe(0);
});
});
@@ -735,7 +753,7 @@ describe('DayAssignmentsRepository.listItineraryForGpx (PL30)', () => {
const day1 = createDay(testDb, trip.id, { day_number: 1, date: '2026-01-01', title: 'Day One' });
const day2 = createDay(testDb, trip.id, { day_number: 2, date: '2026-01-02' });
const geo = createPlace(testDb, trip.id, { name: 'Has Coords', lat: 1, lng: 2 });
const noGeo = testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, NULL, NULL)').run(trip.id, 'No Coords').lastInsertRowid as number;
const noGeo = await insertRow(t, Places, { trip: trip.id, name: 'No Coords', lat: null, lng: null });
const secondStop = createPlace(testDb, trip.id, { name: 'Second Stop', lat: 3, lng: 4 });
// day2's stop first (order_index 0), day1's stop second — proves the
// ORDER BY is day_number then order_index, not insertion/id order.
@@ -754,7 +772,7 @@ describe('DayAssignmentsRepository.listItineraryForGpx (PL30)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const noGeo = testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, NULL, NULL)').run(trip.id, 'No Coords').lastInsertRowid as number;
const noGeo = await insertRow(t, Places, { trip: trip.id, name: 'No Coords', lat: null, lng: null });
createDayAssignment(testDb, day.id, noGeo);
expect(await assignments.listItineraryForGpx(trip.id)).toEqual([]);
});
@@ -788,34 +806,29 @@ describe('DayAssignmentsRepository.listAllForTrip (TP48)', () => {
const bare = createPlace(testDb, trip.id, { name: 'Stop B' });
const accommodation = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id);
const insert = testDb.prepare(`
INSERT INTO day_assignments (
day_id, place_id, order_index, notes, reservation_status, reservation_notes,
reservation_datetime, created_at, assignment_time, assignment_end_time,
leg_transport_mode, incoming_leg_transport_mode, end_day, accommodation_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// Row A: every nullable column non-null, a unicode string and a
// '007'-style digit string among the values.
const a1 = insert.run(
day.id, place.id, 5, 'Notes — 日本 ☕️ 007', 'confirmed', 'resv notes 007',
'2026-01-01T10:00:00Z', '2026-01-01 09:00:00', '10:00', '11:00',
'walking', 'driving', 1, accommodation.id,
).lastInsertRowid as number;
const a1 = await insertRow(t, DayAssignments, {
day: day.id, place: place.id, order_index: 5, notes: 'Notes — 日本 ☕️ 007', reservation_status: 'confirmed',
reservation_notes: 'resv notes 007', reservation_datetime: '2026-01-01T10:00:00Z', created_at: '2026-01-01 09:00:00',
assignment_time: '10:00', assignment_end_time: '11:00', leg_transport_mode: 'walking',
incoming_leg_transport_mode: 'driving', end_day: 1, accommodation_id: accommodation.id,
});
// Row B: every nullable column NULL (end_day is NOT NULL, given 0).
const a2 = insert.run(
day.id, bare.id, null, null, null, null,
null, null, null, null,
null, null, 0, null,
).lastInsertRowid as number;
const a2 = await insertRow(t, DayAssignments, {
day: day.id, place: bare.id, order_index: null, notes: null, reservation_status: null,
reservation_notes: null, reservation_datetime: null, created_at: null, assignment_time: null,
assignment_end_time: null, leg_transport_mode: null, incoming_leg_transport_mode: null, end_day: 0,
accommodation_id: null,
});
const otherDay = createDay(testDb, other.id);
const otherPlace = createPlace(testDb, other.id);
createDayAssignment(testDb, otherDay.id, otherPlace.id);
const rows = await assignments.listAllForTrip(trip.id);
// test-sql-allow: the legacy TP48 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?').all(trip.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([a1, a2]);
@@ -841,11 +854,10 @@ describe('DayAssignmentsRepository.insertAssignmentCopy (TP49)', () => {
assignment_time: '10:00', assignment_end_time: '11:00', end_day: 0,
});
const row = testDb.prepare(`
SELECT day_id, place_id, order_index, notes, reservation_status, reservation_notes,
reservation_datetime, assignment_time, assignment_end_time, end_day, accommodation_id
FROM day_assignments WHERE id = ?
`).get(newId);
const row = await assignmentCols(
newId, 'day_id', 'place_id', 'order_index', 'notes', 'reservation_status', 'reservation_notes',
'reservation_datetime', 'assignment_time', 'assignment_end_time', 'end_day', 'accommodation_id',
);
expect(row).toEqual({
day_id: day.id, place_id: place.id, order_index: 2, notes: 'n',
reservation_status: 'booked', reservation_notes: 'rn', reservation_datetime: '2026-01-01T10:00',
@@ -866,8 +878,8 @@ describe('DayAssignmentsRepository.setAccommodation (TP57)', () => {
await assignments.setAccommodation(assignment.id, accom.id);
expect((testDb.prepare('SELECT accommodation_id FROM day_assignments WHERE id = ?').get(assignment.id) as { accommodation_id: number }).accommodation_id).toBe(accom.id);
expect((testDb.prepare('SELECT accommodation_id FROM day_assignments WHERE id = ?').get(other.id) as { accommodation_id: number | null }).accommodation_id).toBeNull();
expect((await findRow(t, DayAssignments, { id: assignment.id }))!.accommodation_id).toBe(accom.id);
expect((await findRow(t, DayAssignments, { id: other.id }))!.accommodation_id).toBeNull();
});
});
@@ -903,10 +915,10 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id, { name: 'Museum' });
testDb.prepare('UPDATE places SET place_time = ?, end_time = ?, duration_minutes = ?, stop_type = ?, fill_percent = ? WHERE id = ?')
.run('09:00', '11:00', 90, null, null, place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '09:00', end_time: '11:00', duration_minutes: 90, stop_type: null, fill_percent: null });
createDayAssignment(testDb, day.id, place.id);
// test-sql-allow: the legacy RPL2 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id);
const typed = await assignments.listRoadtripVisits(trip.id);
expect(typed).toEqual(legacy);
@@ -919,13 +931,14 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi
const day = createDay(testDb, trip.id, { day_number: 1 });
const checkoutDay = createDay(testDb, trip.id, { day_number: 2 });
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
testDb.prepare('UPDATE places SET place_time = ?, end_time = ?, duration_minutes = ?, stop_type = ?, fill_percent = ? WHERE id = ?')
.run('08:00', '10:00', 45, 'lodging', 80, place.id);
await updateRows(t, Places, { id: place.id }, { place_time: '08:00', end_time: '10:00', duration_minutes: 45, stop_type: 'lodging', fill_percent: 80 });
const stay = createDayAccommodation(testDb, trip.id, place.id, day.id, checkoutDay.id, { check_in: '15:00', check_out: '11:00' });
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ?, leg_transport_mode = ?, incoming_leg_transport_mode = ?, end_day = ? WHERE id = ?')
.run('14:00', '14:30', 'driving', 'walking', 1, assignment.id);
await updateRows(t, DayAssignments, { id: assignment.id }, {
assignment_time: '14:00', assignment_end_time: '14:30', leg_transport_mode: 'driving', incoming_leg_transport_mode: 'walking', end_day: 1,
});
// test-sql-allow: the legacy RPL2 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id);
const typed = await assignments.listRoadtripVisits(trip.id);
expect(typed).toEqual(legacy);
@@ -956,6 +969,7 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi
function legacyPublicForShare(dayIds: number[]): unknown {
if (dayIds.length === 0) return [];
const placeholders = dayIds.map(() => '?').join(',');
// test-sql-allow: the legacy SH9 statement is the parity oracle the repository is compared against.
return testDb.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -982,25 +996,25 @@ describe('DayAssignmentsRepository.listPublicForShare (SH9, share.service.ts)',
const category = createCategory(testDb, { name: 'Museum', color: '#111111', icon: '🏛️' });
const placeFull = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.86, lng: 2.34, category_id: category.id, description: 'Art museum' });
testDb.prepare(`
UPDATE places SET address = ?, price = ?, currency = ?, place_time = ?, end_time = ?,
duration_minutes = ?, notes = ?, image_url = ?, transport_mode = ?, website = ?, phone = ?,
google_place_id = 'ChIJ123', google_ftid = 'ftid1', osm_id = 'osm1', amap_poi_id = 'amap1',
stop_type = 'lodging', fill_percent = 80
WHERE id = ?`).run('Rue de Rivoli', 17.5, 'EUR', '09:00', '11:00', 120, 'bring ID', 'https://img/louvre.jpg', 'walking', 'https://louvre.fr', '+33140205050', placeFull.id);
await updateRows(t, Places, { id: placeFull.id }, {
address: 'Rue de Rivoli', price: 17.5, currency: 'EUR', place_time: '09:00', end_time: '11:00',
duration_minutes: 120, notes: 'bring ID', image_url: 'https://img/louvre.jpg', transport_mode: 'walking',
website: 'https://louvre.fr', phone: '+33140205050', google_place_id: 'ChIJ123', google_ftid: 'ftid1',
osm_id: 'osm1', amap_poi_id: 'amap1', stop_type: 'lodging', fill_percent: 80,
});
const assignmentOnFullPlace = createDayAssignment(testDb, day1.id, placeFull.id, { order_index: 2 });
testDb.prepare(`
UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ?, reservation_status = 'confirmed',
reservation_notes = 'window seat', reservation_datetime = '2026-09-01T09:00:00.000Z',
leg_transport_mode = 'driving', incoming_leg_transport_mode = 'walking', accommodation_id = NULL
WHERE id = ?`).run('14:00', '14:30', assignmentOnFullPlace.id);
await updateRows(t, DayAssignments, { id: assignmentOnFullPlace.id }, {
assignment_time: '14:00', assignment_end_time: '14:30', reservation_status: 'confirmed',
reservation_notes: 'window seat', reservation_datetime: '2026-09-01T09:00:00.000Z',
leg_transport_mode: 'driving', incoming_leg_transport_mode: 'walking', accommodation_id: null,
});
const placeBare = createPlace(testDb, trip.id, { name: 'Unnamed spot' });
testDb.prepare(`
UPDATE places SET category_id = NULL, lat = NULL, lng = NULL, address = NULL, price = NULL,
currency = NULL, place_time = NULL, end_time = NULL, duration_minutes = NULL, notes = NULL,
image_url = NULL, transport_mode = NULL, website = NULL, phone = NULL, description = NULL
WHERE id = ?`).run(placeBare.id);
await updateRows(t, Places, { id: placeBare.id }, {
category: null, lat: null, lng: null, address: null, price: null, currency: null, place_time: null,
end_time: null, duration_minutes: null, notes: null, image_url: null, transport_mode: null,
website: null, phone: null, description: null,
});
const assignmentOnBarePlace = createDayAssignment(testDb, day2.id, placeBare.id, { order_index: 1 });
// A second assignment on day1, ordered before both of the above by order_index.
@@ -6,6 +6,7 @@ import { createDay, createDayNote, createTrip, createUser } from '../../../helpe
import { DayNotes } from '../../../../src/db/entities/DayNotes.entity';
import type { DayNotesRepository } from '../../../../src/db/repositories/DayNotes.repository';
import { DB_TIMESTAMP_RE } from '../../../../src/db/types';
import { findRow, insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -24,6 +25,7 @@ describe('DayNotesRepository timestamps', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null });
// test-sql-allow: typeof() reports the SQLite storage class of the stored value, which no entity maps.
const stored = testDb.prepare('SELECT created_at, typeof(created_at) AS kind FROM day_notes WHERE id = ?').get(row.id) as { created_at: string; kind: string };
expect(stored.kind).toBe('text');
expect(stored.created_at).toMatch(DB_TIMESTAMP_RE);
@@ -35,6 +37,7 @@ describe('DayNotesRepository timestamps', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null });
// test-sql-allow: the raw full row is the parity oracle the repository's return value is compared against.
expect(row).toStrictEqual(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(row.id));
expect(row.icon).toBe('📝');
expect(row.sort_order).toBe(0);
@@ -45,6 +48,7 @@ describe('DayNotesRepository timestamps', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null });
// test-sql-allow: the point is that SQLite's own date() parses the stored value, so the read has to be SQL.
const d = testDb.prepare('SELECT date(created_at) AS d FROM day_notes WHERE id = ?').get(row.id) as { d: string };
expect(d.d).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
@@ -115,6 +119,7 @@ describe('DayNotesRepository.listByDayIds (DY4)', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text' });
await t.repo(DayNotes).find({}, { disableIdentityMap: false }); // populate the identity map with the managed note entity
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE day_notes SET text = ? WHERE id = ?').run('Fresh text', note.id);
const rows = await notes.listByDayIds([day.id]);
@@ -137,17 +142,21 @@ describe('DayNotesRepository.listByTrip (TP70)', () => {
const other = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const insert = testDb.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color) VALUES (?, ?, ?, ?, ?, ?, ?)');
// Row A: every nullable column non-null, a unicode string and a
// '007'-style digit string among the values.
const n1 = insert.run(day.id, trip.id, 'Keep — 日本 ☕️ 007', '09:00', '🗒️', 1, '#00ff00').lastInsertRowid as number;
const n1 = await insertRow(t, DayNotes, {
day: day.id, trip: trip.id, text: 'Keep — 日本 ☕️ 007', time: '09:00', icon: '🗒️', sort_order: 1, color: '#00ff00',
});
// Row B: every nullable column NULL.
const n2 = insert.run(day.id, trip.id, 'Bare note', null, null, null, null).lastInsertRowid as number;
const n2 = await insertRow(t, DayNotes, {
day: day.id, trip: trip.id, text: 'Bare note', time: null, icon: null, sort_order: null, color: null,
});
const otherDay = createDay(testDb, other.id);
createDayNote(testDb, otherDay.id, other.id, { text: 'Not this trip' });
const rows = await notes.listByTrip(trip.id);
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(trip.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([n1, n2]);
@@ -170,8 +179,9 @@ describe('DayNotesRepository.insertNoteCopy (TP71)', () => {
day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5,
});
const row = testDb.prepare('SELECT day_id, trip_id, text, time, icon, sort_order, color FROM day_notes WHERE id = ?').get(newId);
expect(row).toEqual({ day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, color: null });
const { day_id, trip_id, text, time, icon, sort_order, color } = (await findRow(t, DayNotes, { id: newId }))!;
expect({ day_id, trip_id, text, time, icon, sort_order, color })
.toEqual({ day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, color: null });
});
});
@@ -185,6 +195,7 @@ describe('DayNotesRepository.listByDayAndTrip (Plan 4 Task 1, day-notes.service.
const first = createDayNote(testDb, day.id, trip.id, { sort_order: 1 });
createDayNote(testDb, otherDay.id, trip.id);
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
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);
@@ -223,7 +234,8 @@ describe('DayNotesRepository.updateNote (Plan 4 Task 1, day-notes.service.ts::up
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))
const stored = (await findRow(t, DayNotes, { id: note.id }))!;
expect({ text: stored.text, time: stored.time, icon: stored.icon, sort_order: stored.sort_order, color: stored.color })
.toEqual({ text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' });
});
@@ -241,7 +253,7 @@ describe('DayNotesRepository.deleteById (Plan 4 Task 1, day-notes.service.ts::re
await notes.deleteById(note.id);
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
expect(await findRow(t, DayNotes, { id: note.id })).toBeNull();
});
it('NOTEREPO-018: deleting an id that does not exist is a no-op, not an error', async () => {
@@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity';
import type { DocumentProvidersRepository } from '../../../../src/db/repositories/DocumentProviders.repository';
import { insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -26,17 +27,17 @@ afterAll(async () => {
testDb.close();
});
function insertProvider(row: {
async function insertProvider(row: {
id: string;
name: string;
description?: string | null;
icon?: string | null;
enabled: 0 | 1;
sort_order?: number;
}): void {
testDb
.prepare('INSERT INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
.run(row.id, row.name, row.description ?? null, row.icon ?? null, row.enabled, row.sort_order ?? 0);
}): Promise<void> {
await insertRow(t, DocumentProviders, {
id: row.id, name: row.name, description: row.description ?? null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0,
});
}
// Plan 3i Task 4 fix wave (should-land 5): AD29 (listAllOrdered) and AD32
@@ -47,9 +48,10 @@ function insertProvider(row: {
// docstring names.
describe('DocumentProvidersRepository — admin (AD29/AD32) read methods, full-key parity', () => {
it('DOCPROVREPO-001 (AD29): listAllOrdered matches SELECT * FROM document_providers ORDER BY sort_order, id — unfiltered, including a disabled provider', async () => {
insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 1 });
insertProvider({ id: 'papra', name: 'Papra', enabled: 1, sort_order: 0 });
await insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 1 });
await insertProvider({ id: 'papra', name: 'Papra', enabled: 1, sort_order: 0 });
// test-sql-allow: the legacy AD29 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM document_providers ORDER BY sort_order, id').all();
const rows = await documentProviders.listAllOrdered();
expect(rows.map((r) => r.id)).toEqual(['papra', 'paperless']); // includes the disabled one, unlike listEnabledCatalog
@@ -64,10 +66,11 @@ describe('DocumentProvidersRepository — admin (AD29/AD32) read methods, full-k
});
it('DOCPROVREPO-003 (AD32): findById matches SELECT * FROM document_providers WHERE id = ?, on both a pre-write read and a post-write re-select', async () => {
insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 });
await insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 });
const preWrite = await documentProviders.findById('paperless');
expect(preWrite).toEqual({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 });
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE document_providers SET enabled = 1 WHERE id = ?').run('paperless');
const postWrite = await documentProviders.findById('paperless');
expect(postWrite?.enabled).toBe(1);
@@ -13,6 +13,10 @@ import { createTrip, createUser } from '../../../helpers/factories';
import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity';
import type { DocumentSyncItemsRepository } from '../../../../src/db/repositories/DocumentSyncItems.repository';
import { currentTimestampKysely } from '../../../../src/db/dialect/kysely-functions';
import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity';
import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity';
import { TripFiles } from '../../../../src/db/entities/TripFiles.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -25,22 +29,22 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function fixture() {
async function fixture() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const connInfo = testDb
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
)
.run(trip.id, user.id);
const linkInfo = testDb
.prepare(
`INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy, conflict_policy, sync_enabled, created_by)
VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`,
)
.run(trip.id, connInfo.lastInsertRowid, user.id);
return { user, trip, linkId: Number(linkInfo.lastInsertRowid) };
const connectionId = await insertRow(t, DocumentConnections, {
trip: trip.id, provider: 'paperless', ownerUser: user.id, base_url: 'https://paperless.example.com', secrets: null, settings: '{}',
});
const linkId = await insertRow(t, TripDocumentLinks, {
trip: trip.id, connection: connectionId, provider_id: 'paperless', remote_scope_key: 'tag:1', remote_label: 'Japan',
direction: 'both', delete_policy: 'unlink', conflict_policy: 'manual', sync_enabled: 1, createdByRef: user.id,
});
return { user, trip, linkId };
}
/** The sync item as it is now; fails the test when it is gone. */
async function itemRow(id: number) {
return (await findRow(t, DocumentSyncItems, { id }))!;
}
function insertData(linkId: number, tripId: number, over: Partial<Parameters<DocumentSyncItemsRepository['insertOrUpsertOnConflict']>[0]> = {}) {
@@ -121,17 +125,17 @@ describe('DocumentSyncItemsRepository', () => {
* remote_id per link" post-conversion by upserting (not duplicating).
*/
it('DS24REPO-002 (R3 mutation proof): a colliding (link_id, remote_id) pair upserts in place rather than duplicating', async () => {
const { linkId, trip } = fixture();
const { linkId, trip } = await fixture();
await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-a', state: 'synced', remote_version: 'v1' }));
await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r2', trek_doc_uid: 'uid-b', state: 'synced', remote_version: 'v1' }));
const afterTwo = testDb.prepare('SELECT id, remote_id, remote_version FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string }>;
const afterTwo = await findRows(t, DocumentSyncItems, { link: linkId }, { id: 'asc' });
expect(afterTwo).toHaveLength(2);
const r1Id = afterTwo.find((r) => r.remote_id === 'r1')!.id;
// The colliding insert: same link_id + remote_id='r1' as the first row.
await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-c', state: 'conflict', remote_version: 'v2' }));
const afterThree = testDb.prepare('SELECT id, remote_id, remote_version, state FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string; state: string }>;
const afterThree = await findRows(t, DocumentSyncItems, { link: linkId }, { id: 'asc' });
// Still exactly two rows — the third insert upserted onto the first, not a third row.
expect(afterThree).toHaveLength(2);
const upserted = afterThree.find((r) => r.id === r1Id)!;
@@ -141,10 +145,10 @@ describe('DocumentSyncItemsRepository', () => {
});
it('DS24REPO-003: two rows with NULL remote_id under the same link never collide (the partial index only constrains non-NULL)', async () => {
const { linkId, trip } = fixture();
const { linkId, trip } = await fixture();
await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-x' }));
await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-y' }));
const rows = testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ?').all(linkId);
const rows = await findRows(t, DocumentSyncItems, { link: linkId });
expect(rows).toHaveLength(2);
});
});
@@ -156,7 +160,7 @@ describe('DocumentSyncItemsRepository', () => {
remote_id: 'r1', file_id: null, remote_name: 'a.pdf', remote_version: 'v1', remote_size: 512,
remote_modified_at: '2026-09-01T08:00:00Z', content_sha256: 'old-hash', pushed_sha256: 'old-pushed',
}),
).then(() => (testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?').get(linkId, 'r1') as { id: number }).id);
).then(async () => (await findRow(t, DocumentSyncItems, { link: linkId, remote_id: 'r1' }))!.id);
return id;
}
@@ -169,11 +173,9 @@ describe('DocumentSyncItemsRepository', () => {
* genuinely proving "kept", not merely "never changed".
*/
it('DS23REPO-001 (R3 parity): a non-null new value overwrites every COALESCE column; a null one keeps the existing value', async () => {
const { linkId, trip } = fixture();
const { linkId, trip } = await fixture();
const itemId = await seedRow(linkId, trip.id);
const newFileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'stored.pdf', 'b.pdf').lastInsertRowid,
);
const newFileId = await insertRow(t, TripFiles, { trip: trip.id, filename: 'stored.pdf', original_name: 'b.pdf' });
// Branch 1: every COALESCE column gets a genuinely new, non-null value.
await repo.recordAttempt(itemId, {
@@ -181,7 +183,7 @@ describe('DocumentSyncItemsRepository', () => {
remote_name: 'b.pdf', remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z',
content_sha256: 'new-hash', pushed_sha256: 'new-pushed', attempts: 0, next_attempt_after_seconds: null,
});
let row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record<string, unknown>;
let row = await itemRow(itemId);
expect(row).toMatchObject({
file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048,
remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed',
@@ -193,7 +195,7 @@ describe('DocumentSyncItemsRepository', () => {
remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
});
row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record<string, unknown>;
row = await itemRow(itemId);
expect(row).toMatchObject({
file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048,
remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed',
@@ -201,7 +203,7 @@ describe('DocumentSyncItemsRepository', () => {
});
it('DS23REPO-002: next_attempt_after_seconds shifts the DB clock forward by the given second count; null leaves it NULL', async () => {
const { linkId, trip } = fixture();
const { linkId, trip } = await fixture();
const itemId = await seedRow(linkId, trip.id);
await repo.recordAttempt(itemId, {
@@ -209,37 +211,38 @@ describe('DocumentSyncItemsRepository', () => {
remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
pushed_sha256: null, attempts: 1, next_attempt_after_seconds: 300,
});
const row = testDb.prepare("SELECT next_attempt_at, datetime('now', '+300 seconds') AS expected FROM document_sync_items WHERE id = ?").get(itemId) as { next_attempt_at: string; expected: string };
expect(row.next_attempt_at).not.toBeNull();
expect(Math.abs(new Date(`${row.next_attempt_at.replace(' ', 'T')}Z`).getTime() - new Date(`${row.expected.replace(' ', 'T')}Z`).getTime())).toBeLessThan(5000);
const nextAttemptAt = (await itemRow(itemId)).next_attempt_at;
const expected = Date.now() + 300_000;
expect(nextAttemptAt).not.toBeNull();
expect(Math.abs(new Date(`${nextAttemptAt!.replace(' ', 'T')}Z`).getTime() - expected)).toBeLessThan(5000);
await repo.recordAttempt(itemId, {
state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null,
remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
});
const cleared = testDb.prepare('SELECT next_attempt_at FROM document_sync_items WHERE id = ?').get(itemId) as { next_attempt_at: string | null };
const cleared = await itemRow(itemId);
expect(cleared.next_attempt_at).toBeNull();
});
it('DS23REPO-003: synced_at is stamped only when state is synced, and left alone otherwise', async () => {
const { linkId, trip } = fixture();
const { linkId, trip } = await fixture();
const itemId = await seedRow(linkId, trip.id);
testDb.prepare("UPDATE document_sync_items SET synced_at = '2020-01-01 00:00:00' WHERE id = ?").run(itemId);
await updateRows(t, DocumentSyncItems, { id: itemId }, { synced_at: '2020-01-01 00:00:00' });
await repo.recordAttempt(itemId, {
state: 'error', error_code: 'timeout', file_id: null, remote_id: null, remote_version: null,
remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
pushed_sha256: null, attempts: 1, next_attempt_after_seconds: null,
});
expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).toBe('2020-01-01 00:00:00');
expect((await itemRow(itemId)).synced_at).toBe('2020-01-01 00:00:00');
await repo.recordAttempt(itemId, {
state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null,
remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null,
pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null,
});
expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).not.toBe('2020-01-01 00:00:00');
expect((await itemRow(itemId)).synced_at).not.toBe('2020-01-01 00:00:00');
});
});
});
@@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { GooglePlacePhotoMeta } from '../../../../src/db/entities/GooglePlacePhotoMeta.entity';
import type { GooglePlacePhotoMetaRepository } from '../../../../src/db/repositories/GooglePlacePhotoMeta.repository';
import { countRows, findRow, insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -22,18 +23,22 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function rawRow(placeId: string): unknown {
return testDb.prepare('SELECT * FROM google_place_photo_meta WHERE place_id = ?').get(placeId);
function storedRow(placeId: string) {
return findRow(t, GooglePlacePhotoMeta, { place_id: placeId });
}
function seedMeta(placeId: string, attribution: string | null, fetchedAt: number, errorAt: number | null) {
return insertRow(t, GooglePlacePhotoMeta, { place_id: placeId, attribution, fetched_at: fetchedAt, error_at: errorAt });
}
describe('GooglePlacePhotoMetaRepository.findLive / findErrored', () => {
it('GPPMREPO-001: findLive returns the attribution only when error_at is NULL', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Some Author', 1000);
await seedMeta('p1', 'Some Author', 1000, null);
expect(await meta.findLive('p1')).toEqual({ attribution: 'Some Author' });
});
it('GPPMREPO-002: findLive returns null when error_at is set, even with an attribution present', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 2000);
await seedMeta('p1', null, 1000, 2000);
expect(await meta.findLive('p1')).toBeNull();
});
@@ -43,26 +48,26 @@ describe('GooglePlacePhotoMetaRepository.findLive / findErrored', () => {
it('GPPMREPO-004: works with coords:lat:lng pseudo-ids (TEXT key, colons and dots included)', async () => {
const pseudoId = 'coords:48.8566:2.3522';
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run(pseudoId, 1000);
await seedMeta(pseudoId, null, 1000, null);
expect(await meta.findLive(pseudoId)).toEqual({ attribution: null });
});
it('GPPMREPO-005: findErrored returns {error_at} only when error_at IS NOT NULL', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 5000);
await seedMeta('p1', null, 1000, 5000);
expect(await meta.findErrored('p1')).toEqual({ error_at: 5000 });
});
it('GPPMREPO-006: findErrored returns null when error_at is NULL', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'A', 1000);
await seedMeta('p1', 'A', 1000, null);
expect(await meta.findErrored('p1')).toBeNull();
});
});
describe('GooglePlacePhotoMetaRepository.deleteByPlaceId', () => {
it('GPPMREPO-007: deletes the row for the given place_id', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run('p1', 1000);
await seedMeta('p1', null, 1000, null);
await meta.deleteByPlaceId('p1');
expect(rawRow('p1')).toBeUndefined();
expect(await storedRow('p1')).toBeNull();
});
it('GPPMREPO-008: deleting a missing place_id does not throw', async () => {
@@ -73,12 +78,12 @@ describe('GooglePlacePhotoMetaRepository.deleteByPlaceId', () => {
describe('GooglePlacePhotoMetaRepository.upsertError / upsertPhoto — INSERT OR REPLACE parity', () => {
it('GPPMREPO-009: upsertError writes NULL attribution and matching fetched_at/error_at on a fresh row', async () => {
await meta.upsertError('p1', 12345);
expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: null, fetched_at: 12345, error_at: 12345 });
expect(await storedRow('p1')).toMatchObject({ place_id: 'p1', attribution: null, fetched_at: 12345, error_at: 12345 });
});
it('GPPMREPO-010: upsertPhoto writes the attribution with a NULL error_at on a fresh row', async () => {
await meta.upsertPhoto('p1', 'Some Author · CC BY 2.0', 999);
expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: 'Some Author · CC BY 2.0', fetched_at: 999, error_at: null });
expect(await storedRow('p1')).toMatchObject({ place_id: 'p1', attribution: 'Some Author · CC BY 2.0', fetched_at: 999, error_at: null });
});
// PP4 forces attribution NULL; PP5 forces error_at NULL — same PK, mirror
@@ -86,23 +91,23 @@ describe('GooglePlacePhotoMetaRepository.upsertError / upsertPhoto — INSERT OR
// row after each (Task 1 brief's explicit test requirement).
it('GPPMREPO-011: put then markError then put again — each call overwrites the row exactly (INSERT OR REPLACE semantics via em.upsert merge)', async () => {
await meta.upsertPhoto('p1', 'Author A', 100);
expect(rawRow('p1')).toMatchObject({ attribution: 'Author A', fetched_at: 100, error_at: null });
expect(await storedRow('p1')).toMatchObject({ attribution: 'Author A', fetched_at: 100, error_at: null });
await meta.upsertError('p1', 200);
expect(rawRow('p1')).toMatchObject({ attribution: null, fetched_at: 200, error_at: 200 });
expect(await storedRow('p1')).toMatchObject({ attribution: null, fetched_at: 200, error_at: 200 });
await meta.upsertPhoto('p1', 'Author B', 300);
expect(rawRow('p1')).toMatchObject({ attribution: 'Author B', fetched_at: 300, error_at: null });
expect(await storedRow('p1')).toMatchObject({ attribution: 'Author B', fetched_at: 300, error_at: null });
// Only one row exists for this PK throughout — no duplicate insert.
expect(testDb.prepare('SELECT COUNT(*) as c FROM google_place_photo_meta WHERE place_id = ?').get('p1')).toEqual({ c: 1 });
expect(await countRows(t, GooglePlacePhotoMeta, { place_id: 'p1' })).toBe(1);
});
});
describe('GooglePlacePhotoMetaRepository.listPlaceIds', () => {
it('GPPMREPO-012: lists every place_id, full table scan', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p1', 1);
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p2', 2);
await insertRow(t, GooglePlacePhotoMeta, { place_id: 'p1', fetched_at: 1 });
await insertRow(t, GooglePlacePhotoMeta, { place_id: 'p2', fetched_at: 2 });
expect((await meta.listPlaceIds()).sort()).toEqual(['p1', 'p2']);
});
@@ -118,12 +123,13 @@ describe('GooglePlacePhotoMetaRepository.listPlaceIds', () => {
// non-PK-only `findOne` always re-runs its WHERE against the live table).
describe('GooglePlacePhotoMetaRepository — fresh after a raw UPDATE', () => {
it('GPPMREPO-014 (fresh after a raw UPDATE, not D-shape): a raw write after an unrelated identity-map read is visible in the next findLive call, in one query (disableIdentityMap regression)', async () => {
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Old', 1000);
await seedMeta('p1', 'Old', 1000, null);
// The FIRST, wider setup read passes `disableIdentityMap: false`
// explicitly and carries the column the later write targets
// (`attribution`) — a PK-only `findOne` on `place_id` alone (not
// `findLive`'s own `error_at IS NULL`-narrowed filter under test).
await t.repo(GooglePlacePhotoMeta).findOne({ place_id: 'p1' }, { disableIdentityMap: false });
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE google_place_photo_meta SET attribution = ? WHERE place_id = ?').run('New', 'p1');
const connection = t.orm.em.getConnection();
@@ -10,6 +10,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { HiddenRegions } from '../../../../src/db/entities/HiddenRegions.entity';
import type { HiddenRegionsRepository } from '../../../../src/db/repositories/HiddenRegions.repository';
import { insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -26,10 +27,11 @@ describe('HiddenRegionsRepository.listForUser (AT22)', () => {
it('HIDDENREGREPO-001: matches SELECT region_code FROM hidden_regions WHERE user_id = ? run raw, scoped to the user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(user.id, 'DE-BY', 'DE');
testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(user.id, 'DE-BE', 'DE');
testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(other.id, 'FR-IDF', 'FR');
await insertRow(t, HiddenRegions, { user: user.id, region_code: 'DE-BY', country_code: 'DE' });
await insertRow(t, HiddenRegions, { user: user.id, region_code: 'DE-BE', country_code: 'DE' });
await insertRow(t, HiddenRegions, { user: other.id, region_code: 'FR-IDF', country_code: 'FR' });
// test-sql-allow: the legacy AT22 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT region_code FROM hidden_regions WHERE user_id = ?').all(user.id);
const codes = await repo.listForUser(user.id);
@@ -5,6 +5,14 @@ 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 { countRows, findRow, findRows, insertRow } from '../../../helpers/factories/rows';
function seedKey(key: string, userId: number, method: string, path: string, statusCode: number, responseBody: string, createdAt?: number) {
return insertRow(t, IdempotencyKeys, {
key, user: userId, method, path, status_code: statusCode, response_body: responseBody,
...(createdAt !== undefined ? { created_at: createdAt } : {}),
});
}
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -21,8 +29,7 @@ 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}');
await seedKey('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.
@@ -46,7 +53,11 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => {
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');
const stored = (await findRow(t, IdempotencyKeys, { key: 'k' }))!;
const row = {
key: stored.key, user_id: stored.user_id, method: stored.method, path: stored.path,
status_code: stored.status_code, response_body: stored.response_body, created_at: stored.created_at,
};
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,
@@ -65,7 +76,7 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => {
});
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 });
expect(await countRows(t, IdempotencyKeys)).toBe(1);
});
it('IDEMPREPO-005: the SAME key for a DIFFERENT user is a separate row, not a conflict', async () => {
@@ -73,31 +84,27 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => {
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 });
expect(await countRows(t, IdempotencyKeys)).toBe(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);
await seedKey('old', user.id, 'POST', '/x', 200, '{}', 100);
await seedKey('boundary', user.id, 'POST', '/y', 200, '{}', 200);
await seedKey('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);
const keys = (await findRows(t, IdempotencyKeys, {}, { key: 'asc' })).map((r) => 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);
await seedKey('fresh', user.id, 'POST', '/z', 200, '{}', 1000);
expect(await idempotencyKeys.deleteExpired(500)).toBe(0);
});
});
@@ -7,6 +7,7 @@ import { InviteTokens } from '../../../../src/db/entities/InviteTokens.entity';
import type { InviteTokensRepository } from '../../../../src/db/repositories/InviteTokens.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { updateRows } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -20,6 +21,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function rawInvite(id: number): unknown {
// test-sql-allow: the raw full row is the parity oracle the repository's reads are compared against.
return testDb.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(id);
}
@@ -99,7 +101,7 @@ describe('InviteTokensRepository', () => {
it('INVREPO-006: at capacity — no row matches, returns null, used_count unchanged', async () => {
const created = createInviteToken(testDb, { token: 'at-cap', max_uses: 1 });
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(created.id);
await updateRows(t, InviteTokens, { id: created.id }, { used_count: 1 });
const row = await invites.incrementUsedCount('at-cap');
expect(row).toBeNull();
expect((rawInvite(created.id) as { used_count: number }).used_count).toBe(1);
@@ -107,7 +109,7 @@ describe('InviteTokensRepository', () => {
it('INVREPO-007: max_uses = 0 means unlimited — always increments', async () => {
createInviteToken(testDb, { token: 'unlimited', max_uses: 0 });
testDb.prepare("UPDATE invite_tokens SET used_count = 500 WHERE token = 'unlimited'").run();
await updateRows(t, InviteTokens, { token: 'unlimited' }, { used_count: 500 });
const row = await invites.incrementUsedCount('unlimited');
expect(row?.used_count).toBe(501);
});
@@ -138,9 +140,9 @@ describe('InviteTokensRepository', () => {
const { user: admin } = createUser(testDb, { username: 'inviter-1' });
const trip = createTrip(testDb, admin.id, { title: 'Bound Trip' });
const older = createInviteToken(testDb, { token: 'older', created_by: admin.id });
testDb.prepare('UPDATE invite_tokens SET created_at = ? WHERE id = ?').run('2026-01-01 00:00:00', older.id);
await updateRows(t, InviteTokens, { id: older.id }, { created_at: '2026-01-01 00:00:00' });
const newer = createInviteToken(testDb, { token: 'newer', created_by: admin.id });
testDb.prepare('UPDATE invite_tokens SET created_at = ?, trip_id = ? WHERE id = ?').run('2026-02-01 00:00:00', trip.id, newer.id);
await updateRows(t, InviteTokens, { id: newer.id }, { created_at: '2026-02-01 00:00:00', trip: trip.id });
const rows = await invites.listWithCreatorAndTrip();
@@ -5,6 +5,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, createTrip, addTripMember, type TestUser, type TestTrip } from '../../../helpers/factories';
import { Notifications } from '../../../../src/db/entities/Notifications.entity';
import type { NotificationsRepository } from '../../../../src/db/repositories/Notifications.repository';
import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
import { Users } from '../../../../src/db/entities/Users.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -28,41 +30,44 @@ afterAll(async () => {
});
/** Inserts one of each NT-inventory notification type, with every optional field set, for `recipientId`. Returns the three ids in insertion order (simple, boolean, navigate). */
function seedThreeTypes(recipientId: number, senderId: number | null): { simpleId: number; booleanId: number; navigateId: number } {
const simpleId = testDb.prepare(`
INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params)
VALUES ('simple', 'trip', ?, ?, ?, 'notif.trip_invite.title', '{"trip":"Rome"}', 'notif.trip_invite.text', '{"actor":"Alice"}')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
async function seedThreeTypes(recipientId: number, senderId: number | null): Promise<{ simpleId: number; booleanId: number; navigateId: number }> {
const simpleId = await insertRow(t, Notifications, {
type: 'simple', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId,
title_key: 'notif.trip_invite.title', title_params: '{"trip":"Rome"}', text_key: 'notif.trip_invite.text', text_params: '{"actor":"Alice"}',
});
const booleanId = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params,
positive_text_key, negative_text_key, positive_callback, negative_callback
) VALUES ('boolean', 'trip', ?, ?, ?, 'notif.vacay_invite.title', '{"trip":"Rome"}', 'notif.vacay_invite.text', '{"actor":"Bob"}',
'notif.action.accept', 'notif.action.decline', '{"action":"noop","payload":{"x":1}}', '{"action":"noop","payload":{"x":2}}')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
const booleanId = await insertRow(t, Notifications, {
type: 'boolean', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId,
title_key: 'notif.vacay_invite.title', title_params: '{"trip":"Rome"}', text_key: 'notif.vacay_invite.text', text_params: '{"actor":"Bob"}',
positive_text_key: 'notif.action.accept', negative_text_key: 'notif.action.decline',
positive_callback: '{"action":"noop","payload":{"x":1}}', negative_callback: '{"action":"noop","payload":{"x":2}}',
});
const navigateId = testDb.prepare(`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params,
navigate_text_key, navigate_target
) VALUES ('navigate', 'trip', ?, ?, ?, 'notif.booking_change.title', '{"trip":"Rome"}', 'notif.booking_change.text', '{"actor":"Carl"}',
'notif.action.view_trip', '/trips/1')
`).run(trip.id, senderId, recipientId).lastInsertRowid as number;
const navigateId = await insertRow(t, Notifications, {
type: 'navigate', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId,
title_key: 'notif.booking_change.title', title_params: '{"trip":"Rome"}', text_key: 'notif.booking_change.text', text_params: '{"actor":"Carl"}',
navigate_text_key: 'notif.action.view_trip', navigate_target: '/trips/1',
});
return { simpleId, booleanId, navigateId };
}
/** The legacy `SELECT * FROM notifications WHERE id = ?`, run raw, for a full-key parity comparison. */
function legacyFindById(id: number): unknown {
// test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against.
return testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id);
}
/** The notification row as it is now; fails the test when it is gone. */
async function notificationRow(id: number) {
return (await findRow(t, Notifications, { id }))!;
}
describe('NotificationsRepository — findById/findByIdForRecipient parity (fully seeded: simple/boolean/navigate, every optional field set)', () => {
it('NOTREPO-001 — findById matches the legacy full-row SELECT, byte for byte, for all three notification types', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb);
const { simpleId, booleanId, navigateId } = seedThreeTypes(recipient.id, sender.id);
const { simpleId, booleanId, navigateId } = await seedThreeTypes(recipient.id, sender.id);
for (const id of [simpleId, booleanId, navigateId]) {
const legacy = legacyFindById(id);
@@ -74,8 +79,9 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full
it('NOTREPO-002 — findByIdForRecipient matches the legacy recipient-scoped SELECT, and returns undefined for the wrong recipient', async () => {
const { user: recipient } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
const { booleanId } = await seedThreeTypes(recipient.id, null);
// test-sql-allow: the legacy recipient-scoped SELECT is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(booleanId, recipient.id);
expect(await repo.findByIdForRecipient(booleanId, recipient.id)).toEqual(legacy);
expect(await repo.findByIdForRecipient(booleanId, stranger.id)).toBeUndefined();
@@ -84,8 +90,9 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full
it('NOTREPO-003 — findWithSenderById matches the legacy LEFT JOIN users projection (sender_username/sender_avatar), including a NULL sender', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb, { username: 'Alice', email: 'alice@test.example.com' });
const { simpleId } = seedThreeTypes(recipient.id, sender.id);
const { simpleId } = await seedThreeTypes(recipient.id, sender.id);
// test-sql-allow: the legacy LEFT JOIN projection is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
@@ -94,10 +101,11 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full
expect(await repo.findWithSenderById(simpleId)).toEqual(legacy);
// A system notification (no sender) — the LEFT JOIN's NULL side.
const systemId = testDb.prepare(`
INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params)
VALUES ('simple', 'admin', 0, NULL, ?, 'notif.version_available.title', '{}', 'notif.version_available.text', '{}')
`).run(recipient.id).lastInsertRowid as number;
const systemId = await insertRow(t, Notifications, {
type: 'simple', scope: 'admin', target: 0, sender: null, recipient: recipient.id,
title_key: 'notif.version_available.title', title_params: '{}', text_key: 'notif.version_available.text', text_params: '{}',
});
// test-sql-allow: the legacy LEFT JOIN projection is the parity oracle the repository is compared against.
const legacySystem = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
@@ -109,14 +117,14 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full
it('NOTREPO-004 — listForRecipient matches the legacy joined page read, for both unreadOnly variants', async () => {
const { user: recipient } = createUser(testDb);
const { user: sender } = createUser(testDb);
seedThreeTypes(recipient.id, sender.id);
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? LIMIT 1').run(recipient.id);
// SQLite compiled without LIMIT-on-UPDATE support in better-sqlite3 by default — fall back to an id-scoped update.
const firstId = (testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ? ORDER BY id LIMIT 1').get(recipient.id) as { id: number }).id;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(firstId);
await seedThreeTypes(recipient.id, sender.id);
// The recipient's first notification is read, the other two are not.
const [first] = await findRows(t, Notifications, { recipient: recipient.id }, { id: 'asc' });
await updateRows(t, Notifications, { id: first.id }, { is_read: 1 });
for (const unreadOnly of [false, true]) {
const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?';
// test-sql-allow: the legacy joined page read is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar
FROM notifications n LEFT JOIN users u ON n.sender_id = u.id
@@ -132,6 +140,7 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full
describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => {
it('NOTREPO-005 — getTripOwnerId matches `SELECT user_id FROM trips WHERE id = ?`', async () => {
// test-sql-allow: the legacy statement named in the case title is the parity oracle.
const legacy = (testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id;
expect(await repo.getTripOwnerId(trip.id)).toBe(legacy);
expect(await repo.getTripOwnerId(999999)).toBeNull();
@@ -140,11 +149,10 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => {
it('NOTREPO-006 — listNonGuestTripMemberIds (NT2, the guest-exclusion chokepoint, #1362): a guest member never appears, a real member always does', async () => {
const { user: member } = createUser(testDb);
addTripMember(testDb, trip.id, member.id);
const guestId = (testDb.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-repo@guests.invalid', '', 'user', 1)",
).run()).lastInsertRowid as number;
const guestId = await insertRow(t, Users, { username: 'Guest', email: 'guest-repo@guests.invalid', password_hash: '', role: 'user', is_guest: 1 });
addTripMember(testDb, trip.id, guestId);
// test-sql-allow: the legacy statement named in the case title is the parity oracle.
const legacy = (testDb.prepare(
'SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0',
).all(trip.id) as { user_id: number }[]).map((r) => r.user_id);
@@ -165,10 +173,9 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => {
const { user: admin1 } = createUser(testDb, { role: 'admin' });
const { user: admin2 } = createUser(testDb, { role: 'admin' });
createUser(testDb); // a regular user — must not appear
const guestAdminId = (testDb.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('GuestAdmin', 'guest-admin@guests.invalid', '', 'admin', 1)",
).run()).lastInsertRowid as number;
const guestAdminId = await insertRow(t, Users, { username: 'GuestAdmin', email: 'guest-admin@guests.invalid', password_hash: '', role: 'admin', is_guest: 1 });
// test-sql-allow: the legacy statement named in the case title is the parity oracle.
const legacy = (testDb.prepare(
'SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0',
).all('admin') as { id: number }[]).map((r) => r.id);
@@ -182,7 +189,8 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => {
it('NOTREPO-009 — findUserBasic matches `SELECT username, avatar FROM users WHERE id = ?`', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('avatar.png', user.id);
await updateRows(t, Users, { id: user.id }, { avatar: 'avatar.png' });
// test-sql-allow: the legacy statement named in the case title is the parity oracle.
const legacy = testDb.prepare('SELECT username, avatar FROM users WHERE id = ?').get(user.id);
expect(await repo.findUserBasic(user.id)).toEqual(legacy);
expect(await repo.findUserBasic(999999)).toBeUndefined();
@@ -202,7 +210,7 @@ describe('NotificationsRepository — writes', () => {
navigate_text_key: null, navigate_target: null,
});
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as Record<string, unknown>;
const row = await notificationRow(id);
expect(row.type).toBe('boolean');
expect(row.scope).toBe('trip');
expect(row.target).toBe(trip.id);
@@ -216,11 +224,11 @@ describe('NotificationsRepository — writes', () => {
it('NOTREPO-011 — setRead/markAllRead/deleteForRecipient/deleteAllForRecipient are recipient-scoped and return the affected-row count', async () => {
const { user: recipient } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const { simpleId, booleanId } = seedThreeTypes(recipient.id, null);
const { simpleId, booleanId } = await seedThreeTypes(recipient.id, null);
expect(await repo.setRead(simpleId, stranger.id, 1)).toBe(0); // wrong recipient — no-op
expect(await repo.setRead(simpleId, recipient.id, 1)).toBe(1);
expect((testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(simpleId) as { is_read: number }).is_read).toBe(1);
expect((await notificationRow(simpleId)).is_read).toBe(1);
expect(await repo.setRead(simpleId, recipient.id, 0)).toBe(1);
@@ -235,22 +243,22 @@ describe('NotificationsRepository — writes', () => {
it('NOTREPO-012 — claimResponse (NT20) only succeeds once: the atomic double-submit claim', async () => {
const { user: recipient } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
const { booleanId } = await seedThreeTypes(recipient.id, null);
expect(await repo.claimResponse(booleanId, recipient.id, 'positive')).toBe(1);
// A second claim on the same, now-claimed notification affects 0 rows —
// the `response IS NULL` guard is the load-bearing part of this statement.
expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(0);
expect((testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(booleanId) as { response: string }).response).toBe('positive');
expect((await notificationRow(booleanId)).response).toBe('positive');
});
it('NOTREPO-013 — releaseResponse (NT21) restores the claim so a retry can succeed', async () => {
const { user: recipient } = createUser(testDb);
const { booleanId } = seedThreeTypes(recipient.id, null);
const { booleanId } = await seedThreeTypes(recipient.id, null);
await repo.claimResponse(booleanId, recipient.id, 'positive');
await repo.releaseResponse(booleanId, recipient.id, 0);
const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(booleanId) as { response: string | null; is_read: number };
const row = await notificationRow(booleanId);
expect(row.response).toBeNull();
expect(row.is_read).toBe(0);
expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(1);
@@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../../../src/db/repositories/PhotoProviders.repository';
import { findRow, insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -26,28 +27,28 @@ afterAll(async () => {
testDb.close();
});
function rawRow(id: string): unknown {
return testDb.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id);
function storedRow(id: string) {
return findRow(t, PhotoProviders, { id });
}
function insertProvider(row: {
async function insertProvider(row: {
id: string;
name: string;
description?: string | null;
icon?: string | null;
enabled: 0 | 1;
sort_order?: number;
}): void {
testDb
.prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
.run(row.id, row.name, row.description ?? null, row.icon ?? null, row.enabled, row.sort_order ?? 0);
}): Promise<void> {
await insertRow(t, PhotoProviders, {
id: row.id, name: row.name, description: row.description ?? null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0,
});
}
describe('PhotoProvidersRepository.listEnabled', () => {
it('ADDONSPPREPO-001: returns only enabled providers, ordered by sort_order then id', async () => {
insertProvider({ id: 'synology', name: 'Synology', enabled: 1, sort_order: 0 });
insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 });
insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: -1 });
await insertProvider({ id: 'synology', name: 'Synology', enabled: 1, sort_order: 0 });
await insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 });
await insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: -1 });
const rows = await photoProviders.listEnabled();
// Same sort_order (0) for both enabled rows — id breaks the tie.
@@ -55,7 +56,7 @@ describe('PhotoProvidersRepository.listEnabled', () => {
});
it('ADDONSPPREPO-002: the enabled column comes back as the raw stored integer, not a coerced boolean', async () => {
insertProvider({ id: 'immich', name: 'Immich', enabled: 1 });
await insertProvider({ id: 'immich', name: 'Immich', enabled: 1 });
const [row] = await photoProviders.listEnabled();
expect(row.enabled).toBe(1);
expect(typeof row.enabled).toBe('number');
@@ -66,7 +67,7 @@ describe('PhotoProvidersRepository.listEnabled', () => {
});
it('ADDONSPPREPO-004: carries the full row shape (id, name, description, icon, enabled, sort_order)', async () => {
insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 1, sort_order: 4 });
await insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 1, sort_order: 4 });
const [row] = await photoProviders.listEnabled();
expect(row).toEqual({
id: 'immich',
@@ -76,14 +77,14 @@ describe('PhotoProvidersRepository.listEnabled', () => {
enabled: 1,
sort_order: 4,
});
expect(rawRow('immich')).toMatchObject({ enabled: 1 });
expect(await storedRow('immich')).toMatchObject({ enabled: 1 });
});
});
describe('PhotoProvidersRepository.listAll / findEnabled', () => {
it('M1: listAll (the admin listing) returns every row regardless of enabled, unordered by that flag', async () => {
insertProvider({ id: 'immich', name: 'Immich', enabled: 1 });
insertProvider({ id: 'off', name: 'Off', enabled: 0 });
await insertProvider({ id: 'immich', name: 'Immich', enabled: 1 });
await insertProvider({ id: 'off', name: 'Off', enabled: 0 });
const rows = await photoProviders.listAll();
expect(rows.map((r) => r.id).sort()).toEqual(['immich', 'off']);
@@ -91,7 +92,7 @@ describe('PhotoProvidersRepository.listAll / findEnabled', () => {
});
it('M1: findEnabled reads the enabled flag for a known provider and null for an unknown one', async () => {
insertProvider({ id: 'synology', name: 'Synology', enabled: 1 });
await insertProvider({ id: 'synology', name: 'Synology', enabled: 1 });
expect(await photoProviders.findEnabled('synology')).toEqual({ enabled: 1 });
expect(await photoProviders.findEnabled('does-not-exist')).toBeNull();
});
@@ -103,9 +104,10 @@ describe('PhotoProvidersRepository.listAll / findEnabled', () => {
// names.
describe('PhotoProvidersRepository.listAllOrdered (AD27) / findById (AD31/AD40)', () => {
it('ADDONSPPREPO-005: listAllOrdered matches SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id — unfiltered, including a disabled provider', async () => {
insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: 1 });
insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 });
await insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: 1 });
await insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 });
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id').all();
const rows = await photoProviders.listAllOrdered();
expect(rows.map((r) => r.id)).toEqual(['immich', 'off']); // includes the disabled one, unlike listEnabled
@@ -117,10 +119,11 @@ describe('PhotoProvidersRepository.listAllOrdered (AD27) / findById (AD31/AD40)'
});
it('ADDONSPPREPO-007: findById matches SELECT * FROM photo_providers WHERE id = ?, on both a pre-write read and a post-write re-select (byte-identical text at both call sites)', async () => {
insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 });
await insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 });
const preWrite = await photoProviders.findById('immich');
expect(preWrite).toEqual({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 });
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
const postWrite = await photoProviders.findById('immich');
expect(postWrite?.enabled).toBe(1);
@@ -11,6 +11,9 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createReservation, createTrip, createUser } from '../../../helpers/factories';
import { ReservationTravelers } from '../../../../src/db/entities/ReservationTravelers.entity';
import type { ReservationTravelersRepository } from '../../../../src/db/repositories/ReservationTravelers.repository';
import { updateRows } from '../../../helpers/factories/rows';
import { addReservationTraveler } from '../../../helpers/factories/reservations';
import { Users } from '../../../../src/db/entities/Users.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -23,33 +26,35 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacyListForTrip = (trip_id: number) => testDb.prepare(`
SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
FROM reservation_travelers rt JOIN reservations r ON rt.reservation_id = r.id JOIN users u ON rt.user_id = u.id
WHERE r.trip_id = ? ORDER BY rt.reservation_id`).all(trip_id);
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacyListForReservation = (reservation_id: number) => testDb.prepare(`
SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest
FROM reservation_travelers rt JOIN users u ON rt.user_id = u.id WHERE rt.reservation_id = ?`).all(reservation_id);
describe('ReservationTravelersRepository — fully seeded world', () => {
const seed = () => {
const seed = async () => {
const { user: owner } = createUser(testDb);
const { user: named } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Named Traveler', named.id);
await updateRows(t, Users, { id: named.id }, { display_name: 'Named Traveler' });
const { user: guest } = createUser(testDb);
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
await updateRows(t, Users, { id: guest.id }, { is_guest: 1 });
const trip = createTrip(testDb, owner.id);
const flight = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(flight.id, owner.id);
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(flight.id, named.id);
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(hotel.id, guest.id);
await addReservationTraveler(t, flight.id, owner.id);
await addReservationTraveler(t, flight.id, named.id);
await addReservationTraveler(t, hotel.id, guest.id);
return { owner, named, guest, trip, flight, hotel };
};
it('RS6 listForTrip — matches the legacy JOIN statement, COALESCE(display_name, username), ordered by reservation', async () => {
const { trip, flight, hotel } = seed();
const { trip, flight, hotel } = await seed();
const legacy = legacyListForTrip(trip.id);
const typed = await repo.listForTrip(trip.id);
expect(typed).toEqual(legacy);
@@ -59,7 +64,7 @@ describe('ReservationTravelersRepository — fully seeded world', () => {
});
it('RR3 listForReservation — matches the legacy statement (no ORDER BY, no reservation_id column)', async () => {
const { flight, owner, named } = seed();
const { flight, owner, named } = await seed();
const legacy = legacyListForReservation(flight.id);
const typed = await repo.listForReservation(flight.id);
expect(typed).toEqual(legacy);
@@ -23,6 +23,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createDay, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { RoadtripDayTracks } from '../../../../src/db/entities/RoadtripDayTracks.entity';
import type { RoadtripDayTracksRepository } from '../../../../src/db/repositories/RoadtripDayTracks.repository';
import { findRows } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -44,39 +45,40 @@ function fixture() {
return { trip, dayA, placeA, placeB };
}
function rawRows(): { day_id: number; place_id: number; stray_km: number | null; created_at: string | null }[] {
return testDb.prepare('SELECT day_id, place_id, stray_km, created_at FROM roadtrip_day_tracks ORDER BY day_id').all() as never[];
async function storedRows(): Promise<{ day_id: number | null | undefined; place_id: number; stray_km: number | null | undefined; created_at: string | null | undefined }[]> {
return (await findRows(t, RoadtripDayTracks, {}, { day: 'asc' }))
.map((r) => ({ day_id: r.day_id, place_id: r.place_id, stray_km: r.stray_km, created_at: r.created_at }));
}
describe('RoadtripDayTracksRepository', () => {
it('RT7REPO-001: deleteForDay removes the one row for that day', async () => {
const { dayA, placeA } = fixture();
await tracks.upsertTrack(dayA.id, placeA.id, null);
expect(rawRows()).toHaveLength(1);
expect(await storedRows()).toHaveLength(1);
await tracks.deleteForDay(dayA.id);
expect(rawRows()).toHaveLength(0);
expect(await storedRows()).toHaveLength(0);
});
it('RT7REPO-002: deleteForDay on a day with no track is a silent no-op', async () => {
const { dayA } = fixture();
await expect(tracks.deleteForDay(dayA.id)).resolves.toBeUndefined();
expect(rawRows()).toHaveLength(0);
expect(await storedRows()).toHaveLength(0);
});
it('UPSERTTRACKREPO-001: a fresh day_id inserts one row', async () => {
const { dayA, placeA } = fixture();
await tracks.upsertTrack(dayA.id, placeA.id, 1.5);
const rows = rawRows();
const rows = await storedRows();
expect(rows).toEqual([{ day_id: dayA.id, place_id: placeA.id, stray_km: 1.5, created_at: expect.any(String) }]);
});
it('UPSERTTRACKREPO-002: re-upserting the SAME day_id updates in place — one row, the second call\'s place_id/stray_km, created_at untouched', async () => {
const { dayA, placeA, placeB } = fixture();
await tracks.upsertTrack(dayA.id, placeA.id, 1.5);
const before = rawRows()[0]!;
const before = (await storedRows())[0]!;
await tracks.upsertTrack(dayA.id, placeB.id, 9.25);
const after = rawRows();
const after = await storedRows();
// Exactly one row — if `onConflictFields: ['day']` resolved to anything
// other than the physical `day_id` PK, the second call would either
@@ -93,7 +95,7 @@ describe('RoadtripDayTracksRepository', () => {
const dayB = createDay(testDb, trip.id);
await tracks.upsertTrack(dayA.id, placeA.id, null);
await tracks.upsertTrack(dayB.id, placeA.id, null);
expect(rawRows()).toHaveLength(2);
expect(await storedRows()).toHaveLength(2);
});
it('UPSERTTRACKREPO-004: the rendered upsert SQL names the physical day_id column as its conflict target', async () => {
@@ -5,6 +5,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, type TestUser } from '../../../helpers/factories';
import { Settings } from '../../../../src/db/entities/Settings.entity';
import type { SettingsRepository } from '../../../../src/db/repositories/Settings.repository';
import { countRows, findRow, insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -27,22 +28,24 @@ afterAll(async () => {
testDb.close();
});
function rawRow(userId: number, key: string): unknown {
return testDb.prepare('SELECT * FROM settings WHERE user_id = ? AND key = ?').get(userId, key);
function storedRow(userId: number, key: string) {
return findRow(t, Settings, { user: userId, key });
}
function insertRaw(userId: number, key: string, value: string | null): void {
testDb.prepare('INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)').run(userId, key, value);
async function insertSetting(userId: number, key: string, value: string | null): Promise<void> {
await insertRow(t, Settings, { user: userId, key, value });
}
describe('settings table — the (user_id, key) unique index the ON CONFLICT target relies on', () => {
it(
"UNIQUE-SCHEMA-001: PRAGMA index_list('settings') reports a unique index over exactly (user_id, key), the migrated schema's inline UNIQUE(user_id, key)",
() => {
// test-sql-allow: the index list comes from PRAGMA index_list, which no entity or repository maps.
const indexes = testDb.prepare("PRAGMA index_list('settings')").all() as { name: string; unique: number }[];
const uniqueIndexes = indexes.filter((idx) => idx.unique);
expect(uniqueIndexes.length).toBeGreaterThan(0);
const columnSets = uniqueIndexes.map((idx) => {
// test-sql-allow: the index columns come from PRAGMA index_info, which no entity or repository maps.
const cols = testDb.prepare(`PRAGMA index_info('${idx.name}')`).all() as { seqno: number; name: string }[];
return [...cols].sort((a, b) => a.seqno - b.seqno).map((c) => c.name);
});
@@ -58,9 +61,9 @@ describe('settings table — the (user_id, key) unique index the ON CONFLICT tar
describe('SettingsRepository', () => {
it('SETTINGSREPO-001: getForUser reads every row for that user', async () => {
insertRaw(user.id, 'dark_mode', 'true');
insertRaw(user.id, 'temperature_unit', 'celsius');
insertRaw(otherUser.id, 'dark_mode', 'false'); // a different user's row — must not leak in
await insertSetting(user.id, 'dark_mode', 'true');
await insertSetting(user.id, 'temperature_unit', 'celsius');
await insertSetting(otherUser.id, 'dark_mode', 'false'); // a different user's row — must not leak in
const rows = await settings.getForUser(user.id);
expect(rows.map((r) => r.key).sort()).toEqual(['dark_mode', 'temperature_unit']);
expect(rows.every((r) => r.user_id === user.id)).toBe(true);
@@ -71,7 +74,7 @@ describe('SettingsRepository', () => {
});
it('SETTINGSREPO-003: getOne reads a single (user, key) row', async () => {
insertRaw(user.id, 'dark_mode', 'true');
await insertSetting(user.id, 'dark_mode', 'true');
const row = await settings.getOne(user.id, 'dark_mode');
expect(row).not.toBeNull();
expect(row?.value).toBe('true');
@@ -83,29 +86,27 @@ describe('SettingsRepository', () => {
});
it("SETTINGSREPO-005: getOne scopes strictly by user — another user's row for the same key never leaks", async () => {
insertRaw(otherUser.id, 'dark_mode', 'true');
await insertSetting(otherUser.id, 'dark_mode', 'true');
expect(await settings.getOne(user.id, 'dark_mode')).toBeNull();
});
it('SETTINGSREPO-006: upsertForUser inserts a new row exactly as the legacy INSERT would', async () => {
await settings.upsertForUser(user.id, 'dark_mode', 'true');
expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
});
it('SETTINGSREPO-007: upsertForUser on an existing (user, key) replaces the value only — no duplicate row, matching ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', async () => {
insertRaw(user.id, 'dark_mode', 'false');
await insertSetting(user.id, 'dark_mode', 'false');
await settings.upsertForUser(user.id, 'dark_mode', 'true');
expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
expect(
testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'),
).toEqual({ c: 1 });
expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
expect(await countRows(t, Settings, { user: user.id, key: 'dark_mode' })).toBe(1);
});
it("SETTINGSREPO-008: upsertForUser scopes by user — the same key for two different users stores two separate rows, not a collision (the conflict target is (user, key), not key alone)", async () => {
await settings.upsertForUser(user.id, 'dark_mode', 'true');
await settings.upsertForUser(otherUser.id, 'dark_mode', 'false');
expect(rawRow(user.id, 'dark_mode')).toMatchObject({ value: 'true' });
expect(rawRow(otherUser.id, 'dark_mode')).toMatchObject({ value: 'false' });
expect(await storedRow(user.id, 'dark_mode')).toMatchObject({ value: 'true' });
expect(await storedRow(otherUser.id, 'dark_mode')).toMatchObject({ value: 'false' });
});
it("SETTINGSREPO-014: em.upsert infers the (user, key) conflict target from the metadata's relation-named uniques entry with NO explicit onConflictFields — the entry the generator's fix(db) commit made consumable (task-5-review.md Important 1). SettingsRepository.upsertForUser still passes onConflictFields explicitly, for clarity, not because it is required.", async () => {
@@ -114,7 +115,7 @@ describe('SettingsRepository', () => {
try {
// Insert branch: no existing row, onConflictFields omitted entirely.
await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'true' }, { onConflictAction: 'merge' });
expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' });
// Merge branch: an existing row, same call shape — proves the SAME
// inferred target both creates and merges, not just happens to insert once.
@@ -123,10 +124,8 @@ describe('SettingsRepository', () => {
spy.mockClear();
await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'false' }, { onConflictAction: 'merge' });
expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'false' });
expect(
testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'),
).toEqual({ c: 1 });
expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'false' });
expect(await countRows(t, Settings, { user: user.id, key: 'dark_mode' })).toBe(1);
const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(mergeSql).toContain('on conflict (`user_id`, `key`)');
} finally {
@@ -140,15 +139,17 @@ describe('SettingsRepository', () => {
// never the surrogate `id` PK, so it is not PK-only and always re-queries,
// needing no `refresh: true`. These pin that directly rather than assume it.
it('SETTINGSREPO-012: getOne sees a raw UPDATE on the same (user, key) in the same request', async () => {
insertRaw(user.id, 'dark_mode', 'old');
await insertSetting(user.id, 'dark_mode', 'old');
expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('old'); // populate the identity map
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run('new', user.id, 'dark_mode');
expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('new');
});
it('SETTINGSREPO-013: getOne sees a raw DELETE on the same (user, key) in the same request', async () => {
insertRaw(user.id, 'dark_mode', 'old');
await insertSetting(user.id, 'dark_mode', 'old');
expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('old'); // populate the identity map
// test-sql-allow: the out-of-band delete this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('DELETE FROM settings WHERE user_id = ? AND key = ?').run(user.id, 'dark_mode');
expect(await settings.getOne(user.id, 'dark_mode')).toBeNull();
});
@@ -7,6 +7,8 @@ import { Tags } from '../../../../src/db/entities/Tags.entity';
import type { TagsRepository } from '../../../../src/db/repositories/Tags.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { findRows } from '../../../helpers/factories/rows';
import { tagPlace } from '../../../helpers/factories/places';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -22,9 +24,15 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function rawTag(id: number): unknown {
// test-sql-allow: the raw full row is the parity oracle the repository's reads are compared against.
return testDb.prepare('SELECT * FROM tags WHERE id = ?').get(id);
}
/** The ids of the tags on the place, through the place_tags pivot, in tag id order. */
async function placeTagIds(placeId: number): Promise<number[]> {
return (await findRows(t, Tags, { place_tags_inverse: placeId }, { id: 'asc' })).map((tag) => tag.id);
}
async function withQueryCount<T>(fn: () => Promise<T>): Promise<{ value: T; queries: number }> {
const connection = t.orm.em.getConnection();
const spy = vi.spyOn(connection, 'execute');
@@ -109,6 +117,7 @@ describe('TagsRepository', () => {
const { user } = createUser(testDb);
const created = createTag(testDb, user.id, { name: 'Stale', color: '#111111' });
expect((await tags.findByIdAndUser(created.id, user.id))?.color).toBe('#111111'); // populate the identity map
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE tags SET color = ? WHERE id = ?').run('#222222', created.id);
const { value, queries } = await withQueryCount(() => tags.findByIdAndUser(created.id, user.id));
expect(value?.color).toBe('#222222');
@@ -201,6 +210,7 @@ describe('TagsRepository', () => {
const { user } = createUser(testDb);
const created = createTag(testDb, user.id, { name: 'OLD', color: '#aaaaaa' });
await tags.listByUser(user.id); // populate the identity map with name: 'OLD'
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('NEW', created.id);
const updated = await tags.patch(created.id, { color: '#bbbbbb' });
expect(updated?.name).toBe('NEW');
@@ -217,6 +227,7 @@ describe('TagsRepository', () => {
const { user } = createUser(testDb);
const created = createTag(testDb, user.id, { name: 'RaceColor', color: '#111111' });
await tags.findByIdAndUser(created.id, user.id); // populate the identity map with '#111111'
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE tags SET color = ? WHERE id = ?').run('#333333', created.id);
const updated = await tags.patch(created.id, { color: '#111111' });
expect(updated?.color).toBe('#111111');
@@ -262,8 +273,8 @@ describe('TagsRepository', () => {
// Plan 3c Task 1 (QH1): the batch tag-by-place loader behind
// `QueryHelpersService.loadTagsByPlaceIds`, moved off `query-helpers.service.ts`.
describe('listForPlaces', () => {
function attach(tagId: number, placeId: number): void {
testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId);
function attach(tagId: number, placeId: number): Promise<void> {
return tagPlace(t, placeId, [tagId]);
}
it('LISTFORPLACESREPO-001: an empty placeIds array short-circuits to [] without querying', async () => {
@@ -277,7 +288,7 @@ describe('TagsRepository', () => {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' });
attach(tag.id, place.id);
await attach(tag.id, place.id);
const rows = await tags.listForPlaces([place.id]);
expect(rows).toEqual([
@@ -290,7 +301,7 @@ describe('TagsRepository', () => {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const tag = createTag(testDb, user.id, { name: 'Compact', color: '#00ff00' });
attach(tag.id, place.id);
await attach(tag.id, place.id);
const rows = await tags.listForPlaces([place.id], { compact: true });
expect(rows).toEqual([{ id: tag.id, name: 'Compact', color: '#00ff00', created_at: (rawTag(tag.id) as { created_at: string }).created_at, place_id: place.id }]);
@@ -303,8 +314,8 @@ describe('TagsRepository', () => {
const placeA = createPlace(testDb, trip.id, { name: 'A' });
const placeB = createPlace(testDb, trip.id, { name: 'B' });
const tag = createTag(testDb, user.id);
attach(tag.id, placeA.id);
attach(tag.id, placeB.id);
await attach(tag.id, placeA.id);
await attach(tag.id, placeB.id);
const rows = await tags.listForPlaces([placeA.id, placeB.id]);
expect(rows.map((r) => r.place_id).sort()).toEqual([placeA.id, placeB.id].sort());
@@ -332,7 +343,8 @@ describe('TagsRepository', () => {
// (`name`) — `find({})` with the base default merged in populates
// nothing.
await t.repo(Tags).find({}, { disableIdentityMap: false });
attach(tag.id, place.id);
await attach(tag.id, place.id);
// test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included.
testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('Renamed', tag.id);
const rows = await tags.listForPlaces([place.id]);
expect(rows[0]?.name).toBe('Renamed');
@@ -368,8 +380,8 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_
await tags.insertIgnore(place.id, [tagA.id, tagB.id]);
// Re-run with an overlapping id — the pre-existing pair must not error or duplicate.
await tags.insertIgnore(place.id, [tagA.id]);
const rows = testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ? ORDER BY tag_id').all(place.id) as { tag_id: number }[];
expect(rows.map((r) => r.tag_id).sort((a, b) => a - b)).toEqual([tagA.id, tagB.id].sort((a, b) => a - b));
const rows = await placeTagIds(place.id);
expect([...rows].sort((a, b) => a - b)).toEqual([tagA.id, tagB.id].sort((a, b) => a - b));
});
it('PLACETAGSREPO-002: insertIgnore with an empty array is a no-op', async () => {
@@ -377,7 +389,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
await tags.insertIgnore(place.id, []);
const rows = testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').all(place.id);
const rows = await placeTagIds(place.id);
expect(rows).toEqual([]);
});
@@ -390,8 +402,8 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_
await tags.insertIgnore(place.id, [tag.id]);
await tags.insertIgnore(other.id, [tag.id]);
await tags.deleteForPlace(place.id);
expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]);
expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(other.id)).toHaveLength(1);
expect(await placeTagIds(place.id)).toEqual([]);
expect(await placeTagIds(other.id)).toHaveLength(1);
});
// Task 9 fix wave (B-M5, the OAUTHTOKREPO-012/REANCHORDAY-003 rollback
@@ -416,7 +428,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]);
expect(await placeTagIds(place.id)).toEqual([]);
});
it('PLACETAGSREPO-007: deleteForPlace inside a uow.transactional that then ROLLS BACK leaves the pairs in place', async () => {
@@ -437,7 +449,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toHaveLength(1);
expect(await placeTagIds(place.id)).toHaveLength(1);
});
});
@@ -492,6 +504,6 @@ describe('TagsRepository.listPlaceTagsForTrip (TP46)', () => {
expect((caught as Error).message).toBe('force rollback');
expect(await tags.listPlaceTagsForTrip(trip.id)).toEqual([]);
expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]);
expect(await placeTagIds(place.id)).toEqual([]);
});
});
@@ -5,6 +5,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, type TestUser } from '../../../helpers/factories';
import { UserNoticeDismissals } from '../../../../src/db/entities/UserNoticeDismissals.entity';
import type { UserNoticeDismissalsRepository } from '../../../../src/db/repositories/UserNoticeDismissals.repository';
import { countRows, findRow, insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -27,12 +28,19 @@ afterAll(async () => {
testDb.close();
});
function insertRaw(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string | null): void {
testDb.prepare('INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)').run(userId, noticeId, dismissedAt, dismissedAppVersion);
async function insertDismissal(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string | null): Promise<void> {
await insertRow(t, UserNoticeDismissals, { user: userId, notice_id: noticeId, dismissed_at: dismissedAt, dismissed_app_version: dismissedAppVersion });
}
/** The dismissal's stored columns, or undefined when there is none. */
async function storedDismissal(userId: number, noticeId: string) {
const row = await findRow(t, UserNoticeDismissals, { user: userId, notice_id: noticeId });
return row ? { dismissed_at: row.dismissed_at, dismissed_app_version: row.dismissed_app_version } : undefined;
}
describe('user_notice_dismissals — the (user_id, notice_id) composite PK the ON CONFLICT target relies on (Plan 3f Task 0 R5/§14, Task 6)', () => {
it("UNDREPO-SCHEMA-001: PRAGMA table_info('user_notice_dismissals') reports a 2-column PRIMARY KEY over exactly (user_id, notice_id), matching the migration DDL", () => {
// test-sql-allow: the primary key comes from PRAGMA table_info, which no entity or repository maps.
const cols = testDb.prepare("PRAGMA table_info('user_notice_dismissals')").all() as { name: string; pk: number }[];
const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
expect(pkCols).toEqual(['user_id', 'notice_id']);
@@ -46,11 +54,12 @@ describe('user_notice_dismissals — the (user_id, notice_id) composite PK the O
describe('UserNoticeDismissalsRepository — reads (SN4 parity)', () => {
it('UNDREPO-001 — listForUser matches `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?`, fully seeded across several notices/versions, and never leaks another user', async () => {
insertRaw(user.id, 'welcome-v1', 1_700_000_000_000, null);
insertRaw(user.id, 'release-notes', 1_700_000_001_000, '4.2.1');
insertRaw(user.id, 'v3014-whitespace-collision', 1_700_000_002_000, '3.0.14');
insertRaw(otherUser.id, 'welcome-v1', 1_700_000_003_000, '4.3.0'); // a different user's row — must not leak in
await insertDismissal(user.id, 'welcome-v1', 1_700_000_000_000, null);
await insertDismissal(user.id, 'release-notes', 1_700_000_001_000, '4.2.1');
await insertDismissal(user.id, 'v3014-whitespace-collision', 1_700_000_002_000, '3.0.14');
await insertDismissal(otherUser.id, 'welcome-v1', 1_700_000_003_000, '4.3.0'); // a different user's row — must not leak in
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = (testDb.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?').all(user.id) as { notice_id: string; dismissed_app_version: string | null }[])
.sort((a, b) => a.notice_id.localeCompare(b.notice_id));
const converted = (await repo.listForUser(user.id))
@@ -77,7 +86,7 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => {
try {
// Insert branch: no existing row.
await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_000_000, '4.2.1');
expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
expect(await storedDismissal(user.id, 'release-notes'))
.toEqual({ dismissed_at: 1_700_000_000_000, dismissed_app_version: '4.2.1' });
const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
@@ -88,9 +97,9 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => {
// the existing row in place rather than inserting a second one (the composite PK
// is what makes this an UPDATE, not an INSERT, on the second call).
await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_050_000, '4.3.0');
expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
expect(await storedDismissal(user.id, 'release-notes'))
.toEqual({ dismissed_at: 1_700_000_050_000, dismissed_app_version: '4.3.0' });
expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes') as { c: number }).c).toBe(1);
expect(await countRows(t, UserNoticeDismissals, { user: user.id, notice_id: 'release-notes' })).toBe(1);
const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql));
expect(mergeSql).toContain('on conflict (`user_id`, `notice_id`)');
@@ -105,10 +114,8 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => {
await repo.upsertDismissal(user.id, 'welcome-v1', 1_700_000_001_000, '4.0.0');
await repo.upsertDismissal(otherUser.id, 'release-notes', 1_700_000_002_000, '4.1.0');
expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals').get() as { c: number }).c).toBe(3);
expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes'))
.toEqual({ dismissed_app_version: '4.2.1' });
expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(otherUser.id, 'release-notes'))
.toEqual({ dismissed_app_version: '4.1.0' });
expect(await countRows(t, UserNoticeDismissals)).toBe(3);
expect((await storedDismissal(user.id, 'release-notes'))?.dismissed_app_version).toBe('4.2.1');
expect((await storedDismissal(otherUser.id, 'release-notes'))?.dismissed_app_version).toBe('4.1.0');
});
});
@@ -11,6 +11,9 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { VacayPlanMembers } from '../../../../src/db/entities/VacayPlanMembers.entity';
import type { VacayPlanMembersRepository } from '../../../../src/db/repositories/VacayPlanMembers.repository';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
import { makeVacayPlan } from '../../../helpers/factories/vacay';
import { Users } from '../../../../src/db/entities/Users.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -23,31 +26,32 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function insertPlan(ownerId: number): number {
return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid);
async function insertPlan(ownerId: number): Promise<number> {
return (await makeVacayPlan(t, ownerId)).id;
}
function insertMember(planId: number, userId: number, status: string): number {
return Number(testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, userId, status).lastInsertRowid);
function insertMember(planId: number, userId: number, status: string): Promise<number> {
return insertRow(t, VacayPlanMembers, { plan: planId, user: userId, status });
}
describe('VacayPlanMembersRepository.listAvailableForFusion (VC75, getAvailableUsers — the fusion-candidate picker)', () => {
it('VACAYMEMREPO-001: matches the legacy three-NOT-IN statement — excludes self, guests, existing plan members, any already-fused user, and the owner of an already-fused plan', async () => {
const { user: me } = createUser(testDb);
const planId = insertPlan(me.id);
const planId = await insertPlan(me.id);
const { user: candidate } = createUser(testDb, { username: 'candidate' });
const { user: existingMember } = createUser(testDb, { username: 'existingmember' });
insertMember(planId, existingMember.id, 'pending');
await insertMember(planId, existingMember.id, 'pending');
const { user: alreadyFusedElsewhere } = createUser(testDb, { username: 'fusedelsewhere' });
const otherPlan = insertPlan(createUser(testDb, { username: 'otherowner' }).user.id);
insertMember(otherPlan, alreadyFusedElsewhere.id, 'accepted');
const otherPlan = await insertPlan(createUser(testDb, { username: 'otherowner' }).user.id);
await insertMember(otherPlan, alreadyFusedElsewhere.id, 'accepted');
const { user: fusedPlanOwner } = createUser(testDb, { username: 'fusedplanowner' });
const fusedPlan = insertPlan(fusedPlanOwner.id);
const fusedPlan = await insertPlan(fusedPlanOwner.id);
const { user: theirFusedMember } = createUser(testDb, { username: 'theirfusedmember' });
insertMember(fusedPlan, theirFusedMember.id, 'accepted');
await insertMember(fusedPlan, theirFusedMember.id, 'accepted');
const { user: guest } = createUser(testDb, { username: 'guestuser' });
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
await updateRows(t, Users, { id: guest.id }, { is_guest: 1 });
// test-sql-allow: the legacy VC75 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ? AND COALESCE(u.is_guest, 0) = 0
@@ -70,12 +74,13 @@ describe('VacayPlanMembersRepository.listAvailableForFusion (VC75, getAvailableU
describe('VacayPlanMembersRepository.listPendingForPlan / listPendingForUser (VC129/130, getPlanData\'s share picker)', () => {
it('VACAYMEMREPO-002: listPendingForPlan matches the legacy JOIN — outgoing invites the plan owner sent', async () => {
const { user: owner } = createUser(testDb);
const planId = insertPlan(owner.id);
const planId = await insertPlan(owner.id);
const { user: invitee } = createUser(testDb, { username: 'invitee' });
insertMember(planId, invitee.id, 'pending');
await insertMember(planId, invitee.id, 'pending');
const { user: accepted } = createUser(testDb, { username: 'accepted' });
insertMember(planId, accepted.id, 'accepted');
await insertMember(planId, accepted.id, 'accepted');
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT m.id, m.user_id, u.username, u.email, m.created_at
FROM vacay_plan_members m JOIN users u ON m.user_id = u.id
@@ -88,10 +93,11 @@ describe('VacayPlanMembersRepository.listPendingForPlan / listPendingForUser (VC
it('VACAYMEMREPO-003: listPendingForUser matches the legacy JOIN — the OWNER\'s username/email, not the invited member\'s', async () => {
const { user: owner } = createUser(testDb, { username: 'planowner' });
const planId = insertPlan(owner.id);
const planId = await insertPlan(owner.id);
const { user: invitee } = createUser(testDb, { username: 'invitee' });
insertMember(planId, invitee.id, 'pending');
await insertMember(planId, invitee.id, 'pending');
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(`
SELECT m.id, m.plan_id, u.username, u.email, m.created_at
FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id JOIN users u ON p.owner_id = u.id
@@ -10,12 +10,20 @@ import { resetTestDb } from '../../../helpers/test-db';
import { createUser } from '../../../helpers/factories';
import { createTestVacaySharesRepo } from '../../../helpers/vacay-repos';
import type { VacaySharesRepository } from '../../../../src/db/repositories/VacayShares.repository';
import { sharedTestOrm } from '../../../helpers/test-uow';
import type { TestOrm } from '../../../helpers/test-orm';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
import { addVacayPlanMember, makeVacayPlan } from '../../../helpers/factories/vacay';
import { Users } from '../../../../src/db/entities/Users.entity';
import { VacayShares } from '../../../../src/db/entities/VacayShares.entity';
const testDb = createSnapshotTestDb();
let repo: VacaySharesRepository;
let orm: TestOrm;
beforeAll(async () => {
repo = await createTestVacaySharesRepo(testDb);
orm = await sharedTestOrm(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
@@ -25,10 +33,8 @@ afterAll(() => testDb.close());
const LEGACY_LIST_OUTGOING = `SELECT s.id, s.user_id, u.username FROM vacay_shares s JOIN users u ON s.user_id = u.id WHERE s.owner_id = ? ORDER BY s.id`;
const LEGACY_LIST_INCOMING = `SELECT s.id, s.owner_id, s.hidden, u.username FROM vacay_shares s JOIN users u ON s.owner_id = u.id WHERE s.user_id = ? ORDER BY s.id`;
function insertShare(ownerId: number, userId: number, hidden = 0): number {
return Number(
testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id, hidden) VALUES (?, ?, ?)').run(ownerId, userId, hidden).lastInsertRowid,
);
function insertShare(ownerId: number, userId: number, hidden = 0): Promise<number> {
return insertRow(orm, VacayShares, { owner: ownerId, user: userId, hidden });
}
describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements', () => {
@@ -37,10 +43,11 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements'
const { user: recipient1 } = createUser(testDb, { username: 'recipient-1' });
const { user: recipient2 } = createUser(testDb, { username: 'recipient-2' });
const { user: ownerB } = createUser(testDb, { username: 'owner-b' });
insertShare(ownerA.id, recipient2.id);
insertShare(ownerA.id, recipient1.id);
insertShare(ownerB.id, recipient1.id); // a different owner — must not appear for ownerA
await insertShare(ownerA.id, recipient2.id);
await insertShare(ownerA.id, recipient1.id);
await insertShare(ownerB.id, recipient1.id); // a different owner — must not appear for ownerA
// test-sql-allow: the legacy VC81 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_OUTGOING).all(ownerA.id);
const rows = await repo.listOutgoing(ownerA.id);
@@ -50,6 +57,7 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements'
it('VC81 listOutgoing: an owner with no outgoing shares returns [], matching the legacy statement', async () => {
const { user: owner } = createUser(testDb);
// test-sql-allow: the legacy VC81 statement is the parity oracle the repository is compared against.
expect(await repo.listOutgoing(owner.id)).toEqual(testDb.prepare(LEGACY_LIST_OUTGOING).all(owner.id));
});
@@ -58,10 +66,11 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements'
const { user: ownerA } = createUser(testDb, { username: 'owner-a' });
const { user: ownerB } = createUser(testDb, { username: 'owner-b' });
const { user: elsewhere } = createUser(testDb, { username: 'elsewhere' });
insertShare(ownerB.id, recipient.id, 1); // hidden
insertShare(ownerA.id, recipient.id, 0);
insertShare(ownerA.id, elsewhere.id, 0); // a different recipient — must not appear for `recipient`
await insertShare(ownerB.id, recipient.id, 1); // hidden
await insertShare(ownerA.id, recipient.id, 0);
await insertShare(ownerA.id, elsewhere.id, 0); // a different recipient — must not appear for `recipient`
// test-sql-allow: the legacy VC82 statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_INCOMING).all(recipient.id);
const rows = await repo.listIncoming(recipient.id);
@@ -74,6 +83,7 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements'
it('VC82/VC91 listIncoming: a recipient with no incoming shares returns [], matching the legacy statement', async () => {
const { user: recipient } = createUser(testDb);
// test-sql-allow: the legacy VC82 statement is the parity oracle the repository is compared against.
expect(await repo.listIncoming(recipient.id)).toEqual(testDb.prepare(LEGACY_LIST_INCOMING).all(recipient.id));
});
});
@@ -96,13 +106,14 @@ describe('VacaySharesRepository — VC90 listAvailableForShare parity with the l
const { user: guest } = createUser(testDb, { username: 'a-guest' });
const { user: sharedByOther } = createUser(testDb, { username: 'shared-by-someone-else' });
createUser(testDb, { username: 'bystander' });
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
const planId = Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(planOwner.id).lastInsertRowid);
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'accepted')").run(planId, accepted.id);
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')").run(planId, pending.id);
insertShare(caller.id, alreadyShared.id);
insertShare(planOwner.id, sharedByOther.id); // another owner's share — must not exclude
await updateRows(orm, Users, { id: guest.id }, { is_guest: 1 });
const { id: planId } = await makeVacayPlan(orm, planOwner.id);
await addVacayPlanMember(orm, planId, accepted.id, 'accepted');
await addVacayPlanMember(orm, planId, pending.id, 'pending');
await insertShare(caller.id, alreadyShared.id);
await insertShare(planOwner.id, sharedByOther.id); // another owner's share — must not exclude
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_AVAILABLE).all(caller.id, caller.id, planId, planId);
const rows = await repo.listAvailableForShare(caller.id, planId);
@@ -113,6 +124,7 @@ describe('VacaySharesRepository — VC90 listAvailableForShare parity with the l
it('VC90 listAvailableForShare: a plan id with no plan excludes only the caller, guests and shares, matching the legacy statement', async () => {
const { user: caller } = createUser(testDb, { username: 'caller' });
createUser(testDb, { username: 'someone' });
// test-sql-allow: the legacy statement is the parity oracle the repository is compared against.
const legacy = testDb.prepare(LEGACY_LIST_AVAILABLE).all(caller.id, caller.id, 999999, 999999);
const rows = await repo.listAvailableForShare(caller.id, 999999);
expect(rows).toEqual(legacy);
@@ -10,6 +10,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { VacayUserSettings } from '../../../../src/db/entities/VacayUserSettings.entity';
import type { VacayUserSettingsRepository } from '../../../../src/db/repositories/VacayUserSettings.repository';
import { insertRow } from '../../../helpers/factories/rows';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -25,17 +26,18 @@ afterAll(async () => { await t.close(); testDb.close(); });
describe('VacayUserSettingsRepository.findForUser (VC3, getUserYearSettings)', () => {
it('VACAYUSETREPO-001: matches SELECT * FROM vacay_user_settings WHERE user_id = ? run raw, hire_date both NULL and SET', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?)')
.run(user.id, 'fiscal', 4, 1, '2020-06-15');
await insertRow(t, VacayUserSettings, { user: user.id, year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: '2020-06-15' });
// test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id);
expect(await repo.findForUser(user.id)).toEqual(legacy);
});
it('VACAYUSETREPO-002: hire_date NULL matches the legacy row too', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO vacay_user_settings (user_id) VALUES (?)').run(user.id);
await insertRow(t, VacayUserSettings, { user: user.id });
// test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id);
expect(await repo.findForUser(user.id)).toEqual(legacy);
});
@@ -10,6 +10,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { VacayUserYears } from '../../../../src/db/entities/VacayUserYears.entity';
import type { VacayUserYearsRepository } from '../../../../src/db/repositories/VacayUserYears.repository';
import { insertRow } from '../../../helpers/factories/rows';
import { makeVacayPlan } from '../../../helpers/factories/vacay';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -22,27 +24,27 @@ beforeAll(async () => {
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function insertPlan(ownerId: number): number {
return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid);
async function insertPlan(ownerId: number): Promise<number> {
return (await makeVacayPlan(t, ownerId)).id;
}
describe('VacayUserYearsRepository.findForYear (VC34/98/109/123, getStats)', () => {
it('VACAYUYEARREPO-001: matches the legacy row with vacation_days SET (30)', async () => {
const { user } = createUser(testDb);
const planId = insertPlan(user.id);
testDb.prepare('INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)')
.run(user.id, planId, 2026, 25, 3);
const planId = await insertPlan(user.id);
await insertRow(t, VacayUserYears, { user: user.id, plan: planId, year: 2026, vacation_days: 25, carried_over: 3 });
// test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(user.id, planId, 2026);
expect(await repo.findForYear(user.id, planId, 2026)).toEqual(legacy);
});
it('VACAYUYEARREPO-002: matches the legacy row with vacation_days explicitly NULL — the review\'s named gap', async () => {
const { user } = createUser(testDb);
const planId = insertPlan(user.id);
testDb.prepare('INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)')
.run(user.id, planId, 2027, null, null);
const planId = await insertPlan(user.id);
await insertRow(t, VacayUserYears, { user: user.id, plan: planId, year: 2027, vacation_days: null, carried_over: null });
// test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against.
const legacy = testDb.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(user.id, planId, 2027);
const row = await repo.findForYear(user.id, planId, 2027);
expect(row).toEqual(legacy);
@@ -52,7 +54,7 @@ describe('VacayUserYearsRepository.findForYear (VC34/98/109/123, getStats)', ()
it('VACAYUYEARREPO-003: null for a year with no row', async () => {
const { user } = createUser(testDb);
const planId = insertPlan(user.id);
const planId = await insertPlan(user.id);
expect(await repo.findForYear(user.id, planId, 2099)).toBeNull();
});
});
@@ -6,6 +6,7 @@ import { createUser } from '../../../../helpers/factories';
import { Collections } from '../../../../../src/db/entities/Collections.entity';
import { CollectionMembers } from '../../../../../src/db/entities/CollectionMembers.entity';
import { resolveCollectionRole, type CollectionRole } from '../../../../../src/db/repositories/_shared/collection-role';
import { addCollectionMember, makeCollection } from '../../../../helpers/factories/collections';
/**
* Plan 3h Task 1, R6 — the collections cluster's shared collection-role
@@ -48,7 +49,7 @@ afterAll(async () => { await t.close(); testDb.close(); });
type Actor = 'owner' | 'admin' | 'editor' | 'viewer' | 'pending' | 'stranger';
function seedFixture() {
async function seedFixture() {
const { user: owner } = createUser(testDb);
const { user: admin } = createUser(testDb);
const { user: editor } = createUser(testDb);
@@ -56,16 +57,14 @@ function seedFixture() {
const { user: pending } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const collectionId = testDb
.prepare("INSERT INTO collections (owner_id, name) VALUES (?, 'Fixture')")
.run(owner.id).lastInsertRowid as number;
const { id: collectionId } = await makeCollection(t, owner.id, { name: 'Fixture' });
const addMember = (userId: number, role: string, status: string) =>
testDb.prepare('INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, ?, ?)').run(collectionId, userId, status, role);
addMember(admin.id, 'admin', 'accepted');
addMember(editor.id, 'editor', 'accepted');
addMember(viewer.id, 'viewer', 'accepted');
addMember(pending.id, 'editor', 'pending');
addCollectionMember(t, collectionId, userId, { status, role });
await addMember(admin.id, 'admin', 'accepted');
await addMember(editor.id, 'editor', 'accepted');
await addMember(viewer.id, 'viewer', 'accepted');
await addMember(pending.id, 'editor', 'pending');
return {
collectionId: collectionId as number,
@@ -104,7 +103,7 @@ const EXPECTED: Record<Actor, { role: CollectionRole; access: 'ok' | 404; edit:
describe('_shared/collection-role — R6 parity harness (owner/admin/editor/viewer/pending/stranger)', () => {
(Object.keys(EXPECTED) as Actor[]).forEach((actor) => {
it(`resolves the ${actor} case to role=${String(EXPECTED[actor].role)} and the matching refusal shapes`, async () => {
const { collectionId, userIds } = seedFixture();
const { collectionId, userIds } = await seedFixture();
const collections = t.repo(Collections);
const members = t.repo(CollectionMembers);
@@ -119,7 +118,7 @@ describe('_shared/collection-role — R6 parity harness (owner/admin/editor/view
});
it('a stranger and a pending invite are indistinguishable to the predicate (both resolve to null, no access)', async () => {
const { collectionId, userIds } = seedFixture();
const { collectionId, userIds } = await seedFixture();
const collections = t.repo(Collections);
const members = t.repo(CollectionMembers);
@@ -39,6 +39,7 @@ import { Users } from '../../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { readUser } from '../../../helpers/factories/users';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -56,7 +57,7 @@ afterAll(async () => { await t.close(); testDb.close(); });
describe('identity-map write-back (task-1-review.md B1)', () => {
it('IMWB-001: findByIdWithPasswordVersion then getApiKeyColumns then patchProfile — the profile write survives the request', async () => {
const { user } = createUser(testDb, { username: 'before', email: 'before@example.test' });
const originalPasswordVersion = (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }).password_version;
const originalPasswordVersion = (await readUser(t, user.id)).password_version;
await withRequestContext(t.orm, async () => {
// Projection A — the JwtAuthGuard/GlobalAuthGuard shape.
@@ -71,9 +72,7 @@ describe('identity-map write-back (task-1-review.md B1)', () => {
});
});
const row = testDb
.prepare('SELECT username, email, password_version FROM users WHERE id = ?')
.get(user.id) as { username: string; email: string; password_version: number };
const row = await readUser(t, user.id);
expect(row.username).toBe('newname'); // the nativeUpdate must stick
expect(row.email).toBe('before@example.test'); // untouched by either read
expect(row.password_version).toBe(originalPasswordVersion); // untouched by either read
@@ -90,9 +89,7 @@ describe('identity-map write-back (task-1-review.md B1)', () => {
});
});
const row = testDb
.prepare('SELECT password_hash, password_version, username FROM users WHERE id = ?')
.get(user.id) as { password_hash: string; password_version: number; username: string };
const row = await readUser(t, user.id);
expect(row.password_hash).toBe('NEWHASH');
expect(row.password_version).toBe(5); // the session-invalidation gate — must NOT revert to the pre-change value
expect(row.username).toBe('someone'); // untouched by either read
@@ -18,11 +18,20 @@ import {
createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo,
} from '../../../helpers/vacay-repos';
import { sharedTestOrm } from '../../../helpers/test-uow';
import { insertRow, updateRows } from '../../../helpers/factories/rows';
import { addVacayPlanMember, makeVacayPlan } from '../../../helpers/factories/vacay';
import { VacayCompanyHolidays } from '../../../../src/db/entities/VacayCompanyHolidays.entity';
import { VacayHolidayCalendars } from '../../../../src/db/entities/VacayHolidayCalendars.entity';
import { VacayPlanMembers } from '../../../../src/db/entities/VacayPlanMembers.entity';
import { VacayPlans } from '../../../../src/db/entities/VacayPlans.entity';
import { VacayUserColors } from '../../../../src/db/entities/VacayUserColors.entity';
const testDb = createSnapshotTestDb();
const orm = () => sharedTestOrm(testDb);
function makePlan(ownerId: number): number {
return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid);
async function makePlan(ownerId: number): Promise<number> {
return (await makeVacayPlan(await orm(), ownerId)).id;
}
beforeEach(() => resetTestDb(testDb));
@@ -32,8 +41,8 @@ describe('VacayCompanyHolidaysRepository — note ?? null (VC29/VC67/VC112)', ()
it('listForPlan/listForRange return note: null for a row whose note column is genuinely NULL', async () => {
const repo = await createTestVacayCompanyHolidaysRepo(testDb);
const { user } = createUser(testDb);
const planId = makePlan(user.id);
testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, NULL)').run(planId, '2026-12-25');
const planId = await makePlan(user.id);
await insertRow(await orm(), VacayCompanyHolidays, { plan: planId, date: '2026-12-25', note: null });
expect(await repo.listForPlan(planId)).toEqual([{ date: '2026-12-25', note: null, fraction: 1 }]);
expect(await repo.listForRange(planId, '2026-12-01', '2027-01-01')).toMatchObject([{ date: '2026-12-25', note: null }]);
@@ -45,9 +54,9 @@ describe('VacayUserColorsRepository — color ?? null (VC79/VC57.../VC59)', () =
const repo = await createTestVacayUserColorsRepo(testDb);
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const planId = makePlan(u1.id);
testDb.prepare('INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, NULL)').run(u1.id, planId);
testDb.prepare('INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(u2.id, planId, '#111111');
const planId = await makePlan(u1.id);
await insertRow(await orm(), VacayUserColors, { user: u1.id, plan: planId, color: null });
await insertRow(await orm(), VacayUserColors, { user: u2.id, plan: planId, color: '#111111' });
expect(await repo.listForPlan(planId)).toEqual(expect.arrayContaining([{ color: null }, { color: '#111111' }]));
expect(await repo.findColor(u1.id, planId)).toEqual({ color: null });
@@ -65,13 +74,11 @@ describe('VacayHolidayCalendarsRepository — findById/findScopedForPlan not-fou
it('findScopedForPlan returns the row for the correct plan, null under a DIFFERENT plan', async () => {
const repo = await createTestVacayHolidayCalendarsRepo(testDb);
const { user } = createUser(testDb);
const planId = makePlan(user.id);
const otherPlanId = makePlan(createUser(testDb).user.id);
const id = Number(
testDb
.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region, color, sort_order) VALUES (?, 'public_holiday', 'US', '#fecaca', 0)")
.run(planId).lastInsertRowid,
);
const planId = await makePlan(user.id);
const otherPlanId = await makePlan(createUser(testDb).user.id);
const id = await insertRow(await orm(), VacayHolidayCalendars, {
plan: planId, type: 'public_holiday', region: 'US', color: '#fecaca', sort_order: 0,
});
expect((await repo.findScopedForPlan(id, planId))?.id).toBe(id);
expect(await repo.findScopedForPlan(id, otherPlanId)).toBeNull();
expect((await repo.findById(id))?.id).toBe(id);
@@ -83,15 +90,15 @@ describe('VacayPlanMembersRepository — findMembership/findAcceptedForUser (VC4
const repo = await createTestVacayPlanMembersRepo(testDb);
const { user: owner } = createUser(testDb);
const { user: target } = createUser(testDb);
const planId = makePlan(owner.id);
const planId = await makePlan(owner.id);
expect(await repo.findMembership(planId, target.id)).toBeNull();
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')").run(planId, target.id);
await addVacayPlanMember(await orm(), planId, target.id, 'pending');
const pending = await repo.findMembership(planId, target.id);
expect(pending).toMatchObject({ status: 'pending' });
testDb.prepare('UPDATE vacay_plan_members SET status = NULL WHERE plan_id = ? AND user_id = ?').run(planId, target.id);
await updateRows(await orm(), VacayPlanMembers, { plan: planId, user: target.id }, { status: null });
expect(await repo.findMembership(planId, target.id)).toEqual({ id: pending!.id, status: null });
});
@@ -99,13 +106,11 @@ describe('VacayPlanMembersRepository — findMembership/findAcceptedForUser (VC4
const repo = await createTestVacayPlanMembersRepo(testDb);
const { user: owner } = createUser(testDb);
const { user: target } = createUser(testDb);
const planId = makePlan(owner.id);
const planId = await makePlan(owner.id);
expect(await repo.findAcceptedForUser(target.id)).toBeNull();
const id = Number(
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'accepted')").run(planId, target.id).lastInsertRowid,
);
const { id } = await addVacayPlanMember(await orm(), planId, target.id, 'accepted');
expect(await repo.findAcceptedForUser(target.id)).toEqual({ id });
});
});
@@ -114,7 +119,7 @@ describe('VacayPlansRepository — findOwnerId/getHolidaysEnabled/findVacayUser
it('findOwnerId returns null for a plan id that does not exist, the owner id for one that does', async () => {
const repo = await createTestVacayPlansRepo(testDb);
const { user } = createUser(testDb);
const planId = makePlan(user.id);
const planId = await makePlan(user.id);
expect(await repo.findOwnerId(999999)).toBeNull();
expect(await repo.findOwnerId(planId)).toEqual({ owner_id: user.id });
});
@@ -122,13 +127,13 @@ describe('VacayPlansRepository — findOwnerId/getHolidaysEnabled/findVacayUser
it('getHolidaysEnabled returns null for a missing plan, null for a genuinely NULL column, and the value when set', async () => {
const repo = await createTestVacayPlansRepo(testDb);
const { user } = createUser(testDb);
const planId = makePlan(user.id);
const planId = await makePlan(user.id);
expect(await repo.getHolidaysEnabled(999999)).toBeNull();
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = NULL WHERE id = ?').run(planId);
await updateRows(await orm(), VacayPlans, { id: planId }, { holidays_enabled: null });
expect(await repo.getHolidaysEnabled(planId)).toBeNull();
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(planId);
await updateRows(await orm(), VacayPlans, { id: planId }, { holidays_enabled: 1 });
expect(await repo.getHolidaysEnabled(planId)).toBe(1);
});