diff --git a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts index 16384ae24..1c1d5cbc8 100644 --- a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts @@ -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() 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([]); + }); +}); diff --git a/server/tests/unit/db/repositories/Days.repository.test.ts b/server/tests/unit/db/repositories/Days.repository.test.ts index cd029776d..3e87780ce 100644 --- a/server/tests/unit/db/repositories/Days.repository.test.ts +++ b/server/tests/unit/db/repositories/Days.repository.test.ts @@ -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() 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([]); + }); }); diff --git a/server/tests/unit/db/repositories/Places.repository.test.ts b/server/tests/unit/db/repositories/Places.repository.test.ts index 5bdab996b..81ce8dba5 100644 --- a/server/tests/unit/db/repositories/Places.repository.test.ts +++ b/server/tests/unit/db/repositories/Places.repository.test.ts @@ -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); + }); });