test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)

Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.
This commit is contained in:
jubnl
2026-09-24 07:00:30 +02:00
parent f0816e557e
commit 235f036657
3 changed files with 267 additions and 0 deletions
@@ -795,3 +795,75 @@ describe('DayAssignmentsRepository.setAccommodation (TP57)', () => {
expect((testDb.prepare('SELECT accommodation_id FROM day_assignments WHERE id = ?').get(other.id) as { accommodation_id: number | null }).accommodation_id).toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plan 4 Task 8b-2, item 1 (3d Task 7 review's "DayAssignments.listRoadtripVisits"
// carry): RPL2 (`roadtrip-plan.service.ts::context`'s `visits` read) had no
// repository-level toEqual(<legacy raw>) test — a correlated scalar subquery
// inside a LEFT JOIN ON clause, exercised here with every nullable column
// both null (no stay, no leg transport, coalesce falls back to the place)
// and set (a matching stay + its checkout day, explicit assignment overrides).
// ─────────────────────────────────────────────────────────────────────────────
const LEGACY_LIST_ROADTRIP_VISITS = `
SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
COALESCE(a.assignment_time, p.place_time) AS time,
COALESCE(a.assignment_end_time, p.end_time) AS end_time,
p.duration_minutes, a.end_day,
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent,
stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
FROM day_assignments a
JOIN days d ON d.id = a.day_id
JOIN places p ON p.id = a.place_id
LEFT JOIN day_accommodations stay
ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
WHERE d.trip_id = ?
ORDER BY d.day_number, a.order_index, a.created_at
`;
describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.service.ts::context)', () => {
it('ASSIGNREPO-031: matches the legacy statement — no stay, coalesce falls back to the place, leg transport NULL', async () => {
const { user } = createUser(testDb);
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);
createDayAssignment(testDb, day.id, place.id);
const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id);
const typed = await assignments.listRoadtripVisits(trip.id);
expect(typed).toEqual(legacy);
expect(typed[0]).toMatchObject({ time: '09:00', end_time: '11:00', stay_id: null, check_in: null, check_out: null, checkout_day: null, leg_transport_mode: null });
});
it('ASSIGNREPO-032: matches the legacy statement — a matching stay populates stay/checkout columns, explicit overrides win over coalesce', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
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);
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);
const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id);
const typed = await assignments.listRoadtripVisits(trip.id);
expect(typed).toEqual(legacy);
expect(typed[0]).toMatchObject({
time: '14:00', end_time: '14:30', stay_id: stay.id, check_in: '15:00', check_out: '11:00',
checkout_day: 2, leg_transport_mode: 'driving', incoming_leg_transport_mode: 'walking', end_day: 1,
stop_type: 'lodging', fill_percent: 80, duration_minutes: 45,
});
});
it('ASSIGNREPO-033: empty array for a trip with no assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await assignments.listRoadtripVisits(trip.id)).toEqual([]);
});
});
@@ -352,4 +352,76 @@ describe('DaysRepository.listForPublicApi (Plan 4 Task 1, public-api.service.ts:
const trip = createTrip(testDb, user.id);
expect(await days.listForPublicApi(trip.id)).toEqual([]);
});
it('DAYREPO-029 (rule 19): title/notes SET (not just the default NULL) still matches the legacy row, key for key', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2026-06-03', title: 'Departure' });
testDb.prepare('UPDATE days SET notes = ? WHERE id = ?').run('Pack early', day.id);
const legacy = testDb.prepare('SELECT id, day_number, date, title, notes FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id);
const rows = await days.listForPublicApi(trip.id);
expect(rows).toEqual(legacy);
expect(rows).toEqual([{ id: day.id, day_number: day.day_number, date: '2026-06-03', title: 'Departure', notes: 'Pack early' }]);
});
});
describe('DaysRepository.listPlanDays (Plan 4 Task 8b-2, RPL1 — roadtrip-plan.service.ts::context)', () => {
it('DAYREPO-030: id/day_number/date/title/default_transport_mode, ordered by day_number, both NULL and SET nullable columns', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const day2 = createDay(testDb, trip.id, { day_number: 2, date: '2026-06-02', title: 'Day two' });
testDb.prepare("UPDATE days SET default_transport_mode = ? WHERE id = ?").run('cycling', day2.id);
const day1 = createDay(testDb, trip.id, { day_number: 1 }); // date/title/default_transport_mode all NULL
createDay(testDb, other.id, { day_number: 1 });
const legacy = testDb
.prepare('SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number')
.all(trip.id);
const rows = await days.listPlanDays(trip.id);
expect(rows).toEqual(legacy);
expect(rows).toEqual([
{ id: day1.id, day_number: 1, date: null, title: null, default_transport_mode: null },
{ id: day2.id, day_number: 2, date: '2026-06-02', title: 'Day two', default_transport_mode: 'cycling' },
]);
});
it('DAYREPO-031: empty array for a trip with no days', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await days.listPlanDays(trip.id)).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plan 4 Task 8b-2, item 1 (3d Task 7 review's "Days.listPlanDays" carry):
// RPL1 (`roadtrip-plan.service.ts::context`) had no repository-level
// toEqual(<legacy raw>) test.
// ─────────────────────────────────────────────────────────────────────────────
describe('DaysRepository.listPlanDays (RPL1, roadtrip-plan.service.ts::context)', () => {
it('DAYREPO-029: id/day_number/date/title/default_transport_mode, ordered by day_number, default_transport_mode both NULL and set', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const day2 = createDay(testDb, trip.id, { day_number: 2, date: '2026-06-02', title: 'Day 2' });
const day1 = createDay(testDb, trip.id, { day_number: 1, date: '2026-06-01', title: null });
testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('walking', day2.id);
createDay(testDb, other.id, { day_number: 1 });
const legacy = testDb.prepare('SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id);
const rows = await days.listPlanDays(trip.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id)).toEqual([day1.id, day2.id]);
expect(rows[0].default_transport_mode).toBeNull();
expect(rows[1].default_transport_mode).toBe('walking');
});
it('DAYREPO-030: empty array for a trip with no days', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await days.listPlanDays(trip.id)).toEqual([]);
});
});
@@ -1255,4 +1255,127 @@ describe('PlacesRepository.listAssignedForPublicApi (Plan 4 Task 1, public-api.s
const trip = createTrip(testDb, user.id);
expect(await places.listAssignedForPublicApi(trip.id)).toEqual([]);
});
it('PLACEREPO-032 (rule 19): matches the legacy JOIN run raw, every nullable projected column both NULL and SET', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const category = createCategory(testDb, { name: 'Landmark' });
const day = createDay(testDb, trip.id);
const sparse = createPlace(testDb, trip.id, { name: 'Sparse' });
testDb.prepare(
'UPDATE places SET address = NULL, lat = NULL, lng = NULL, place_time = NULL, end_time = NULL, duration_minutes = NULL, notes = NULL, transport_mode = NULL, category_id = NULL WHERE id = ?',
).run(sparse.id);
const full = createPlace(testDb, trip.id, { name: 'Full', lat: 10.5, lng: 20.5, category_id: category.id });
testDb.prepare(
"UPDATE places SET address = ?, place_time = ?, end_time = ?, duration_minutes = ?, notes = ?, transport_mode = ? WHERE id = ?",
).run('123 Main St', '09:00', '10:00', 60, 'Bring a jacket', 'walking', full.id);
createDayAssignment(testDb, day.id, sparse.id, { order_index: 0 });
createDayAssignment(testDb, day.id, full.id, { order_index: 1 });
const legacy = testDb.prepare(`
SELECT da.day_id, p.name, p.address, p.lat, p.lng, p.place_time, p.end_time, p.duration_minutes, p.notes, p.transport_mode, c.name as category
FROM day_assignments da JOIN places p ON p.id = da.place_id LEFT JOIN categories c ON c.id = p.category_id
WHERE p.trip_id = ? AND da.accommodation_id IS NULL
ORDER BY da.day_id ASC, da.order_index ASC`).all(trip.id);
const rows = await places.listAssignedForPublicApi(trip.id);
expect(rows).toEqual(legacy);
expect(rows).toEqual([
{ day_id: day.id, name: 'Sparse', address: null, lat: null, lng: null, place_time: null, end_time: null, duration_minutes: null, notes: null, transport_mode: null, category: null },
{ day_id: day.id, name: 'Full', address: '123 Main St', lat: 10.5, lng: 20.5, place_time: '09:00', end_time: '10:00', duration_minutes: 60, notes: 'Bring a jacket', transport_mode: 'walking', category: 'Landmark' },
]);
});
});
describe('PlacesRepository.isTrackInTrip (Plan 4 Task 8b-2, RT13 — RoadtripService.trackExists)', () => {
it('PLACEREPO-033: true only when route_geometry is set AND non-empty AND the place belongs to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const withTrack = createPlace(testDb, trip.id, { name: 'Has track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('{"type":"LineString"}', withTrack.id);
const nullGeometry = createPlace(testDb, trip.id, { name: 'Null geometry' });
const emptyGeometry = createPlace(testDb, trip.id, { name: 'Empty geometry' });
testDb.prepare("UPDATE places SET route_geometry = '' WHERE id = ?").run(emptyGeometry.id);
const foreignTrack = createPlace(testDb, other.id, { name: 'Foreign track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('{"type":"LineString"}', foreignTrack.id);
const legacy = (id: number) =>
testDb.prepare("SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''").get(id, trip.id);
expect(await places.isTrackInTrip(withTrack.id, trip.id)).toBe(true);
expect(!!legacy(withTrack.id)).toBe(true);
expect(await places.isTrackInTrip(nullGeometry.id, trip.id)).toBe(false);
expect(!!legacy(nullGeometry.id)).toBe(false);
expect(await places.isTrackInTrip(emptyGeometry.id, trip.id)).toBe(false);
expect(!!legacy(emptyGeometry.id)).toBe(false);
// Belongs to a different trip — the trip_id predicate excludes it even though its geometry is set.
expect(await places.isTrackInTrip(foreignTrack.id, trip.id)).toBe(false);
expect(!!legacy(foreignTrack.id)).toBe(false);
});
it('PLACEREPO-034: an unknown place id is false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await places.isTrackInTrip(999999, trip.id)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plan 4 Task 8b-2, item 1 (3d Task 7 review's "Places.findTrackInTrip" carry,
// the actual method is `isTrackInTrip`): RT13 (`RoadtripService.trackExists`)
// had no repository-level parity test. `SELECT id FROM places WHERE id = ?
// AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''`,
// both nullable/empty-string edges pinned against the legacy predicate.
// ─────────────────────────────────────────────────────────────────────────────
describe('PlacesRepository.isTrackInTrip (RT13, RoadtripService.trackExists)', () => {
const legacyIsTrackInTrip = (id: number, tripId: number): boolean =>
!!testDb.prepare(
"SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''",
).get(id, tripId);
it('PLACEREPO-032: a place with route_geometry set matches the legacy predicate — true', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('{"type":"LineString"}', place.id);
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(legacyIsTrackInTrip(place.id, trip.id));
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(true);
});
it('PLACEREPO-033: route_geometry NULL matches the legacy predicate — false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET route_geometry = NULL WHERE id = ?').run(place.id);
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(legacyIsTrackInTrip(place.id, trip.id));
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(false);
});
it('PLACEREPO-034: route_geometry \'\' (empty string) matches the legacy predicate — false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
testDb.prepare("UPDATE places SET route_geometry = '' WHERE id = ?").run(place.id);
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(legacyIsTrackInTrip(place.id, trip.id));
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(false);
});
it('PLACEREPO-035: a track on a DIFFERENT trip is not found — false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const place = createPlace(testDb, other.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('{"type":"LineString"}', place.id);
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(legacyIsTrackInTrip(place.id, trip.id));
expect(await places.isTrackInTrip(place.id, trip.id)).toBe(false);
});
});