mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(server): the booked night settles in one pass, and the sibling paths seat it too
The audit over the 4.3.1 release found the seating rule reading a night by one value and its neighbours by another, so two nights on one day never agreed: the migration reported moves that changed nothing, flipped the pair on the next run and dropped the road drawn between them, and in the service every notes edit swapped them. One rule now lives in accommodations/night-seat.ts, measures every night by its own check-in, breaks a tie by the booking made first, and carries the vias; the service, the reseat migration and a trip date change all seat through it, the last of which used to append the night at the end of its day again. An account created by a first SSO login writes the user.register row the password path writes. Tests cover the equal and absent check-ins, the date change, the booking form path and a day that is nothing but its night.
This commit is contained in:
@@ -229,8 +229,14 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined;
|
||||
expect(newUser).toBeDefined();
|
||||
// Registered, the way the audit log reports a password signup, and then logged in:
|
||||
// an admin reading user.register for who got an account sees the SSO ones too.
|
||||
const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as
|
||||
{ action: string; details: string | null }[];
|
||||
expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']);
|
||||
expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' });
|
||||
});
|
||||
|
||||
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* A night's stop used to go last on its check-in day unless a stop pinned to a later
|
||||
* hour pulled it forward, and the backfill for older bookings appended it as well. The
|
||||
* night leads its day now (AccommodationsService.positionForCheckIn), and this step
|
||||
* brings the trips planned under the old rule along, drawn roads included.
|
||||
* night leads its day now (night-seat.ts, the rule AccommodationsService applies), and
|
||||
* this step brings the trips planned under the old rule along, drawn roads included.
|
||||
*
|
||||
* The rules live in reseat-booked-nights.ts and are tested there directly on a fully
|
||||
* migrated database: the columns the step reads (assignment_time, accommodation_id)
|
||||
@@ -189,6 +189,108 @@ describe('booked night reseat', () => {
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-011: two nights with the same check-in, booked in that order, stay put with their roads', () => {
|
||||
// Each used to count the other as "at or before" its check-in, so the step reported
|
||||
// two moves it never made and the reanchor dropped the road between them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, first, 0, null, night(db, first, '15:00'));
|
||||
stop(db, second, 1, null, night(db, second, '15:00'));
|
||||
const between = via(db, 0);
|
||||
const intoTomorrow = via(db, 1);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([
|
||||
{ id: between, after_order_index: 0, sequence: 0 },
|
||||
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
|
||||
]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-012: two nights with the same check-in settle by their booking, and only once', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, first, second] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, fuel, 0);
|
||||
stop(db, second, 1, null, secondNight);
|
||||
stop(db, first, 2, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(2);
|
||||
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
expect(indexes(db)).toEqual([0, 1, 2]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-013: two nights without a check-in settle by their booking too', () => {
|
||||
// Both used to want the front, and every pass swapped them.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-014: a night without a check-in leads one with a clock', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, clocked, open] = [place(db, 'Aral'), place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
stop(db, fuel, 0);
|
||||
stop(db, clocked, 1, null, night(db, clocked, '15:00'));
|
||||
stop(db, open, 2, null, night(db, open, null));
|
||||
|
||||
reseatBookedNights(db);
|
||||
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([open, clocked, fuel]);
|
||||
});
|
||||
|
||||
it('RESEAT-015: an hour pinned on a night own stop is not what the other nights read it by', () => {
|
||||
// The stop carries eight, the booking carries three, and a night seats itself by
|
||||
// its check-in. Read the pinned hour off the row instead and the two of them
|
||||
// answer the same question differently: each counts the other as ahead, the step
|
||||
// reports two moves on every run for ever, and the swap in between drops the road.
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, '15:00');
|
||||
const secondNight = night(db, second, '15:00');
|
||||
stop(db, first, 0, null, firstNight);
|
||||
stop(db, second, 1, '08:00', secondNight);
|
||||
const between = via(db, 0);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(vias(db)).toEqual([{ id: between, after_order_index: 0, sequence: 0 }]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
});
|
||||
|
||||
it('RESEAT-016: a night without a check-in leads on its booking, whatever hour its place carries', () => {
|
||||
const db = freshDb();
|
||||
const [first, second] = [place(db, 'Adlon', '09:00'), place(db, 'Mercure')];
|
||||
const firstNight = night(db, first, null);
|
||||
const secondNight = night(db, second, null);
|
||||
stop(db, second, 0, null, secondNight);
|
||||
stop(db, first, 1, null, firstNight);
|
||||
|
||||
expect(reseatBookedNights(db)).toBe(1);
|
||||
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
expect(reseatBookedNights(db)).toBe(0);
|
||||
expect(order(db)).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it('RESEAT-008: runs again without moving anything', () => {
|
||||
const db = freshDb();
|
||||
const [fuel, hotel] = [place(db, 'Aral'), place(db, 'Rostock')];
|
||||
|
||||
@@ -789,6 +789,73 @@ describe('the day stop a booking implies', () => {
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022j two nights with the same check-in settle by their booking and stay settled through a change of notes', () => {
|
||||
// Each used to read the other as "at or before" its own check-in, so a notes edit on
|
||||
// the front one moved it behind the other, took the road between them with it, and
|
||||
// a notes edit on the other swapped them back.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const a = svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
const between = Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 52.5, 13.4)')
|
||||
.run(day.id).lastInsertRowid);
|
||||
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(quiet.mirror.vias).toBeUndefined();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(between)).toEqual({ after_order_index: 0 });
|
||||
|
||||
// Dragged the other way round on purpose: an equal clock is no contradiction.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = CASE place_id WHEN ? THEN 1 ELSE 0 END WHERE day_id = ?').run(first.id, day.id);
|
||||
for (const booked of [a, b]) {
|
||||
const existing = svc.getAccommodation(booked.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).toBeNull();
|
||||
}
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([second.id, first.id]);
|
||||
});
|
||||
|
||||
it('ACC-022k two nights without a check-in: the earlier booking leads', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [museum, first, second] = ['Pergamon', 'Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, day.id, museum.id);
|
||||
svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id });
|
||||
const b = svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id }) as any;
|
||||
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
const existing = svc.getAccommodation(b.accommodation.id, trip.id)!;
|
||||
expect((svc.updateAccommodation(b.accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
});
|
||||
|
||||
it('ACC-022l a night on a day dragged out of clock order is settled where a fresh seat puts it', () => {
|
||||
// Pinned two, then pinned eight, on purpose. A check-in at ten goes behind the eight,
|
||||
// and nothing else satisfies both clocks; a change of notes used to relocate it to
|
||||
// the very same seat and report a move for it.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [late, early, hotel] = ['Museum', 'Aral', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, late.id).id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, early.id).id);
|
||||
const { accommodation } = svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any;
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
|
||||
const existing = svc.getAccommodation(accommodation.id, trip.id)!;
|
||||
const quiet = svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any;
|
||||
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
});
|
||||
|
||||
it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -604,6 +604,91 @@ describe('resyncAccommodationDays', () => {
|
||||
expect(testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(res))
|
||||
.toMatchObject({ day_id: null, reservation_time: null });
|
||||
});
|
||||
|
||||
// The stop a booking wrote goes along when the stay is carried to another day row.
|
||||
const stopsOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as
|
||||
{ place_id: number; order_index: number; accommodation_id: number | null }[];
|
||||
const addVia = (dayId: number, afterOrderIndex: number) =>
|
||||
Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, 0, 48.1, 11.5)')
|
||||
.run(dayId, afterOrderIndex).lastInsertRowid);
|
||||
const viasOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as
|
||||
{ id: number; after_order_index: number }[];
|
||||
|
||||
it('DAY-SVC-055: the carried stop is seated by its check-in on its new day, and both days keep their roads in place', () => {
|
||||
// The trip gained a day at the front: d1 now holds the day before, d2 the stay's
|
||||
// date. The night used to be appended behind d2's unpinned stops, the very order
|
||||
// a night booked today no longer gets, and neither day's roads were re-pinned.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const [hotel, a, b, c, d] = ['Rostock', 'Aral', 'Hafen', 'Museum', 'Markt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
createDayAssignment(testDb, d1.id, b.id);
|
||||
createDayAssignment(testDb, d2.id, c.id);
|
||||
createDayAssignment(testDb, d2.id, d.id);
|
||||
expect(stopsOn(d1.id).map(s => s.place_id)).toEqual([hotel.id, a.id, b.id]);
|
||||
const outOfHotel = addVia(d1.id, 0);
|
||||
const outOfA = addVia(d1.id, 1);
|
||||
const outOfC = addVia(d2.id, 0);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d2.id)).toEqual([
|
||||
{ place_id: hotel.id, order_index: 0, accommodation_id: accommodation.id },
|
||||
{ place_id: c.id, order_index: 1, accommodation_id: null },
|
||||
{ place_id: d.id, order_index: 2, accommodation_id: null },
|
||||
]);
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[a.id, 0], [b.id, 1]]);
|
||||
// The hotel's road on d1 had no stop ahead to fall back on and goes; A's follows A
|
||||
// to leg zero. On d2 the road out of C is one number further on, behind C.
|
||||
expect(viasOn(d1.id)).toEqual([{ id: outOfA, after_order_index: 0 }]);
|
||||
expect(viasOn(d1.id).map(v => v.id)).not.toContain(outOfHotel);
|
||||
expect(viasOn(d2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-056: a day that already holds the place by hand keeps that stop, and the booking rides along with it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Rostock' });
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d1.id }) as any;
|
||||
const own = createDayAssignment(testDb, d2.id, hotel.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-04-30', d1.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-01', d2.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toMatchObject({ start_day_id: d2.id });
|
||||
expect(stopsOn(d1.id)).toEqual([]);
|
||||
expect(stopsOn(d2.id)).toEqual([{ place_id: hotel.id, order_index: own.order_index, accommodation_id: null }]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-057: a stay whose end alone moves leaves its stop where it is', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = createDay(testDb, trip.id, { date: '2026-05-01' });
|
||||
const d2 = createDay(testDb, trip.id, { date: '2026-05-02' });
|
||||
const d3 = createDay(testDb, trip.id, { date: '2026-05-03' });
|
||||
const [hotel, a] = ['Rostock', 'Aral'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const { accommodation } = accommodations.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: d1.id, end_day_id: d2.id, check_in: '10:00' }) as any;
|
||||
createDayAssignment(testDb, d1.id, a.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-03', d2.id);
|
||||
testDb.prepare('UPDATE days SET date = ? WHERE id = ?').run('2026-05-02', d3.id);
|
||||
|
||||
svc.resyncAccommodationDays(trip.id, new Map([[d1.id, '2026-05-01'], [d2.id, '2026-05-02'], [d3.id, '2026-05-03']]));
|
||||
|
||||
expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(accommodation.id))
|
||||
.toMatchObject({ start_day_id: d1.id, end_day_id: d3.id });
|
||||
expect(stopsOn(d1.id).map(s => [s.place_id, s.order_index])).toEqual([[hotel.id, 0], [a.id, 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* MAPS-AUTO-001..008 — the suggestion list behind the place search box.
|
||||
* MAPS-AUTO-001..013: the suggestion list behind the place search box.
|
||||
*
|
||||
* This is the path the index was built for. Nominatim's usage policy names
|
||||
* autocomplete as unacceptable use in its own words, whatever the rate, and the
|
||||
@@ -73,19 +73,32 @@ const osmHit = (over: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* `enabled` drives the admin kill switch, which is read straight off
|
||||
* app_settings; an unset row reads as on, because the switch is fail-open.
|
||||
* `enabled` drives the index switch, an environment variable; `rows` are the
|
||||
* app_settings the instance holds, by key: a Google key, the provider choice,
|
||||
* the Google-only switch. Anything else reads as absent.
|
||||
*
|
||||
* Keyed on the statement rather than answering everything the same way: the
|
||||
* same `get` also resolves the Google key, and a blanket answer would hand
|
||||
* `'false'` to the key resolver and send the fallback at Google for real.
|
||||
*/
|
||||
function make(enabled = true) {
|
||||
function make(enabled = true, rows: Record<string, string> = {}) {
|
||||
trekPlaces.on = enabled;
|
||||
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
|
||||
const database = {
|
||||
get: vi.fn((sql: string, key?: unknown) =>
|
||||
typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined,
|
||||
),
|
||||
} as unknown as DatabaseService;
|
||||
return new MapsService(database, {} as PlacePhotoCacheService);
|
||||
}
|
||||
|
||||
/** Google's autocomplete envelope, which is not the shape its text search answers in. */
|
||||
const googleSuggestions = (name: string) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
suggestions: [{ placePrediction: { placeId: 'g1', structuredFormat: { mainText: { text: name }, secondaryText: { text: 'Chiyoda' } } } }],
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearch.mockReset();
|
||||
mockSearch.mockResolvedValue([]);
|
||||
@@ -229,4 +242,43 @@ describe('MapsService.autocompletePlaces', () => {
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).not.toBe('trek-places');
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-012: the Google-only switch sends the keystroke to Google and skips the index', async () => {
|
||||
// The admin row promises every search AND every suggestion. The search half
|
||||
// is pinned next door (MAPS-SEARCH-011); this is the other call site.
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
expect(result.source).toBe('google');
|
||||
expect(result.suggestions[0]).toMatchObject({ placeId: 'g1', mainText: 'Tokyo Station' });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('places:autocomplete');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('MAPS-AUTO-013: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
|
||||
mockSearch.mockResolvedValue([hit()]);
|
||||
|
||||
// No key at all: the index answers, as before.
|
||||
const keyless = await make(true, { places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(keyless.source).toBe('trek-places');
|
||||
|
||||
// A key, but the admin picked OpenStreetMap: Google holds no slot, so
|
||||
// there is nothing for the switch to hand the keystroke to.
|
||||
mockSearch.mockClear();
|
||||
const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' })
|
||||
.autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(osmOnly.source).toBe('trek-places');
|
||||
|
||||
// A key and the switch off: the index still answers first.
|
||||
mockSearch.mockClear();
|
||||
const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).autocompletePlaces(1, INPUT);
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(off.source).toBe('trek-places');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,6 +221,25 @@ describe('OidcController /callback', () => {
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-003: an account the callback creates writes the user.register row a password signup writes, then the login', async () => {
|
||||
const res = makeRes();
|
||||
const reqIp = { query: {}, headers: {}, ip: '203.0.113.9', cookies: { trek_oidc_state: 's' } } as unknown as Request;
|
||||
await ctl(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 7, username: 'a', email: 'a@b.c', role: 'user' }, created: true }),
|
||||
})).callback('c', 's', undefined, reqIp, res);
|
||||
|
||||
expect(writeAudit).toHaveBeenCalledTimes(2);
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(1, {
|
||||
userId: 7, action: 'user.register', ip: '203.0.113.9',
|
||||
details: { username: 'a', email: 'a@b.c', role: 'user', method: 'oidc' },
|
||||
});
|
||||
expect(writeAudit).toHaveBeenNthCalledWith(2, { userId: 7, action: 'user.login', ip: '203.0.113.9', details: { method: 'oidc' } });
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('OIDC-CB-LOGIN-002: a login refused by the user lookup writes no login row', async () => {
|
||||
const res = makeRes();
|
||||
await ctl(svc({
|
||||
|
||||
@@ -1309,6 +1309,40 @@ describe('the day stop a hotel booking implies', () => {
|
||||
expect(stopsOn(days[0].id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('RESV-STAY-008: a save from the booking form re-seats the night only when its check-in changed', () => {
|
||||
// The form sends the whole stay on every save, check-in included, so this is the
|
||||
// door a title edit on a hotel booking comes through. Its own reading of "the
|
||||
// check-in changed" (the prior row, read before the write) decides whether a night
|
||||
// the traveller dragged is left alone or seated afresh, the way ACC-022g pins it
|
||||
// for the stay route.
|
||||
const { trip, days } = tripWithDays();
|
||||
const [harbour, market, hotel] = ['Hafen', 'Markt', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, days[0].id, harbour.id);
|
||||
createDayAssignment(testDb, days[0].id, market.id);
|
||||
const stay = (check_in: string) => ({ place_id: hotel.id, start_day_id: days[0].id, end_day_id: days[0].id, check_in });
|
||||
const { reservation } = svc.create(String(trip.id), { title: 'Hotel', type: 'hotel', create_accommodation: stay('15:00') });
|
||||
const order = () => stopsOn(days[0].id).map(s => s.place_id);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
|
||||
// Dragged to the end of the day by hand.
|
||||
const own = stopsOn(days[0].id).find(s => s.place_id === hotel.id)!;
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > 0').run(days[0].id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
let current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { title: 'Hotel, late arrival', type: 'hotel', create_accommodation: stay('15:00') } as never, current);
|
||||
expect(order()).toEqual([harbour.id, market.id, hotel.id]);
|
||||
expect(broadcast).not.toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.anything());
|
||||
|
||||
current = svc.getReservation(String(reservation.id), String(trip.id))!;
|
||||
svc.update(String(reservation.id), String(trip.id), { type: 'hotel', create_accommodation: stay('10:00') } as never, current);
|
||||
expect(order()).toEqual([hotel.id, harbour.id, market.id]);
|
||||
expect(stopsOn(days[0].id).map(s => s.id)).toContain(own.id);
|
||||
expect(broadcast).toHaveBeenCalledWith(String(trip.id), 'assignment:moved', expect.objectContaining({ assignment: expect.objectContaining({ id: own.id }) }));
|
||||
});
|
||||
|
||||
it('RESV-STAY-007: a foreign accommodation_id reaches no stop on the other trip', () => {
|
||||
// The trip_id guard on the stay delete is what denies that reach; the stops go
|
||||
// by accommodation id alone and would otherwise follow it straight over.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -117,6 +117,46 @@ describe('a visit end time on the road trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booked night on the road trip (#2410)', () => {
|
||||
// The night flag comes off the planning SELECT (stay.id), not off a row the tests
|
||||
// hand the service, so a column dropped from the statement would flip every stop
|
||||
// to a night here and nowhere else.
|
||||
it('marks the stop the night is booked at, and a day that is only its night stands among the days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const arrival = createDay(db, trip.id);
|
||||
const hotel = createPlace(db, trip.id, { name: 'Rostock', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, arrival.id, hotel.id);
|
||||
createDayAccommodation(db, trip.id, hotel.id, arrival.id, arrival.id, { check_in: '15:00' });
|
||||
|
||||
const { context, calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(context.visits.map((v) => [v.name, v.stay_id !== null])).toEqual([
|
||||
['Hamburg', false],
|
||||
['Lueneburg', false],
|
||||
['Celle', false],
|
||||
['Rostock', true],
|
||||
]);
|
||||
expect(calculated.quietDays).toEqual([]);
|
||||
expect(calculated.days.map((d) => d.dayId)).toContain(arrival.id);
|
||||
const night = calculated.days.find((d) => d.dayId === arrival.id)!;
|
||||
expect(night.stops.map((s) => [s.name, s.night])).toEqual([['Rostock', true]]);
|
||||
expect(night.legs).toEqual([]);
|
||||
expect(night.schedule.entries[0]).toMatchObject({ arrival: '15:00', anchored: true });
|
||||
});
|
||||
|
||||
it('files a lone stop that is no night under the quiet days', async () => {
|
||||
const { user, trip, plans } = setup();
|
||||
const quiet = createDay(db, trip.id);
|
||||
const museum = createPlace(db, trip.id, { name: 'Museum', lat: 54.09, lng: 12.1 });
|
||||
createDayAssignment(db, quiet.id, museum.id);
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(calculated.days.map((d) => d.dayId)).not.toContain(quiet.id);
|
||||
expect(calculated.quietDays.map((d) => d.dayId)).toEqual([quiet.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booking the traveller rides (#2428)', () => {
|
||||
function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
|
||||
const result = db
|
||||
|
||||
@@ -302,6 +302,7 @@ describe('Roadtrip MCP registration and search', () => {
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [],
|
||||
geometry: [
|
||||
[48, 10],
|
||||
[48, 10.1],
|
||||
@@ -388,7 +389,7 @@ describe('MCP trip preferences authorization', () => {
|
||||
describe('corridor filtering', () => {
|
||||
const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 };
|
||||
function tool() {
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, stops: [], geometry: [[48, 10], [48, 10.1]] }] } };
|
||||
const maps = { search: vi.fn(async () => ({ pois: [
|
||||
{ osm_id: 'a', name: 'Fast', lat: 48, lng: 10.03, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 150 }] } },
|
||||
{ osm_id: 'b', name: 'Slow', lat: 48, lng: 10.07, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 11 }] } },
|
||||
@@ -421,6 +422,44 @@ describe('corridor filtering', () => {
|
||||
expect((await read({ toKm: 0 })).hits).toHaveLength(0);
|
||||
expect((await read({})).failedSources).toEqual(['plugin']);
|
||||
});
|
||||
it('leaves the ride between two terminals out of the search and drops a hit under the flight path (#2428)', async () => {
|
||||
// The day drives to the airport, flies two degrees north and east, and drives on
|
||||
// from the far one: the geometry jumps straight between the terminals, and the
|
||||
// browser hook already cuts that stretch out. The tool tiles the same pieces.
|
||||
const terminal = (role: 'departure' | 'arrival', lat: number, lng: number) => ({
|
||||
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
||||
lat,
|
||||
lng,
|
||||
});
|
||||
const plan = {
|
||||
failures: [],
|
||||
omittedVisits: [],
|
||||
calculated: {
|
||||
dayWindowIssue: null,
|
||||
days: [
|
||||
{
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 48, lng: 10 }, terminal('departure', 48, 10.1), terminal('arrival', 50, 12), { lat: 50, lng: 12.1 }],
|
||||
geometry: [[48, 10], [48, 10.1], [50, 12], [50, 12.1]],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const underTheFlight = { lat: 49, lng: 11 };
|
||||
type Box = { south: number; north: number; west: number; east: number };
|
||||
const maps = { search: vi.fn(async (_query: { bbox: Box }) => ({ pois: [
|
||||
{ osm_id: 'road', name: 'On the road', lat: 48, lng: 10.05, category: 'fuel' },
|
||||
{ osm_id: 'field', name: 'Under the flight', ...underTheFlight, category: 'fuel' },
|
||||
], sources: ['osm'], failedSources: [], truncated: false, clamped: false })) };
|
||||
const mcp = new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never);
|
||||
const body = JSON.parse((await mcp.corridor({ ...input, category: 'fuel' }, ctx)).content[0].text as string);
|
||||
const asked = maps.search.mock.calls.map(([query]) => query.bbox);
|
||||
expect(asked.length).toBeGreaterThan(0);
|
||||
expect(asked.some((b) => b.south <= underTheFlight.lat && b.north >= underTheFlight.lat && b.west <= underTheFlight.lng && b.east >= underTheFlight.lng)).toBe(false);
|
||||
expect(body.hits.map((h: { poi: { osm_id: string } }) => h.poi.osm_id)).toEqual(['road']);
|
||||
expect(body.totalAreas).toBe(asked.length);
|
||||
expect(body.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
it('reads the saved roadtrip context without routing', async () => {
|
||||
const s = setup();
|
||||
|
||||
Reference in New Issue
Block a user