mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the larger service specs through the repositories
The settings, notification, road trip plan, collection and accommodation service suites wrote their fixtures and read back their results with raw statements against the shared test handle. They now seed and inspect through the ORM factories and readers on the suite's shared test ORM, with small local helpers where a suite repeats the same read. The collection and accommodation database mocks drop their dead canAccessTrip, isOwner and getPlaceWithTags stubs: db/database exports none of them since the repositories took them over. Raw statements stay, marked, where they are the subject: the legacy read model oracle in the collections suite, the SELECT * that proves a saved place has no itinerary columns, and the member count taken synchronously while the delete broadcast fires.
This commit is contained in:
@@ -13,17 +13,6 @@ vi.mock('../../../src/db/database', async () => {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
// Real enough for the stop-type stamp a stay write makes: it announces the
|
||||
// place it typed, and a null here would hide that the write happened at all.
|
||||
getPlaceWithTags: (id: number | string) => db.prepare('SELECT * FROM places WHERE id = ?').get(id) ?? null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -43,6 +32,49 @@ import { AccommodationsDomainModule } from '../../../src/nest/accommodations/acc
|
||||
import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller';
|
||||
import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo } from '../../helpers/test-uow';
|
||||
import type { FilterQuery } from '@mikro-orm/core';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
|
||||
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
||||
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { Reservations } from '../../../src/db/entities/Reservations.entity';
|
||||
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
|
||||
|
||||
const orm = () => sharedTestOrm(testDb);
|
||||
|
||||
/** The first booking linked to a stay, or null. */
|
||||
async function linkedReservation(accommodationId: number) {
|
||||
return findRow(await orm(), Reservations, { accommodation_id: String(accommodationId) });
|
||||
}
|
||||
|
||||
/** Every booking linked to a stay. */
|
||||
async function linkedReservations(accommodationId: number) {
|
||||
return findRows(await orm(), Reservations, { accommodation_id: String(accommodationId) }, { id: 'asc' });
|
||||
}
|
||||
|
||||
/** How many bookings a stay holds. */
|
||||
async function linkedCount(accommodationId: number): Promise<{ n: number }> {
|
||||
return { n: await countRows(await orm(), Reservations, { accommodation_id: String(accommodationId) }) };
|
||||
}
|
||||
|
||||
/** A second hotel booking on the same stay. */
|
||||
async function addSecondBooking(tripId: number, accommodationId: number): Promise<number> {
|
||||
return insertRow(await orm(), Reservations, { trip: tripId, type: 'hotel', title: 'Second booking', accommodation_id: String(accommodationId) });
|
||||
}
|
||||
|
||||
/** An expense, optionally on a booking. */
|
||||
async function addExpense(tripId: number, name: string, category: string, totalPrice: number, reservationId: number | null): Promise<number> {
|
||||
return insertRow(await orm(), BudgetItems, { trip: tripId, name, category, total_price: totalPrice, reservation: reservationId });
|
||||
}
|
||||
|
||||
/** Sets columns on day assignments. */
|
||||
async function setStops(where: FilterQuery<DayAssignments>, data: Partial<Pick<DayAssignments, 'assignment_time' | 'order_index' | 'notes' | 'end_day'>>) {
|
||||
await updateRows(await orm(), DayAssignments, where, data);
|
||||
}
|
||||
|
||||
// Named `svc` so the moved cases read exactly as they did on DaysService.
|
||||
let svc: Awaited<ReturnType<typeof makeAccommodationsService>>;
|
||||
@@ -133,8 +165,8 @@ describe('createAccommodation', () => {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
|
||||
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
const reservation = await linkedReservation(accom.id);
|
||||
expect(reservation).not.toBeNull();
|
||||
expect(reservation.type).toBe('hotel');
|
||||
expect(reservation.status).toBe('confirmed');
|
||||
});
|
||||
@@ -152,7 +184,7 @@ describe('createAccommodation', () => {
|
||||
})) as any;
|
||||
|
||||
expect(accom.place_name).toBeNull();
|
||||
const reservation = testDb.prepare('SELECT title FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
expect(reservation.title).toBe('Hotel');
|
||||
});
|
||||
});
|
||||
@@ -208,8 +240,8 @@ describe('updateAccommodation', () => {
|
||||
expect(updated).toBeDefined();
|
||||
|
||||
// Verify linked reservation metadata was synced
|
||||
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
const reservation = await linkedReservation(accom.id);
|
||||
expect(reservation).not.toBeNull();
|
||||
const meta = JSON.parse(reservation.metadata || '{}');
|
||||
expect(meta.check_in_time).toBe('16:00');
|
||||
expect(meta.check_out_time).toBe('12:00');
|
||||
@@ -246,7 +278,7 @@ describe('updateAccommodation', () => {
|
||||
const { accommodation: updated } = (await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' })) as any;
|
||||
|
||||
expect(updated.check_in).toBe('15:00');
|
||||
expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 });
|
||||
expect(await linkedCount(accom.id)).toMatchObject({ n: 0 });
|
||||
});
|
||||
|
||||
it('ACC-007 — merges into the reservation metadata instead of replacing it', async () => {
|
||||
@@ -264,7 +296,7 @@ describe('updateAccommodation', () => {
|
||||
const existing = (await svc.getAccommodation(accom.id, trip.id))!;
|
||||
await svc.updateAccommodation(accom.id, existing as any, { check_in_end: '20:00', check_out: '11:00' });
|
||||
|
||||
const reservation = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
expect(JSON.parse(reservation.metadata)).toEqual({
|
||||
check_in_time: '15:00', check_in_end_time: '20:00', check_out_time: '11:00',
|
||||
});
|
||||
@@ -280,12 +312,12 @@ describe('updateAccommodation', () => {
|
||||
const { accommodation: accom } = (await svc.createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
testDb.prepare('UPDATE reservations SET confirmation_number = ? WHERE accommodation_id = ?').run('RES-9', accom.id);
|
||||
await updateRows(await orm(), Reservations, { accommodation_id: String(accom.id) }, { confirmation_number: 'RES-9' });
|
||||
|
||||
const existing = (await svc.getAccommodation(accom.id, trip.id))!;
|
||||
await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' });
|
||||
|
||||
const reservation = testDb.prepare('SELECT confirmation_number FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
expect(reservation.confirmation_number).toBe('RES-9');
|
||||
});
|
||||
|
||||
@@ -300,12 +332,12 @@ describe('updateAccommodation', () => {
|
||||
// The linked reservation already carries a DIFFERENT, non-null confirmation —
|
||||
// if the stay-side update used `coalesceParam`'s (existing-wins) direction
|
||||
// instead of `coalesceOverride`'s (new-wins), it would keep this value.
|
||||
testDb.prepare('UPDATE reservations SET confirmation_number = ? WHERE accommodation_id = ?').run('RES-OLD', accom.id);
|
||||
await updateRows(await orm(), Reservations, { accommodation_id: String(accom.id) }, { confirmation_number: 'RES-OLD' });
|
||||
|
||||
const existing = (await svc.getAccommodation(accom.id, trip.id))!;
|
||||
await svc.updateAccommodation(accom.id, existing as any, { confirmation: 'ACC-CHANGED' });
|
||||
|
||||
const reservation = testDb.prepare('SELECT confirmation_number FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
expect(reservation.confirmation_number).toBe('ACC-CHANGED');
|
||||
});
|
||||
});
|
||||
@@ -320,7 +352,7 @@ describe('deleteAccommodation', () => {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
|
||||
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
|
||||
const result = await svc.deleteAccommodation(accom.id);
|
||||
expect(result.linkedReservationId).toBe(reservation.id);
|
||||
@@ -329,8 +361,8 @@ describe('deleteAccommodation', () => {
|
||||
expect(await svc.getAccommodation(accom.id, trip.id)).toBeUndefined();
|
||||
|
||||
// Reservation is gone
|
||||
const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
|
||||
expect(deletedRes).toBeUndefined();
|
||||
const deletedRes = await findRow(await orm(), Reservations, { id: reservation.id });
|
||||
expect(deletedRes).toBeNull();
|
||||
});
|
||||
|
||||
it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', async () => {
|
||||
@@ -341,7 +373,7 @@ describe('deleteAccommodation', () => {
|
||||
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
|
||||
|
||||
// Remove the auto-created reservation so there's no linked one
|
||||
testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id);
|
||||
await deleteRows(await orm(), Reservations, { accommodation_id: String(accom.id) });
|
||||
|
||||
const result = await svc.deleteAccommodation(accom.id);
|
||||
expect(result.linkedReservationId).toBeNull();
|
||||
@@ -359,10 +391,8 @@ describe('deleteAccommodation', () => {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
|
||||
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const budgetItemId = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel stay', 'Accommodation', 240, reservation.id).lastInsertRowid as number;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
const budgetItemId = await addExpense(trip.id, 'Hotel stay', 'Accommodation', 240, reservation.id);
|
||||
|
||||
const result = await svc.deleteAccommodation(accom.id);
|
||||
|
||||
@@ -373,7 +403,7 @@ describe('deleteAccommodation', () => {
|
||||
deletedBudgetItemIds: [budgetItemId],
|
||||
mirror: { created: null, moved: null, updated: [], removed: [{ id: expect.any(Number), dayId: day.id }], stamped: null },
|
||||
});
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(budgetItemId)).toBeUndefined();
|
||||
expect(await findRow(await orm(), BudgetItems, { id: budgetItemId })).toBeNull();
|
||||
});
|
||||
|
||||
it('ACC-035 — every expense on every linked booking goes with the stay (#2084)', async () => {
|
||||
@@ -386,23 +416,19 @@ describe('deleteAccommodation', () => {
|
||||
const { accommodation: accom } = (await svc.createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const second = testDb.prepare(
|
||||
'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)'
|
||||
).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number;
|
||||
const expense = (reservationId: number | null, name: string) => testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, name, 'accommodation', 50, reservationId).lastInsertRowid as number;
|
||||
const room = expense(first.id, 'Room');
|
||||
const breakfast = expense(first.id, 'Breakfast');
|
||||
const parking = expense(second, 'Parking');
|
||||
const unrelated = expense(null, 'Coffee');
|
||||
const first = (await linkedReservation(accom.id))!;
|
||||
const second = await addSecondBooking(trip.id, accom.id);
|
||||
const expense = (reservationId: number | null, name: string) => addExpense(trip.id, name, 'accommodation', 50, reservationId);
|
||||
const room = await expense(first.id, 'Room');
|
||||
const breakfast = await expense(first.id, 'Breakfast');
|
||||
const parking = await expense(second, 'Parking');
|
||||
const unrelated = await expense(null, 'Coffee');
|
||||
|
||||
const result = await svc.deleteAccommodation(accom.id);
|
||||
|
||||
expect(result.deletedBudgetItemIds).toEqual([room, breakfast, parking]);
|
||||
expect(result.deletedBudgetItemId).toBe(room);
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated }]);
|
||||
expect((await findRows(await orm(), BudgetItems, { trip: trip.id }, { id: 'asc' })).map(row => ({ id: row.id }))).toEqual([{ id: unrelated }]);
|
||||
});
|
||||
|
||||
it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', async () => {
|
||||
@@ -417,16 +443,14 @@ describe('deleteAccommodation', () => {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
|
||||
const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const second = testDb.prepare(
|
||||
'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)'
|
||||
).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number;
|
||||
const first = (await linkedReservation(accom.id))!;
|
||||
const second = await addSecondBooking(trip.id, accom.id);
|
||||
|
||||
const result = await svc.deleteAccommodation(accom.id);
|
||||
|
||||
expect(result.linkedReservationIds).toEqual([first.id, second]);
|
||||
expect(result.linkedReservationId).toBe(first.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 });
|
||||
expect(await linkedCount(accom.id)).toMatchObject({ n: 0 });
|
||||
});
|
||||
|
||||
it('ACC-011 — updating the stay syncs the times onto every linked booking', async () => {
|
||||
@@ -437,14 +461,12 @@ describe('deleteAccommodation', () => {
|
||||
const { accommodation: accom } = (await svc.createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
})) as any;
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)'
|
||||
).run(trip.id, 'hotel', 'Second booking', accom.id);
|
||||
await addSecondBooking(trip.id, accom.id);
|
||||
|
||||
const existing = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(accom.id) as any;
|
||||
await svc.updateAccommodation(accom.id, existing, { check_in: '15:00', check_out: '11:00' });
|
||||
const existing = (await findRow(await orm(), DayAccommodations, { id: accom.id }))!;
|
||||
await svc.updateAccommodation(accom.id, existing as Parameters<typeof svc.updateAccommodation>[1], { check_in: '15:00', check_out: '11:00' });
|
||||
|
||||
const rows = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').all(accom.id) as Array<{ metadata: string | null }>;
|
||||
const rows = await linkedReservations(accom.id);
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(JSON.parse(row.metadata || '{}')).toMatchObject({ check_in_time: '15:00', check_out_time: '11:00' });
|
||||
@@ -478,7 +500,7 @@ describe('quirk fixes', () => {
|
||||
try {
|
||||
await expect(svc.deleteAccommodation(accom.id)).rejects.toThrow();
|
||||
// The earlier reservation delete inside the transaction rolled back.
|
||||
expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 });
|
||||
expect(await linkedCount(accom.id)).toMatchObject({ n: 1 });
|
||||
} finally {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
@@ -543,7 +565,7 @@ describe('route-facing delegators', () => {
|
||||
const { accommodation: updated } = (await svc.update(String(accom.id), existing as any, { check_in: '16:00', notes: 'late arrival' })) as any;
|
||||
|
||||
expect(updated).toMatchObject({ check_in: '16:00', notes: 'late arrival' });
|
||||
const reservation = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
expect(JSON.parse(reservation.metadata).check_in_time).toBe('16:00');
|
||||
});
|
||||
|
||||
@@ -553,7 +575,7 @@ describe('route-facing delegators', () => {
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const { accommodation: accom } = (await svc.create(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id })) as any;
|
||||
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
const reservation = (await linkedReservation(accom.id))!;
|
||||
|
||||
// The controller broadcasts reservation:deleted off this return value.
|
||||
expect(await svc.remove(String(accom.id))).toEqual({
|
||||
@@ -622,7 +644,7 @@ describe('trip access and edit permission', () => {
|
||||
} finally {
|
||||
// The permission cache is module-scoped and outlives resetTestDb — drop the row and
|
||||
// the cache together, or every later case in this file inherits the raised level.
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run();
|
||||
await deleteRows(await orm(), AppSettings, { key: 'perm_day_edit' });
|
||||
await permissions.invalidatePermissionsCache();
|
||||
}
|
||||
});
|
||||
@@ -647,9 +669,9 @@ describe('trip access and edit permission', () => {
|
||||
* entered a second time as an ordinary stop.
|
||||
*/
|
||||
describe('the day stop a booking implies', () => {
|
||||
const stopsOn = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as
|
||||
{ id: number; place_id: number; order_index: number; accommodation_id: number | null }[];
|
||||
const stopsOn = async (dayId: number) =>
|
||||
(await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc' }))
|
||||
.map(row => ({ id: row.id, place_id: row.place_id, order_index: row.order_index, accommodation_id: row.accommodation_id ?? null }));
|
||||
|
||||
const book = async (tripId: number, placeId: number | null, startDayId: number, endDayId: number) =>
|
||||
(await svc.createAccommodation(tripId, { place_id: placeId as number, start_day_id: startDayId, end_day_id: endDayId })) as any;
|
||||
@@ -662,7 +684,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
const { accommodation, mirror } = await book(trip.id, place.id, day.id, day.id);
|
||||
|
||||
const stops = stopsOn(day.id);
|
||||
const stops = (await stopsOn(day.id));
|
||||
expect(stops).toHaveLength(1);
|
||||
expect(stops[0]).toMatchObject({ place_id: place.id, accommodation_id: accommodation.id });
|
||||
expect(mirror.created).toMatchObject({ id: stops[0].id, day_id: day.id, place_id: place.id });
|
||||
@@ -686,8 +708,8 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
await book(trip.id, hotel.id, day.id, day.id);
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, museum.id]);
|
||||
expect(stopsOn(day.id).map(a => a.order_index)).toEqual([0, 1]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, museum.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.order_index)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', async () => {
|
||||
@@ -698,11 +720,11 @@ describe('the day stop a booking implies', () => {
|
||||
const afternoon = createPlace(testDb, trip.id, { name: 'Mercure' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' });
|
||||
const pinned = createDayAssignment(testDb, day.id, afternoon.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id);
|
||||
await setStops({ id: pinned.id }, { assignment_time: '16:00' });
|
||||
|
||||
await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' });
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]);
|
||||
});
|
||||
|
||||
it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', async () => {
|
||||
@@ -712,11 +734,11 @@ describe('the day stop a booking implies', () => {
|
||||
const morning = createPlace(testDb, trip.id, { name: 'Museum' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' });
|
||||
const pinned = createDayAssignment(testDb, day.id, morning.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(pinned.id);
|
||||
await setStops({ id: pinned.id }, { assignment_time: '09:00' });
|
||||
|
||||
await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' });
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, hotel.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, hotel.id]);
|
||||
});
|
||||
|
||||
it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', async () => {
|
||||
@@ -734,7 +756,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' });
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]);
|
||||
});
|
||||
|
||||
it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', async () => {
|
||||
@@ -747,12 +769,12 @@ describe('the day stop a booking implies', () => {
|
||||
const first = createDayAssignment(testDb, day.id, early.id);
|
||||
createDayAssignment(testDb, day.id, loose.id);
|
||||
const third = createDayAssignment(testDb, day.id, late.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(first.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(third.id);
|
||||
await setStops({ id: first.id }, { assignment_time: '08:00' });
|
||||
await setStops({ id: third.id }, { assignment_time: '14:00' });
|
||||
|
||||
await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' });
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]);
|
||||
});
|
||||
|
||||
it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', async () => {
|
||||
@@ -763,23 +785,25 @@ describe('the day stop a booking implies', () => {
|
||||
createDayAssignment(testDb, day.id, harbour.id);
|
||||
createDayAssignment(testDb, day.id, market.id);
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id })) as any;
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]);
|
||||
|
||||
// Dragged to the end of the day by hand.
|
||||
const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!;
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?').run(day.id, own.order_index);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]);
|
||||
const own = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!;
|
||||
for (const later of await findRows(await orm(), DayAssignments, { day: day.id, order_index: { $gt: own.order_index } })) {
|
||||
await setStops({ id: later.id }, { order_index: later.order_index - 1 });
|
||||
}
|
||||
await setStops({ id: own.id }, { order_index: 2 });
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]);
|
||||
|
||||
let existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const quiet = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any;
|
||||
expect(quiet.mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]);
|
||||
|
||||
existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const reseated = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' })) as any;
|
||||
expect(reseated.mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: own.id } });
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]);
|
||||
});
|
||||
|
||||
it('ACC-022e moving the check-in later re-seats the night', async () => {
|
||||
@@ -789,16 +813,16 @@ describe('the day stop a booking implies', () => {
|
||||
const afternoon = createPlace(testDb, trip.id, { name: 'Mercure' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' });
|
||||
const pinned = createDayAssignment(testDb, day.id, afternoon.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id);
|
||||
await setStops({ id: pinned.id }, { assignment_time: '16:00' });
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, {
|
||||
place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00',
|
||||
})) as any;
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' });
|
||||
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]);
|
||||
});
|
||||
|
||||
it('ACC-022i a second night on the day counts by its check-in', async () => {
|
||||
@@ -808,11 +832,11 @@ describe('the day stop a booking implies', () => {
|
||||
const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
await svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' });
|
||||
await svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' });
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, noon.id]);
|
||||
|
||||
const evening = createPlace(testDb, trip.id, { name: 'Late' });
|
||||
await svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' });
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]);
|
||||
});
|
||||
|
||||
it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', async () => {
|
||||
@@ -826,25 +850,25 @@ describe('the day stop a booking implies', () => {
|
||||
const [harbour, museum, hotel] = ['Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
createDayAssignment(testDb, day.id, harbour.id);
|
||||
const pinned = createDayAssignment(testDb, day.id, museum.id);
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(pinned.id);
|
||||
await setStops({ id: pinned.id }, { assignment_time: '14:00' });
|
||||
const { accommodation } = (await 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(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!;
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
const own = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!;
|
||||
|
||||
// Behind the harbour only: allowed, and left alone.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, harbour.id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(own.id);
|
||||
await setStops({ day: day.id, place: harbour.id }, { order_index: 0 });
|
||||
await setStops({ id: own.id }, { order_index: 1 });
|
||||
let existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'late' })) as any).mirror.moved).toBeNull();
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]);
|
||||
expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]);
|
||||
|
||||
// Behind the afternoon: contradicted by the clocks, and seated afresh, which is
|
||||
// the front, ahead of the harbour that has no hour of its own.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE day_id = ? AND place_id = ?').run(day.id, museum.id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id);
|
||||
await setStops({ day: day.id, place: museum.id }, { order_index: 1 });
|
||||
await setStops({ id: own.id }, { order_index: 2 });
|
||||
existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'later' })) as any).mirror.moved).not.toBeNull();
|
||||
expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]);
|
||||
expect((await 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', async () => {
|
||||
@@ -857,9 +881,8 @@ describe('the day stop a booking implies', () => {
|
||||
const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
const a = await 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 = await 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);
|
||||
expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
const between = await insertRow(await orm(), RoadtripVias, { day: day.id, after_order_index: 0, sequence: 0, lat: 52.5, lng: 13.4 });
|
||||
|
||||
for (const booked of [a, b]) {
|
||||
const existing = (await svc.getAccommodation(booked.accommodation.id, trip.id))!;
|
||||
@@ -867,16 +890,17 @@ describe('the day stop a booking implies', () => {
|
||||
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 });
|
||||
expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id]);
|
||||
expect({ after_order_index: (await findRow(await orm(), RoadtripVias, { id: between }))!.after_order_index }).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);
|
||||
await setStops({ day: day.id, place: first.id }, { order_index: 1 });
|
||||
await setStops({ day: day.id, place: { $ne: first.id } }, { order_index: 0 });
|
||||
for (const booked of [a, b]) {
|
||||
const existing = (await svc.getAccommodation(booked.accommodation.id, trip.id))!;
|
||||
expect((await 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]);
|
||||
expect((await 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', async () => {
|
||||
@@ -888,10 +912,10 @@ describe('the day stop a booking implies', () => {
|
||||
await svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id });
|
||||
const b = await 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]);
|
||||
expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]);
|
||||
const existing = (await svc.getAccommodation(b.accommodation.id, trip.id))!;
|
||||
expect((await 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]);
|
||||
expect((await 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', async () => {
|
||||
@@ -902,16 +926,16 @@ describe('the day stop a booking implies', () => {
|
||||
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);
|
||||
await setStops({ id: createDayAssignment(testDb, day.id, late.id).id }, { assignment_time: '14:00' });
|
||||
await setStops({ id: createDayAssignment(testDb, day.id, early.id).id }, { assignment_time: '08:00' });
|
||||
const { accommodation } = await 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]);
|
||||
expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const quiet = await 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]);
|
||||
expect((await 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', async () => {
|
||||
@@ -922,7 +946,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
const { mirror } = await book(trip.id, place.id, day.id, day.id);
|
||||
|
||||
expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'hotel' });
|
||||
expect(await findRow(await orm(), Places, { id: place.id })).toMatchObject({ stop_type: 'hotel' });
|
||||
// Announced, or the places list keeps the stale null until a reload.
|
||||
expect(mirror.stamped).toMatchObject({ id: place.id, stop_type: 'hotel' });
|
||||
// And the stop carries it, so the rail does not number it.
|
||||
@@ -934,11 +958,11 @@ describe('the day stop a booking implies', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Camping Isar' });
|
||||
testDb.prepare("UPDATE places SET stop_type = 'campsite' WHERE id = ?").run(place.id);
|
||||
await updateRows(await orm(), Places, { id: place.id }, { stop_type: 'campsite' });
|
||||
|
||||
const { mirror } = await book(trip.id, place.id, day.id, day.id);
|
||||
|
||||
expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'campsite' });
|
||||
expect(await findRow(await orm(), Places, { id: place.id })).toMatchObject({ stop_type: 'campsite' });
|
||||
expect(mirror.stamped).toBeNull();
|
||||
});
|
||||
|
||||
@@ -954,7 +978,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
const { mirror } = await book(trip.id, place.id, day.id, day.id);
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id, accommodation_id: null })]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id, accommodation_id: null })]);
|
||||
expect(mirror.created).toBeNull();
|
||||
});
|
||||
|
||||
@@ -970,9 +994,9 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
await book(trip.id, place.id, arrive.id, leave.id);
|
||||
|
||||
expect(stopsOn(arrive.id)).toHaveLength(1);
|
||||
expect(stopsOn(middle.id)).toHaveLength(0);
|
||||
expect(stopsOn(leave.id)).toHaveLength(0);
|
||||
expect((await stopsOn(arrive.id))).toHaveLength(1);
|
||||
expect((await stopsOn(middle.id))).toHaveLength(0);
|
||||
expect((await stopsOn(leave.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ACC-027 a stay with no place writes no stop instead of throwing', async () => {
|
||||
@@ -985,7 +1009,7 @@ describe('the day stop a booking implies', () => {
|
||||
const { mirror } = await book(trip.id, null, day.id, day.id);
|
||||
|
||||
expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null });
|
||||
expect(stopsOn(day.id)).toHaveLength(0);
|
||||
expect((await stopsOn(day.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ACC-028 moving the booking to another day moves its stop with it', async () => {
|
||||
@@ -995,15 +1019,15 @@ describe('the day stop a booking implies', () => {
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day1.id, day1.id);
|
||||
const before = stopsOn(day1.id)[0];
|
||||
const before = (await stopsOn(day1.id))[0];
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any;
|
||||
|
||||
expect(stopsOn(day1.id)).toHaveLength(0);
|
||||
expect((await stopsOn(day1.id))).toHaveLength(0);
|
||||
// The same row, carried over. Not a delete and a fresh insert: everything the
|
||||
// traveller hung on this stop is keyed by its id.
|
||||
expect(stopsOn(day2.id)).toEqual([expect.objectContaining({ id: before.id, place_id: place.id, accommodation_id: accommodation.id })]);
|
||||
expect((await stopsOn(day2.id))).toEqual([expect.objectContaining({ id: before.id, place_id: place.id, accommodation_id: accommodation.id })]);
|
||||
expect(mirror.removed).toEqual([]);
|
||||
expect(mirror.created).toBeNull();
|
||||
expect(mirror.moved).toMatchObject({ oldDayId: day1.id, assignment: { id: before.id, day_id: day2.id } });
|
||||
@@ -1016,14 +1040,13 @@ describe('the day stop a booking implies', () => {
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day1.id, day1.id);
|
||||
const stop = stopsOn(day1.id)[0];
|
||||
testDb.prepare('UPDATE day_assignments SET notes = ?, assignment_time = ?, end_day = 1 WHERE id = ?')
|
||||
.run('ask for the quiet side', '15:30', stop.id);
|
||||
const stop = (await stopsOn(day1.id))[0];
|
||||
await setStops({ id: stop.id }, { notes: 'ask for the quiet side', assignment_time: '15:30', end_day: 1 });
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id });
|
||||
|
||||
const after = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(stop.id) as Record<string, unknown>;
|
||||
const after = (await findRow(await orm(), DayAssignments, { id: stop.id }))!;
|
||||
expect(after).toMatchObject({ day_id: day2.id, notes: 'ask for the quiet side', assignment_time: '15:30', end_day: 1 });
|
||||
});
|
||||
|
||||
@@ -1034,15 +1057,15 @@ describe('the day stop a booking implies', () => {
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day1.id, day1.id);
|
||||
const stop = stopsOn(day1.id)[0];
|
||||
const stop = (await stopsOn(day1.id))[0];
|
||||
// assignment_participants.assignment_id is ON DELETE CASCADE, so a delete and
|
||||
// re-insert would take these with it without a word.
|
||||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(stop.id, user.id);
|
||||
await insertRow(await orm(), AssignmentParticipants, { assignment: stop.id, user: user.id });
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id });
|
||||
|
||||
const kept = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(stop.id);
|
||||
const kept = (await findRows(await orm(), AssignmentParticipants, { assignment: stop.id })).map(row => ({ user_id: row.user_id }));
|
||||
expect(kept).toEqual([{ user_id: user.id }]);
|
||||
});
|
||||
|
||||
@@ -1053,14 +1076,14 @@ describe('the day stop a booking implies', () => {
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day1.id, day1.id);
|
||||
const own = stopsOn(day1.id)[0];
|
||||
const own = (await stopsOn(day1.id))[0];
|
||||
const byHand = createDayAssignment(testDb, day2.id, place.id) as { id: number };
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any;
|
||||
|
||||
expect(stopsOn(day1.id)).toHaveLength(0);
|
||||
expect(stopsOn(day2.id).map((row: { id: number }) => row.id)).toEqual([byHand.id]);
|
||||
expect((await stopsOn(day1.id))).toHaveLength(0);
|
||||
expect((await stopsOn(day2.id)).map((row: { id: number }) => row.id)).toEqual([byHand.id]);
|
||||
expect(mirror.removed).toEqual([{ id: own.id, dayId: day1.id }]);
|
||||
expect(mirror.moved).toBeNull();
|
||||
});
|
||||
@@ -1078,7 +1101,7 @@ describe('the day stop a booking implies', () => {
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id });
|
||||
|
||||
expect(stopsOn(day1.id).map((row: { order_index: number }) => row.order_index)).toEqual([0]);
|
||||
expect((await stopsOn(day1.id)).map((row: { order_index: number }) => row.order_index)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('ACC-029 editing an unrelated field leaves the stop exactly where it is', async () => {
|
||||
@@ -1087,12 +1110,12 @@ describe('the day stop a booking implies', () => {
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day.id, day.id);
|
||||
const before = stopsOn(day.id);
|
||||
const before = (await stopsOn(day.id));
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '16:00' })) as any;
|
||||
|
||||
expect(stopsOn(day.id)).toEqual(before);
|
||||
expect((await stopsOn(day.id))).toEqual(before);
|
||||
expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null });
|
||||
});
|
||||
|
||||
@@ -1105,19 +1128,19 @@ describe('the day stop a booking implies', () => {
|
||||
const day = createDay(testDb, trip.id);
|
||||
const dinner = createPlace(testDb, trip.id, { name: 'Osteria' });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
testDb.prepare("UPDATE places SET place_time = '19:00' WHERE id = ?").run(dinner.id);
|
||||
await updateRows(await orm(), Places, { id: dinner.id }, { place_time: '19:00' });
|
||||
createDayAssignment(testDb, day.id, dinner.id);
|
||||
const { accommodation } = await book(trip.id, hotel.id, day.id, day.id);
|
||||
const stop = stopsOn(day.id).find((row: { place_id: number }) => row.place_id === hotel.id)!;
|
||||
const stop = (await stopsOn(day.id)).find((row: { place_id: number }) => row.place_id === hotel.id)!;
|
||||
// The backfill's placement, written by hand: the night behind the dinner.
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, dinner.id);
|
||||
testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(stop.id);
|
||||
expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]);
|
||||
await setStops({ day: day.id, place: dinner.id }, { order_index: 0 });
|
||||
await setStops({ id: stop.id }, { order_index: 1 });
|
||||
expect((await stopsOn(day.id)).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' })) as any;
|
||||
|
||||
expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([hotel.id, dinner.id]);
|
||||
expect((await stopsOn(day.id)).map((row: { place_id: number }) => row.place_id)).toEqual([hotel.id, dinner.id]);
|
||||
// Re-seated, not rebuilt: the same row, one place further forward.
|
||||
expect(mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: stop.id } });
|
||||
expect(mirror.removed).toEqual([]);
|
||||
@@ -1137,8 +1160,8 @@ describe('the day stop a booking implies', () => {
|
||||
const existing = (await svc.getAccommodation(stay.id, trip.id))!;
|
||||
await svc.updateAccommodation(stay.id, existing, { start_day_id: second.id, end_day_id: second.id });
|
||||
|
||||
expect(stopsOn(second.id)).toEqual([]);
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]);
|
||||
expect((await stopsOn(second.id))).toEqual([]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]);
|
||||
});
|
||||
|
||||
it('ACC-031 cancelling a booking takes back its own stop and leaves the other one', async () => {
|
||||
@@ -1149,11 +1172,11 @@ describe('the day stop a booking implies', () => {
|
||||
const museum = createPlace(testDb, trip.id, { name: 'Pergamon' });
|
||||
const own = createDayAssignment(testDb, day.id, museum.id);
|
||||
const { accommodation } = await book(trip.id, hotel.id, day.id, day.id);
|
||||
const mirrored = stopsOn(day.id).find(a => a.place_id === hotel.id)!;
|
||||
const mirrored = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!;
|
||||
|
||||
const { mirror } = await svc.deleteAccommodation(accommodation.id);
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id })]);
|
||||
expect(mirror.removed).toEqual([{ id: mirrored.id, dayId: day.id }]);
|
||||
});
|
||||
|
||||
@@ -1169,7 +1192,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
const { mirror } = await svc.deleteAccommodation(accommodation.id);
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id })]);
|
||||
expect(mirror.removed).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1182,15 +1205,15 @@ describe('the day stop a booking implies', () => {
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day.id, day.id);
|
||||
const mirrored = stopsOn(day.id)[0];
|
||||
const mirrored = (await stopsOn(day.id))[0];
|
||||
|
||||
const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true });
|
||||
|
||||
// Still there, and now unmarked: nothing may move or delete it on the next edit.
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: mirrored.id, place_id: place.id, accommodation_id: null })]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: mirrored.id, place_id: place.id, accommodation_id: null })]);
|
||||
// Nothing to announce either — no session has to take a stop off its day plan.
|
||||
expect(mirror.removed).toEqual([]);
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toBeUndefined();
|
||||
expect(await findRow(await orm(), DayAccommodations, { id: accommodation.id })).toBeNull();
|
||||
});
|
||||
|
||||
it('ACC-032c remove() forwards keepStop, which is the only door the REST route uses', async () => {
|
||||
@@ -1202,7 +1225,7 @@ describe('the day stop a booking implies', () => {
|
||||
|
||||
await svc.remove(accommodation.id, { keepStop: true });
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]);
|
||||
expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]);
|
||||
});
|
||||
|
||||
it('ACC-033 announceMirror sends the removal before the arrival', async () => {
|
||||
@@ -1242,7 +1265,7 @@ describe('the day stop a booking implies', () => {
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' });
|
||||
const { accommodation } = await book(trip.id, place.id, day.id, day.id);
|
||||
const stop = stopsOn(day.id)[0];
|
||||
const stop = (await stopsOn(day.id))[0];
|
||||
|
||||
const { mirror } = (await svc.remove(accommodation.id, { keepStop: true })) as any;
|
||||
|
||||
@@ -1260,46 +1283,42 @@ describe('the drawn roads a booking moves', () => {
|
||||
// night seated by its check-in ahead of the afternoon renumbers every stop behind
|
||||
// it, and every road drawn behind those stops has to move with them, or the
|
||||
// detour is driven on the wrong leg for everyone.
|
||||
const stopsOn = (dayId: number) =>
|
||||
(testDb.prepare('SELECT place_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as { place_id: number }[])
|
||||
.map(row => row.place_id);
|
||||
const addVia = (dayId: number, afterOrderIndex: number): 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 viaAnchors = (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 }[];
|
||||
const stopsOn = async (dayId: number) =>
|
||||
(await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc' })).map(row => row.place_id);
|
||||
const addVia = async (dayId: number, afterOrderIndex: number): Promise<number> =>
|
||||
insertRow(await orm(), RoadtripVias, { day: dayId, after_order_index: afterOrderIndex, sequence: 0, lat: 48.1, lng: 11.5 });
|
||||
const viaAnchors = async (dayId: number) =>
|
||||
(await findRows(await orm(), RoadtripVias, { day: dayId }, { id: 'asc' })).map(row => ({ id: row.id, after_order_index: row.after_order_index }));
|
||||
/** The day's roads as the route reads them: leg by leg, and in order within a leg. */
|
||||
const legOrder = (dayId: number) =>
|
||||
testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id').all(dayId) as
|
||||
{ id: number; after_order_index: number; sequence: number }[];
|
||||
const pin = (assignmentId: number, time: string) =>
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, assignmentId);
|
||||
const legOrder = async (dayId: number) =>
|
||||
(await findRows(await orm(), RoadtripVias, { day: dayId }, { after_order_index: 'asc', sequence: 'asc', id: 'asc' }))
|
||||
.map(row => ({ id: row.id, after_order_index: row.after_order_index, sequence: row.sequence }));
|
||||
const pin = (assignmentId: number, time: string) => setStops({ id: assignmentId }, { assignment_time: time });
|
||||
|
||||
/** A(0) pinned to nine, B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */
|
||||
function afternoonDay() {
|
||||
async function afternoonDay() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
pin(createDayAssignment(testDb, day.id, a.id).id, '09:00');
|
||||
pin(createDayAssignment(testDb, day.id, b.id).id, '12:00');
|
||||
await pin(createDayAssignment(testDb, day.id, a.id).id, '09:00');
|
||||
await pin(createDayAssignment(testDb, day.id, b.id).id, '12:00');
|
||||
createDayAssignment(testDb, day.id, c.id);
|
||||
return { trip, day, a, b, c, hotel };
|
||||
}
|
||||
|
||||
it('ACC-034 a night seated ahead of the afternoon carries the roads behind it along', async () => {
|
||||
const { trip, day, a, b, c, hotel } = afternoonDay();
|
||||
const afterA = addVia(day.id, 0);
|
||||
const afterB = addVia(day.id, 1);
|
||||
const { trip, day, a, b, c, hotel } = await afternoonDay();
|
||||
const afterA = await addVia(day.id, 0);
|
||||
const afterB = await addVia(day.id, 1);
|
||||
// Behind the last stop: the drive into the next day. It stays with C, which is
|
||||
// still last, rather than being dropped as a leg C never had.
|
||||
const intoTomorrow = addVia(day.id, 2);
|
||||
const intoTomorrow = await addVia(day.id, 2);
|
||||
|
||||
const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any;
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
expect((await viaAnchors(day.id))).toEqual([
|
||||
{ id: afterA, after_order_index: 0 },
|
||||
{ id: afterB, after_order_index: 2 },
|
||||
{ id: intoTomorrow, after_order_index: 3 },
|
||||
@@ -1317,27 +1336,27 @@ describe('the drawn roads a booking moves', () => {
|
||||
});
|
||||
|
||||
it('ACC-034b a night seated behind every drawn road moves none and reports none', async () => {
|
||||
const { trip, day, a, b, c, hotel } = afternoonDay();
|
||||
const afterA = addVia(day.id, 0);
|
||||
const afterB = addVia(day.id, 1);
|
||||
const { trip, day, a, b, c, hotel } = await afternoonDay();
|
||||
const afterA = await addVia(day.id, 0);
|
||||
const afterB = await addVia(day.id, 1);
|
||||
|
||||
const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' })) as any;
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([a.id, b.id, hotel.id, c.id]);
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]);
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, b.id, hotel.id, c.id]);
|
||||
expect((await viaAnchors(day.id))).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]);
|
||||
expect(mirror.vias).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', async () => {
|
||||
const { trip, day, a, b, c, hotel } = afternoonDay();
|
||||
const { trip, day, a, b, c, hotel } = await afternoonDay();
|
||||
// C pinned to the afternoon as well, so an evening check-in puts the night last.
|
||||
testDb.prepare("UPDATE day_assignments SET assignment_time = '15:00' WHERE day_id = ? AND place_id = ?").run(day.id, c.id);
|
||||
await setStops({ day: day.id, place: c.id }, { assignment_time: '15:00' });
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any;
|
||||
expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
const afterA = addVia(day.id, 0);
|
||||
const afterHotel = addVia(day.id, 1);
|
||||
const afterB = addVia(day.id, 2);
|
||||
const afterC = addVia(day.id, 3);
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
const afterA = await addVia(day.id, 0);
|
||||
const afterHotel = await addVia(day.id, 1);
|
||||
const afterB = await addVia(day.id, 2);
|
||||
const afterC = await addVia(day.id, 3);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' })) as any;
|
||||
@@ -1346,13 +1365,13 @@ describe('the drawn roads a booking moves', () => {
|
||||
// behind B follows it one number up. The one behind C was the drive into the next
|
||||
// day, which leaves from the hotel now, so it goes as well rather than bending the
|
||||
// drive from C to the hotel through a point on the road to tomorrow.
|
||||
expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]);
|
||||
expect(viaAnchors(day.id)).toEqual([
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, b.id, c.id, hotel.id]);
|
||||
expect((await viaAnchors(day.id))).toEqual([
|
||||
{ id: afterA, after_order_index: 0 },
|
||||
{ id: afterB, after_order_index: 1 },
|
||||
]);
|
||||
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterHotel);
|
||||
expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterC);
|
||||
expect((await viaAnchors(day.id)).map(via => via.id)).not.toContain(afterHotel);
|
||||
expect((await viaAnchors(day.id)).map(via => via.id)).not.toContain(afterC);
|
||||
expect(mirror.moved).not.toBeNull();
|
||||
expect(mirror.vias).toEqual([{ dayId: day.id, vias: expect.arrayContaining([expect.objectContaining({ id: afterB, after_order_index: 1 })]) }]);
|
||||
});
|
||||
@@ -1362,23 +1381,23 @@ describe('the drawn roads a booking moves', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const [a, b, hotel] = ['Harbour', 'Mercure', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
pin(createDayAssignment(testDb, day.id, a.id).id, '09:00');
|
||||
pin(createDayAssignment(testDb, day.id, b.id).id, '12:00');
|
||||
await pin(createDayAssignment(testDb, day.id, a.id).id, '09:00');
|
||||
await pin(createDayAssignment(testDb, day.id, b.id).id, '12:00');
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any;
|
||||
expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id]);
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id]);
|
||||
// The road out of the hotel is drawn first: by id alone it would lead the leg
|
||||
// it ends up sharing with A's road.
|
||||
const afterHotel = addVia(day.id, 1);
|
||||
const afterA = addVia(day.id, 0);
|
||||
const intoTomorrow = addVia(day.id, 2);
|
||||
const afterHotel = await addVia(day.id, 1);
|
||||
const afterA = await addVia(day.id, 0);
|
||||
const intoTomorrow = await addVia(day.id, 2);
|
||||
|
||||
const { mirror } = await svc.deleteAccommodation(accommodation.id);
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([a.id, b.id]);
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, b.id]);
|
||||
// The two legs around the hotel are one leg now, A to B, and the road out of the
|
||||
// hotel joins A's road on it, behind it: the drive runs A's point first, then the
|
||||
// hotel's, then B. The drive into the next day stays behind B, which is still last.
|
||||
expect(legOrder(day.id)).toEqual([
|
||||
expect((await legOrder(day.id))).toEqual([
|
||||
{ id: afterA, after_order_index: 0, sequence: 0 },
|
||||
{ id: afterHotel, after_order_index: 0, sequence: 1 },
|
||||
{ id: intoTomorrow, after_order_index: 1, sequence: 0 },
|
||||
@@ -1396,38 +1415,38 @@ describe('the drawn roads a booking moves', () => {
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name }));
|
||||
pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00');
|
||||
pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00');
|
||||
pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00');
|
||||
await pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00');
|
||||
await pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00');
|
||||
await pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00');
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' })) as any;
|
||||
expect(stopsOn(day1.id)).toEqual([a.id, hotel.id, b.id]);
|
||||
const afterA = addVia(day1.id, 0);
|
||||
const afterHotel = addVia(day1.id, 1);
|
||||
const outOfC = addVia(day2.id, 0);
|
||||
expect((await stopsOn(day1.id))).toEqual([a.id, hotel.id, b.id]);
|
||||
const afterA = await addVia(day1.id, 0);
|
||||
const afterHotel = await addVia(day1.id, 1);
|
||||
const outOfC = await addVia(day2.id, 0);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any;
|
||||
|
||||
expect(stopsOn(day1.id)).toEqual([a.id, b.id]);
|
||||
expect(stopsOn(day2.id)).toEqual([hotel.id, c.id]);
|
||||
expect((await stopsOn(day1.id))).toEqual([a.id, b.id]);
|
||||
expect((await stopsOn(day2.id))).toEqual([hotel.id, c.id]);
|
||||
// The hotel's road on day one joins A's on the leg that is left, behind it.
|
||||
expect(legOrder(day1.id)).toEqual([
|
||||
expect((await legOrder(day1.id))).toEqual([
|
||||
{ id: afterA, after_order_index: 0, sequence: 0 },
|
||||
{ id: afterHotel, after_order_index: 0, sequence: 1 },
|
||||
]);
|
||||
// C is still the last stop of its day, one number further on.
|
||||
expect(viaAnchors(day2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]);
|
||||
expect((await viaAnchors(day2.id))).toEqual([{ id: outOfC, after_order_index: 1 }]);
|
||||
expect(mirror.vias.map((day: { dayId: number }) => day.dayId).sort()).toEqual([day1.id, day2.id].sort());
|
||||
});
|
||||
|
||||
it('ACC-034f a stop the booking hands to the traveller moves no road', async () => {
|
||||
const { trip, day, hotel } = afternoonDay();
|
||||
const { trip, day, hotel } = await afternoonDay();
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any;
|
||||
const afterB = addVia(day.id, 2);
|
||||
const afterB = await addVia(day.id, 2);
|
||||
|
||||
const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true });
|
||||
|
||||
expect(viaAnchors(day.id)).toEqual([{ id: afterB, after_order_index: 2 }]);
|
||||
expect((await viaAnchors(day.id))).toEqual([{ id: afterB, after_order_index: 2 }]);
|
||||
expect(mirror.vias).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1435,18 +1454,18 @@ describe('the drawn roads a booking moves', () => {
|
||||
// A stay that owns two stops on one day cannot be carried across, so both go and
|
||||
// one comes back, and the day's roads are re-pinned at each step. What the mirror
|
||||
// reports is the state that was left behind, not both steps.
|
||||
const { trip, day, a, b, c, hotel } = afternoonDay();
|
||||
const { trip, day, a, b, c, hotel } = await afternoonDay();
|
||||
const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any;
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) VALUES (?, ?, 4, ?)').run(day.id, hotel.id, accommodation.id);
|
||||
const afterA = addVia(day.id, 0);
|
||||
const afterHotel = addVia(day.id, 1);
|
||||
const afterB = addVia(day.id, 2);
|
||||
addVia(day.id, 3);
|
||||
await insertRow(await orm(), DayAssignments, { day: day.id, place: hotel.id, order_index: 4, accommodation_id: accommodation.id });
|
||||
const afterA = await addVia(day.id, 0);
|
||||
const afterHotel = await addVia(day.id, 1);
|
||||
const afterB = await addVia(day.id, 2);
|
||||
await addVia(day.id, 3);
|
||||
|
||||
const existing = (await svc.getAccommodation(accommodation.id, trip.id))!;
|
||||
const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any;
|
||||
|
||||
expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]);
|
||||
expect(mirror.removed).toHaveLength(2);
|
||||
expect(mirror.created).not.toBeNull();
|
||||
// Both stops went, so the hotel's road joined A's; then the night came back
|
||||
|
||||
@@ -18,14 +18,6 @@ vi.mock('../../../src/db/database', async () => {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
db
|
||||
.prepare(
|
||||
'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)',
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -56,6 +48,52 @@ import {
|
||||
createTestCollectionPlacesRepo, createTestCollectionPlaceRatingsRepo, createTestTripsRepo, createTestTripMembersRepo,
|
||||
createTestPlaceRatingsRepo, createTestTagsRepo, createTestUsersRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { addCollectionMember } from '../../helpers/factories/collections';
|
||||
import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity';
|
||||
import { CollectionMembers } from '../../../src/db/entities/CollectionMembers.entity';
|
||||
import { CollectionPlaceRatings } from '../../../src/db/entities/CollectionPlaceRatings.entity';
|
||||
import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity';
|
||||
import { Collections } from '../../../src/db/entities/Collections.entity';
|
||||
import { GooglePlacePhotoMeta } from '../../../src/db/entities/GooglePlacePhotoMeta.entity';
|
||||
import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { Tags } from '../../../src/db/entities/Tags.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
const orm = () => sharedTestOrm(testDb);
|
||||
|
||||
/** Puts the user on the list as an accepted member, with the role the column defaults to unless given. */
|
||||
async function joinCollection(colId: number, userId: number, role?: 'viewer' | 'editor' | 'admin') {
|
||||
await addCollectionMember(await orm(), colId, userId, role ? { role } : {});
|
||||
}
|
||||
|
||||
/** How many saved places a list holds. */
|
||||
async function placesIn(colId: number): Promise<{ n: number }> {
|
||||
return { n: await countRows(await orm(), CollectionPlaces, { collection: colId }) };
|
||||
}
|
||||
|
||||
/** How many member rows (any status) a list holds. */
|
||||
async function membersOf(colId: number): Promise<{ n: number }> {
|
||||
return { n: await countRows(await orm(), CollectionMembers, { collection: colId }) };
|
||||
}
|
||||
|
||||
/** The newest place on a trip. */
|
||||
async function newestPlace(tripId: number): Promise<{ id: number }> {
|
||||
const [row] = await findRows(await orm(), Places, { trip: tripId }, { id: 'desc' });
|
||||
return { id: row.id };
|
||||
}
|
||||
|
||||
/** The votes stored on a trip place. */
|
||||
async function placeVotes(placeId: number): Promise<{ user_id: number; rating: number }[]> {
|
||||
return (await findRows(await orm(), PlaceRatings, { place: placeId })).map(v => ({ user_id: v.user_id, rating: v.rating }));
|
||||
}
|
||||
|
||||
/** The votes stored on a saved place. */
|
||||
async function savedPlaceVotes(collectionPlaceId: number): Promise<{ user_id: number; rating: number }[]> {
|
||||
return (await findRows(await orm(), CollectionPlaceRatings, { collectionPlace: collectionPlaceId })).map(v => ({ user_id: v.user_id, rating: v.rating }));
|
||||
}
|
||||
|
||||
const storageFx = makeStorageFixture('');
|
||||
let svc: CollectionsService;
|
||||
@@ -180,8 +218,8 @@ describe('collections CRUD + visibility', () => {
|
||||
const b = createUser(testDb).user;
|
||||
const other = await svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0
|
||||
await svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1
|
||||
const otherRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(other.id) as { sort_order: number };
|
||||
const colRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(col.id) as { sort_order: number };
|
||||
const otherRow = (await findRow(await orm(), Collections, { id: other.id }))!;
|
||||
const colRow = (await findRow(await orm(), Collections, { id: col.id }))!;
|
||||
expect(otherRow.sort_order).toBe(0); // untouched — not visible to a
|
||||
expect(colRow.sort_order).toBe(1); // reordered to its index in the visible-filtered list
|
||||
});
|
||||
@@ -194,10 +232,11 @@ describe('saved places + dedup', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id);
|
||||
await joinCollection(col.id, member.id);
|
||||
|
||||
const res = await svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 });
|
||||
expect(res.place).toBeDefined();
|
||||
// test-sql-allow: SELECT * shows the table's real columns, and the assertion is that no itinerary column is among them.
|
||||
const row = testDb.prepare('SELECT * FROM collection_places WHERE id = ?').get(res.place!.id) as Record<string, unknown>;
|
||||
expect(row.owner_id).toBe(owner.id);
|
||||
expect(row.saved_by).toBe(member.id);
|
||||
@@ -216,7 +255,7 @@ describe('saved places + dedup', () => {
|
||||
|
||||
const forced = await svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true });
|
||||
expect(forced.place).toBeDefined();
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 2 });
|
||||
expect(await placesIn(col.id)).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-012: savePlace attaches tags', async () => {
|
||||
@@ -319,7 +358,7 @@ describe('saved places + dedup', () => {
|
||||
const col = await svc.createCollection(u.id, { name: 'Rome' });
|
||||
const trip = createTrip(testDb, u.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' });
|
||||
testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id);
|
||||
await updateRows(await orm(), Places, { id: place.id }, { google_ftid: '0x1:0x2' });
|
||||
await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' });
|
||||
|
||||
const out = await svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]);
|
||||
@@ -335,7 +374,7 @@ describe('saved places + dedup', () => {
|
||||
const col = await svc.createCollection(u.id, { name: 'Rome' });
|
||||
const trip = createTrip(testDb, u.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' });
|
||||
testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id);
|
||||
await updateRows(await orm(), Places, { id: place.id }, { google_ftid: '0x1:0x2' });
|
||||
await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' });
|
||||
|
||||
const listed = (await svc.importablePlaces(u.id, col.id, trip.id)).places.find(p => p.place_id === place.id);
|
||||
@@ -391,12 +430,12 @@ describe('status + updatePlace move', () => {
|
||||
const targetOwner = createUser(testDb).user;
|
||||
const b = await svc.createCollection(targetOwner.id, { name: 'B' });
|
||||
// owner is also an accepted member of b so the move target is visible to them
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(b.id, owner.id);
|
||||
await joinCollection(b.id, owner.id);
|
||||
|
||||
const p = (await svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' })).place!;
|
||||
const moved = await svc.updatePlace(owner.id, p.id, { collection_id: b.id });
|
||||
expect(moved.collection_id).toBe(b.id);
|
||||
const row = testDb.prepare('SELECT owner_id FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number };
|
||||
const row = (await findRow(await orm(), CollectionPlaces, { id: p.id }))!;
|
||||
expect(row.owner_id).toBe(targetOwner.id); // reset to the target collection's owner
|
||||
});
|
||||
|
||||
@@ -448,10 +487,10 @@ describe('copyToTrip', () => {
|
||||
expect(res.copied).toBe(1);
|
||||
expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']);
|
||||
|
||||
const inserted = testDb.prepare("SELECT * FROM places WHERE trip_id = ? AND name = 'Colosseum'").get(trip.id) as Record<string, unknown>;
|
||||
const inserted = (await findRow(await orm(), Places, { trip: trip.id, name: 'Colosseum' }))!;
|
||||
expect(inserted.reservation_status).toBe('none'); // itinerary column took the table default
|
||||
expect(inserted.duration_minutes).toBe(60);
|
||||
const tagLink = testDb.prepare('SELECT COUNT(*) n FROM place_tags WHERE place_id = ?').get(inserted.id);
|
||||
const tagLink = { n: await countRows(await orm(), Tags, { place_tags_inverse: inserted.id }) };
|
||||
expect(tagLink).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
@@ -498,9 +537,9 @@ describe('delete places', () => {
|
||||
const p1 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'A' })).place!;
|
||||
const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'B' })).place!;
|
||||
await svc.deletePlace(u.id, p1.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 1 });
|
||||
expect(await placesIn(col.id)).toEqual({ n: 1 });
|
||||
expect(await svc.deletePlacesMany(u.id, [p2.id])).toEqual([p2.id]);
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await placesIn(col.id)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -554,7 +593,7 @@ describe('fusion invitations', () => {
|
||||
const { owner, target, col } = await setup();
|
||||
await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id);
|
||||
await svc.declineInvite(target.id, col.id, undefined);
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await membersOf(col.id)).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-035: cancelInvite is owner-only', async () => {
|
||||
@@ -562,7 +601,7 @@ describe('fusion invitations', () => {
|
||||
await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id);
|
||||
await expect(svc.cancelInvite(col.id, target.id, target.id)).rejects.toThrow(); // non-owner
|
||||
await svc.cancelInvite(col.id, owner.id, target.id); // owner ok
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await membersOf(col.id)).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', async () => {
|
||||
@@ -595,7 +634,7 @@ describe('fusion invitations', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const normal = createUser(testDb).user;
|
||||
const guest = createUser(testDb).user;
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
|
||||
await updateRows(await orm(), Users, { id: guest.id }, { is_guest: 1 });
|
||||
const col = await svc.createCollection(owner.id, { name: 'C' });
|
||||
const ids = (await svc.availableUsers(owner.id, col.id)).map((u) => u.id);
|
||||
expect(ids).toContain(normal.id);
|
||||
@@ -636,9 +675,9 @@ describe('deleteCollection', () => {
|
||||
expect(targets).not.toContain(owner.id);
|
||||
expect(broadcastToUser.mock.calls.every((c) => (c[1] as { type: string }).type === 'collections:deleted')).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(await orm(), Collections, { id: col.id })).toBe(0);
|
||||
expect(await placesIn(col.id)).toEqual({ n: 0 });
|
||||
expect(await membersOf(col.id)).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -649,13 +688,13 @@ describe('owner_id semantics', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id);
|
||||
await joinCollection(col.id, member.id);
|
||||
const p = (await svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' })).place!;
|
||||
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(member.id);
|
||||
await deleteRows(await orm(), Users, { id: member.id });
|
||||
|
||||
const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number; saved_by: number | null };
|
||||
expect(row).toBeDefined();
|
||||
const row = (await findRow(await orm(), CollectionPlaces, { id: p.id }))!;
|
||||
expect(row).not.toBeNull();
|
||||
expect(row.owner_id).toBe(owner.id);
|
||||
expect(row.saved_by).toBeNull(); // ON DELETE SET NULL
|
||||
});
|
||||
@@ -668,27 +707,27 @@ describe('photo-cache widening', () => {
|
||||
const u = createUser(testDb).user;
|
||||
const col = await svc.createCollection(u.id, { name: 'Photos' });
|
||||
// cache meta for place_id 'gp-x', referenced ONLY by a collection_places row.
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-x', null, Date.now());
|
||||
await insertRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-x', attribution: null, fetched_at: Date.now() });
|
||||
await svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' });
|
||||
|
||||
await removeIfUnreferenced('gp-x'); // would evict if isReferenced ignored collection_places
|
||||
|
||||
const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-x');
|
||||
expect(meta).toBeDefined();
|
||||
const meta = await findRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-x' });
|
||||
expect(meta).not.toBeNull();
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-043: an unreferenced photo is still reclaimable', async () => {
|
||||
testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-orphan', null, Date.now());
|
||||
await insertRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-orphan', attribution: null, fetched_at: Date.now() });
|
||||
await removeIfUnreferenced('gp-orphan');
|
||||
const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-orphan');
|
||||
expect(meta).toBeUndefined();
|
||||
const meta = await findRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-orphan' });
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Labels ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin') {
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', ?)").run(colId, userId, role);
|
||||
return joinCollection(colId, userId, role);
|
||||
}
|
||||
|
||||
describe('collection labels', () => {
|
||||
@@ -708,12 +747,12 @@ describe('collection labels', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const viewer = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Trip' });
|
||||
addMember(col.id, viewer.id, 'viewer');
|
||||
await addMember(col.id, viewer.id, 'viewer');
|
||||
await expect(svc.createLabel(viewer.id, col.id, 'X')).rejects.toThrow(expect.objectContaining({ status: 403, message: 'You have read-only access to this list' }));
|
||||
expect((await svc.getCollection(owner.id, col.id)).collection.labels).toHaveLength(0);
|
||||
|
||||
const editor = createUser(testDb).user;
|
||||
addMember(col.id, editor.id, 'editor');
|
||||
await addMember(col.id, editor.id, 'editor');
|
||||
expect((await svc.createLabel(editor.id, col.id, 'Museums')).id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -839,7 +878,7 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const member = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared rate' });
|
||||
// A viewer (read-only) member — still allowed to cast a personal vote.
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id);
|
||||
await joinCollection(col.id, member.id, 'viewer');
|
||||
const p = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' })).place!;
|
||||
|
||||
await svc.setRating(owner.id, p.id, 5);
|
||||
@@ -861,22 +900,21 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const shared = createUser(testDb).user; // member of BOTH the trip and the collection
|
||||
const tripOnly = createUser(testDb).user; // member of the trip only
|
||||
const col = await svc.createCollection(owner.id, { name: 'From trip rated' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id);
|
||||
await joinCollection(col.id, shared.id, 'editor');
|
||||
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, shared.id);
|
||||
addTripMember(testDb, trip.id, tripOnly.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
||||
const ins = testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)');
|
||||
ins.run(place.id, owner.id, 5);
|
||||
ins.run(place.id, shared.id, 4);
|
||||
ins.run(place.id, tripOnly.id, 1);
|
||||
await insertRow(await orm(), PlaceRatings, { place: place.id, user: owner.id, rating: 5 });
|
||||
await insertRow(await orm(), PlaceRatings, { place: place.id, user: shared.id, rating: 4 });
|
||||
await insertRow(await orm(), PlaceRatings, { place: place.id, user: tripOnly.id, rating: 1 });
|
||||
|
||||
const saved = (await svc.savePlace(owner.id, {
|
||||
collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id,
|
||||
})).place!;
|
||||
|
||||
const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[];
|
||||
const votes = await savedPlaceVotes(saved.id);
|
||||
const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b);
|
||||
expect(voterIds).toEqual([owner.id, shared.id].sort((a, b) => a - b));
|
||||
expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined();
|
||||
@@ -886,7 +924,7 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Copyable' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id);
|
||||
await joinCollection(col.id, member.id, 'editor');
|
||||
const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' })).place!;
|
||||
await svc.setRating(owner.id, cp.id, 5);
|
||||
await svc.setRating(member.id, cp.id, 3);
|
||||
@@ -896,8 +934,8 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const res = await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] });
|
||||
expect(res.copied).toBe(1);
|
||||
|
||||
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number };
|
||||
const votes = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number; rating: number }[];
|
||||
const newPlace = await newestPlace(trip.id);
|
||||
const votes = await placeVotes(newPlace.id);
|
||||
expect(votes).toHaveLength(2);
|
||||
expect(votes.find(v => v.user_id === owner.id)?.rating).toBe(5);
|
||||
expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3);
|
||||
@@ -908,17 +946,17 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const victim = createUser(testDb).user;
|
||||
const col = await svc.createCollection(attacker.id, { name: 'Harvest attempt' });
|
||||
// The victim is a member of the attacker's collection (so they'd be "eligible").
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id);
|
||||
await joinCollection(col.id, victim.id, 'editor');
|
||||
// A PRIVATE trip the attacker is not on, with the victim's vote on a place.
|
||||
const privateTrip = createTrip(testDb, victim.id);
|
||||
const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' });
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5);
|
||||
await insertRow(await orm(), PlaceRatings, { place: secret.id, user: victim.id, rating: 5 });
|
||||
|
||||
const saved = (await svc.savePlace(attacker.id, {
|
||||
collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id,
|
||||
})).place!;
|
||||
|
||||
const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id);
|
||||
const stolen = await savedPlaceVotes(saved.id);
|
||||
expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied
|
||||
});
|
||||
|
||||
@@ -928,7 +966,7 @@ describe('collaborative ratings (#1435)', () => {
|
||||
const notInTrip = createUser(testDb).user; // collection member only
|
||||
const col = await svc.createCollection(owner.id, { name: 'Mixed membership' });
|
||||
for (const u of [inTrip, notInTrip]) {
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id);
|
||||
await joinCollection(col.id, u.id, 'editor');
|
||||
}
|
||||
const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' })).place!;
|
||||
await svc.setRating(owner.id, cp.id, 5);
|
||||
@@ -939,8 +977,8 @@ describe('collaborative ratings (#1435)', () => {
|
||||
addTripMember(testDb, trip.id, inTrip.id);
|
||||
await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] });
|
||||
|
||||
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number };
|
||||
const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[])
|
||||
const newPlace = await newestPlace(trip.id);
|
||||
const ids = (await placeVotes(newPlace.id))
|
||||
.map(v => v.user_id).sort((a, b) => a - b);
|
||||
expect(ids).toEqual([owner.id, inTrip.id].sort((a, b) => a - b));
|
||||
expect(ids).not.toContain(notInTrip.id);
|
||||
@@ -989,15 +1027,15 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => {
|
||||
const mine = await svc.createCollection(u.id, { name: 'Mine' });
|
||||
const shared = await svc.createCollection(otherOwner.id, { name: 'Shared' });
|
||||
// u is an editor on the shared list — can add/edit but NOT delete (owner/admin only).
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(shared.id, u.id);
|
||||
await joinCollection(shared.id, u.id, 'editor');
|
||||
const p1 = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'Deletable' })).place!;
|
||||
const p2 = (await svc.savePlace(u.id, { collection_id: shared.id, name: 'Protected' })).place!;
|
||||
|
||||
// The relocated legacy interleaved checks with deletes, so p1 was gone by
|
||||
// the time p2's 403 fired. Now every id is checked first: nothing deleted.
|
||||
await expect(svc.deletePlacesMany(u.id, [p1.id, p2.id])).rejects.toThrow('Only an admin can delete places from this list');
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p1.id)).toEqual({ n: 1 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p2.id)).toEqual({ n: 1 });
|
||||
expect(await countRows(await orm(), CollectionPlaces, { id: p1.id })).toBe(1);
|
||||
expect(await countRows(await orm(), CollectionPlaces, { id: p2.id })).toBe(1);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-091: assignLabels permission-checks every list before writing anything', async () => {
|
||||
@@ -1005,16 +1043,15 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => {
|
||||
const otherOwner = createUser(testDb).user;
|
||||
const mine = await svc.createCollection(u.id, { name: 'Mine' });
|
||||
const readonly = await svc.createCollection(otherOwner.id, { name: 'ReadOnly' });
|
||||
testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(readonly.id, u.id);
|
||||
await joinCollection(readonly.id, u.id, 'viewer');
|
||||
const label = await svc.createLabel(u.id, mine.id, 'Coast');
|
||||
const pa = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'A' })).place!;
|
||||
const pb = testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, saved_by, name) VALUES (?, ?, ?, ?)')
|
||||
.run(readonly.id, otherOwner.id, otherOwner.id, 'B').lastInsertRowid as number;
|
||||
const pb = await insertRow(await orm(), CollectionPlaces, { collection: readonly.id, owner: otherOwner.id, savedByRef: otherOwner.id, name: 'B' });
|
||||
|
||||
// The relocated legacy checked per list inside the write loop, so `mine`
|
||||
// was labeled before `readonly`'s 403 fired. Now all lists check first.
|
||||
await expect(svc.assignLabels(u.id, [label.id], [pa.id, Number(pb)], false)).rejects.toThrow('You have read-only access to this list');
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_place_labels WHERE collection_place_id = ?').get(pa.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(await orm(), CollectionLabels, { collection_place_labels_inverse: pa.id })).toBe(0);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-092: from-trip saves forward the socket id so the origin client does not echo', async () => {
|
||||
@@ -1039,8 +1076,8 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => {
|
||||
// ── Bulk "visited" from a trip (#1469) ───────────────────────────────────────
|
||||
|
||||
/** The stored status of a saved place, read straight off the row. */
|
||||
function statusOf(placeId: number): string {
|
||||
return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string }).status;
|
||||
async function statusOf(placeId: number): Promise<string> {
|
||||
return (await findRow(await orm(), CollectionPlaces, { id: placeId }))!.status;
|
||||
}
|
||||
|
||||
describe('bulk status', () => {
|
||||
@@ -1054,7 +1091,7 @@ describe('bulk status', () => {
|
||||
|
||||
// pb is already visited, so only pa is a change.
|
||||
expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 });
|
||||
expect(statusOf(pa.id)).toBe('visited');
|
||||
expect(await statusOf(pa.id)).toBe('visited');
|
||||
expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 });
|
||||
});
|
||||
|
||||
@@ -1063,12 +1100,12 @@ describe('bulk status', () => {
|
||||
const viewer = createUser(testDb).user;
|
||||
const mine = await svc.createCollection(viewer.id, { name: 'Mine' });
|
||||
const readonly = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(readonly.id, viewer.id, 'viewer');
|
||||
await addMember(readonly.id, viewer.id, 'viewer');
|
||||
const p1 = (await svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' })).place!;
|
||||
const p2 = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' })).place!;
|
||||
|
||||
await expect(svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).rejects.toThrow('You have read-only access to this list');
|
||||
expect(statusOf(p1.id)).toBe('idea');
|
||||
expect(await statusOf(p1.id)).toBe('idea');
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', async () => {
|
||||
@@ -1084,7 +1121,7 @@ describe('bulk status', () => {
|
||||
await svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id);
|
||||
|
||||
expect(await svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 });
|
||||
const statuses = testDb.prepare('SELECT status FROM collection_places ORDER BY id').all() as { status: string }[];
|
||||
const statuses = await findRows(await orm(), CollectionPlaces, {}, { id: 'asc' });
|
||||
expect(statuses.map(s => s.status)).toEqual(['visited', 'visited', 'idea']);
|
||||
});
|
||||
|
||||
@@ -1097,7 +1134,7 @@ describe('bulk status', () => {
|
||||
const saved = (await svc.saveFromTripPlace(u.id, col.id, trip.id, place.id)).place!;
|
||||
// Renamed on both sides, and moved far enough that coordinates cannot match.
|
||||
await svc.updatePlace(u.id, saved.id, { name: 'Dinner spot', lat: 45, lng: 9 });
|
||||
testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Enzo', place.id);
|
||||
await updateRows(await orm(), Places, { id: place.id }, { name: 'Enzo' });
|
||||
|
||||
expect(await svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 });
|
||||
});
|
||||
@@ -1120,18 +1157,18 @@ describe('bulk status', () => {
|
||||
const trip = createTrip(testDb, viewer.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
||||
const readonly = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(readonly.id, viewer.id, 'viewer');
|
||||
await addMember(readonly.id, viewer.id, 'viewer');
|
||||
const theirs = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 })).place!;
|
||||
|
||||
expect(await svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 });
|
||||
expect(statusOf(theirs.id)).toBe('idea');
|
||||
expect(await statusOf(theirs.id)).toBe('idea');
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', async () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const viewer = createUser(testDb).user;
|
||||
const readonly = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(readonly.id, viewer.id, 'viewer');
|
||||
await addMember(readonly.id, viewer.id, 'viewer');
|
||||
const saved = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' })).place!;
|
||||
await svc.setStatus(owner.id, saved.id, 'visited');
|
||||
|
||||
@@ -1191,7 +1228,7 @@ describe('exportCollection / importCollection (#2198)', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const { col, market } = await seedList(owner.id);
|
||||
addMember(col.id, member.id, 'editor');
|
||||
await addMember(col.id, member.id, 'editor');
|
||||
await svc.setRating(member.id, market.id, 5);
|
||||
|
||||
const file = await svc.exportCollection(owner.id, col.id);
|
||||
@@ -1219,7 +1256,7 @@ describe('exportCollection / importCollection (#2198)', () => {
|
||||
const viewer = createUser(testDb).user;
|
||||
const stranger = createUser(testDb).user;
|
||||
const { col } = await seedList(owner.id);
|
||||
addMember(col.id, viewer.id, 'viewer');
|
||||
await addMember(col.id, viewer.id, 'viewer');
|
||||
|
||||
expect((await svc.exportCollection(viewer.id, col.id)).places).toHaveLength(2);
|
||||
try {
|
||||
@@ -1328,7 +1365,7 @@ describe('exportCollection / importCollection (#2198)', () => {
|
||||
} as never],
|
||||
} })).collection as { id: number };
|
||||
|
||||
const row = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(created.id) as Record<string, unknown>;
|
||||
const row = (await findRow(await orm(), CollectionPlaces, { collection: created.id }))!;
|
||||
expect(row.owner_id).toBe(owner.id);
|
||||
expect(row.saved_by).toBe(owner.id);
|
||||
expect(row.source_trip_id).toBeNull();
|
||||
@@ -1399,7 +1436,7 @@ describe('importIntoCollection', () => {
|
||||
expect(after.collection).toMatchObject({ name: 'Lisbon', description: 'Mine', color: '#111827' });
|
||||
expect(after.places.map(p => p.name)).toEqual(['Time Out Market', 'Belém', 'Alfama']);
|
||||
// Appended after what was there, so a manual order survives.
|
||||
const orders = testDb.prepare('SELECT name, sort_order FROM collection_places WHERE collection_id = ? ORDER BY sort_order').all(col.id);
|
||||
const orders = (await findRows(await orm(), CollectionPlaces, { collection: col.id }, { sort_order: 'asc' })).map(r => ({ name: r.name, sort_order: r.sort_order }));
|
||||
expect(orders).toEqual([
|
||||
{ name: 'Time Out Market', sort_order: 0 },
|
||||
{ name: 'Belém', sort_order: 1 },
|
||||
@@ -1461,8 +1498,8 @@ describe('importIntoCollection', () => {
|
||||
const viewer = createUser(testDb).user;
|
||||
const stranger = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(col.id, editor.id, 'editor');
|
||||
addMember(col.id, viewer.id, 'viewer');
|
||||
await addMember(col.id, editor.id, 'editor');
|
||||
await addMember(col.id, viewer.id, 'viewer');
|
||||
|
||||
expect(await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) })).toMatchObject({ imported: 1 });
|
||||
await expect(svc.importIntoCollection(viewer.id, col.id, { file: file([{ name: 'Alfama' }]) })).rejects.toThrow();
|
||||
@@ -1479,19 +1516,19 @@ describe('importIntoCollection', () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const editor = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(col.id, editor.id, 'editor');
|
||||
await addMember(col.id, editor.id, 'editor');
|
||||
|
||||
await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) });
|
||||
|
||||
const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE collection_id = ?').get(col.id);
|
||||
expect(row).toEqual({ owner_id: owner.id, saved_by: editor.id });
|
||||
const stored = (await findRow(await orm(), CollectionPlaces, { collection: col.id }))!;
|
||||
expect({ owner_id: stored.owner_id, saved_by: stored.saved_by }).toEqual({ owner_id: owner.id, saved_by: editor.id });
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-126: everyone on the list is told once, and nothing is said when nothing changed', async () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const member = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Shared' });
|
||||
addMember(col.id, member.id, 'editor');
|
||||
await addMember(col.id, member.id, 'editor');
|
||||
await svc.savePlace(owner.id, { collection_id: col.id, name: 'Belém' });
|
||||
broadcastToUser.mockClear();
|
||||
|
||||
@@ -1600,12 +1637,12 @@ describe('exportCollectionGpx / readCollectionGpx (#2301)', () => {
|
||||
|
||||
it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', async () => {
|
||||
const owner = createUser(testDb).user;
|
||||
const before = testDb.prepare('SELECT COUNT(*) AS n FROM collections').get() as { n: number };
|
||||
const before = await countRows(await orm(), Collections);
|
||||
|
||||
svc.readCollectionGpx({ gpx: fixture('garmin-sym.gpx') });
|
||||
expect(() => svc.readCollectionGpx({ gpx: fixture('hostile-doctype.gpx') })).toThrow(expect.objectContaining({ code: 'unreadable' }));
|
||||
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM collections').get()).toEqual(before);
|
||||
expect(await countRows(await orm(), Collections)).toBe(before);
|
||||
expect((await svc.listCollections(owner.id)).collections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1631,9 +1668,9 @@ describe('Plan 3h Task 1 — repository conversion parity', () => {
|
||||
const viewer = createUser(testDb).user;
|
||||
const pending = createUser(testDb).user;
|
||||
const col = await svc.createCollection(owner.id, { name: 'Full house', description: 'Every role', color: '#111111' });
|
||||
addMember(col.id, admin.id, 'admin');
|
||||
addMember(col.id, editor.id, 'editor');
|
||||
addMember(col.id, viewer.id, 'viewer');
|
||||
await addMember(col.id, admin.id, 'admin');
|
||||
await addMember(col.id, editor.id, 'editor');
|
||||
await addMember(col.id, viewer.id, 'viewer');
|
||||
await svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id);
|
||||
|
||||
const tag = createTag(testDb, owner.id, { name: 'Foodie' });
|
||||
@@ -1649,7 +1686,9 @@ describe('Plan 3h Task 1 — repository conversion parity', () => {
|
||||
// the same seeded rows — the oracle this test proves the repository
|
||||
// conversion against, kept structurally separate from the production
|
||||
// code path it verifies.
|
||||
// test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose.
|
||||
const colRow = testDb.prepare('SELECT * FROM collections WHERE id = ?').get(col.id) as Record<string, unknown>;
|
||||
// test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose.
|
||||
const placeCount = (testDb.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(col.id) as { n: number }).n;
|
||||
const legacyMembers = [
|
||||
{ user_id: owner.id, username: owner.username, email: owner.email, avatar: null, status: 'accepted', role: 'admin', is_owner: true },
|
||||
@@ -1665,8 +1704,10 @@ describe('Plan 3h Task 1 — repository conversion parity', () => {
|
||||
.sort((a, b) => a.u.username.localeCompare(b.u.username))
|
||||
.map(({ u, role, status }) => ({ user_id: u.id, username: u.username, email: u.email, avatar: null, status, role, is_owner: false })),
|
||||
];
|
||||
// test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose.
|
||||
const placeRow = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(col.id) as Record<string, unknown>;
|
||||
const ratingRows = testDb
|
||||
// test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose.
|
||||
.prepare('SELECT cpr.user_id, cpr.rating FROM collection_place_ratings cpr WHERE cpr.collection_place_id = ? ORDER BY cpr.created_at')
|
||||
.all(place.id) as { user_id: number; rating: number }[];
|
||||
|
||||
@@ -1702,6 +1743,7 @@ describe('Plan 3h Task 1 — repository conversion parity', () => {
|
||||
// cascade already happened by the time any broadcast fires.
|
||||
const memberCountAtBroadcastTime: number[] = [];
|
||||
broadcastToUser.mockImplementation(() => {
|
||||
// test-sql-allow: the count has to be taken synchronously, at the moment the broadcast fires.
|
||||
memberCountAtBroadcastTime.push((testDb.prepare('SELECT COUNT(*) AS n FROM collection_members WHERE collection_id = ?').get(col.id) as { n: number }).n);
|
||||
});
|
||||
|
||||
@@ -1777,9 +1819,9 @@ describe('Plan 3h Task 2 — R6 two-layer authorization order', () => {
|
||||
const strangerOwner = createUser(testDb).user;
|
||||
const strangerTrip = createTrip(testDb, strangerOwner.id); // owner (the caller) is not on this trip
|
||||
|
||||
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(strangerTrip.id) as { n: number }).n;
|
||||
const before = await countRows(await orm(), Places, { trip: strangerTrip.id });
|
||||
await expect(svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] })).rejects.toThrow();
|
||||
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(strangerTrip.id) as { n: number }).n;
|
||||
const after = await countRows(await orm(), Places, { trip: strangerTrip.id });
|
||||
expect(after).toBe(before); // no places row (CL64), and therefore no place_tags/place_ratings rows either
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,12 @@ import { setPluginChannelSource } from '../../../src/nest/notifications/channel-
|
||||
// it for its own signatures and never re-exported it.
|
||||
import type { ExternalChannel } from '../../../src/nest/notifications/notification-events';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { setUserSetting } from '../../helpers/factories/settings';
|
||||
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
|
||||
import { Notifications } from '../../../src/db/entities/Notifications.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
let notifications: NotificationsService;
|
||||
@@ -110,28 +116,37 @@ function setSmtp(): void {
|
||||
setAppSetting(testDb, 'smtp_from', 'trek@test.com');
|
||||
}
|
||||
|
||||
function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void {
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url);
|
||||
const orm = () => sharedTestOrm(testDb);
|
||||
|
||||
async function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): Promise<void> {
|
||||
await setUserSetting(await orm(), userId, 'webhook_url', url);
|
||||
}
|
||||
|
||||
function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void {
|
||||
setAppSetting(testDb, 'admin_webhook_url', url);
|
||||
}
|
||||
|
||||
function getInAppNotifications(recipientId: number) {
|
||||
return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string | null;
|
||||
navigate_text_key: string | null;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
}>;
|
||||
async function getInAppNotifications(recipientId: number) {
|
||||
return findRows(await orm(), Notifications, { recipient: recipientId }, { id: 'asc' });
|
||||
}
|
||||
|
||||
function countAllNotifications(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
async function countAllNotifications(): Promise<number> {
|
||||
return countRows(await orm(), Notifications);
|
||||
}
|
||||
|
||||
/** Every notification's recipient, lowest id first. */
|
||||
async function recipientIds(): Promise<number[]> {
|
||||
return (await findRows(await orm(), Notifications, {}, { recipient: 'asc' })).map(r => r.recipient_id);
|
||||
}
|
||||
|
||||
/** A trip with just its owner, returning its id. */
|
||||
async function newTrip(title: string, userId: number): Promise<number> {
|
||||
return (await makeTrip(await orm(), userId, { title })).id;
|
||||
}
|
||||
|
||||
/** Points the user's email at the address the mail assertions expect. */
|
||||
async function setRecipientEmail(userId: number): Promise<void> {
|
||||
await updateRows(await orm(), Users, { id: userId }, { email: 'recipient@test.com' });
|
||||
}
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||
@@ -161,43 +176,43 @@ describe('send() — multi-channel dispatch', () => {
|
||||
it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Paris', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
expect(await countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Rome', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
expect(await countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-003 — sends only email when only email channel is active', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Berlin', user.id);
|
||||
|
||||
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
@@ -215,10 +230,10 @@ describe('send() — per-user preference filtering', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Paris', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
@@ -232,23 +247,23 @@ describe('send() — per-user preference filtering', () => {
|
||||
setNotificationChannels(testDb, 'none');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
|
||||
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
expect(await countAllNotifications()).toBe(0);
|
||||
});
|
||||
|
||||
it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Paris', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
@@ -269,16 +284,16 @@ describe('send() — recipient resolution', () => {
|
||||
const { user: actor } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
|
||||
const tripId = await newTrip('Trip', owner.id);
|
||||
await addTripMember(await orm(), tripId, member1.id);
|
||||
await addTripMember(await orm(), tripId, member2.id);
|
||||
await addTripMember(await orm(), tripId, actor.id);
|
||||
|
||||
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
// Owner, member1, member2 get it; actor is excluded
|
||||
expect(countAllNotifications()).toBe(3);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(await countAllNotifications()).toBe(3);
|
||||
const recipients = await recipientIds();
|
||||
expect(recipients).toContain(owner.id);
|
||||
expect(recipients).toContain(member1.id);
|
||||
expect(recipients).toContain(member2.id);
|
||||
@@ -290,20 +305,20 @@ describe('send() — recipient resolution', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
|
||||
const tripId = await newTrip('Trip', owner.id);
|
||||
await addTripMember(await orm(), tripId, member.id);
|
||||
// A guest joined into the trip — assignable, but has no inbox.
|
||||
const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-x@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId);
|
||||
const guestId = await insertRow(await orm(), Users, { username: 'Guest', email: 'guest-x@guests.invalid', password_hash: '', role: 'user', is_guest: 1 });
|
||||
await addTripMember(await orm(), tripId, guestId);
|
||||
|
||||
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Owner', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
let recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
let recipients = await recipientIds();
|
||||
expect(recipients).toContain(member.id);
|
||||
expect(recipients).not.toContain(guestId);
|
||||
|
||||
// Even a direct user-scope notification (e.g. a todo assigned to the guest) is dropped.
|
||||
await send({ event: 'vacay_invite', actorId: owner.id, scope: 'user', targetId: guestId, params: { actor: 'owner@test.com', planId: '1' } });
|
||||
recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
recipients = await recipientIds();
|
||||
expect(recipients).not.toContain(guestId);
|
||||
});
|
||||
|
||||
@@ -311,10 +326,10 @@ describe('send() — recipient resolution', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
|
||||
const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-y@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId);
|
||||
const tripId = await newTrip('Trip', owner.id);
|
||||
await addTripMember(await orm(), tripId, member.id);
|
||||
const guestId = await insertRow(await orm(), Users, { username: 'Guest', email: 'guest-y@guests.invalid', password_hash: '', role: 'user', is_guest: 1 });
|
||||
await addTripMember(await orm(), tripId, guestId);
|
||||
|
||||
const tripRecipients = await notifications.resolveRecipients('trip', tripId);
|
||||
expect(tripRecipients).toContain(owner.id);
|
||||
@@ -332,8 +347,8 @@ describe('send() — recipient resolution', () => {
|
||||
|
||||
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
|
||||
expect(await countAllNotifications()).toBe(1);
|
||||
const notif = (await findRow(await orm(), Notifications, {}))!;
|
||||
expect(notif.recipient_id).toBe(target.id);
|
||||
});
|
||||
|
||||
@@ -345,8 +360,8 @@ describe('send() — recipient resolution', () => {
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
|
||||
|
||||
expect(countAllNotifications()).toBe(2);
|
||||
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
|
||||
expect(await countAllNotifications()).toBe(2);
|
||||
const recipients = await recipientIds();
|
||||
expect(recipients).toContain(admin1.id);
|
||||
expect(recipients).toContain(admin2.id);
|
||||
});
|
||||
@@ -369,11 +384,11 @@ describe('send() — recipient resolution', () => {
|
||||
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
|
||||
const { user: owner } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Solo', owner.id);
|
||||
|
||||
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
|
||||
|
||||
expect(countAllNotifications()).toBe(0);
|
||||
expect(await countAllNotifications()).toBe(0);
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -389,7 +404,7 @@ describe('send() — in-app notification content', () => {
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
const notifs = await getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('navigate');
|
||||
expect(notifs[0].title_key).toBe('notif.trip_invite.title');
|
||||
@@ -405,7 +420,7 @@ describe('send() — in-app notification content', () => {
|
||||
// vacay_invite without planId → no navigate target → simple type
|
||||
await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } });
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
const notifs = await getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].type).toBe('simple');
|
||||
expect(notifs[0].navigate_target).toBeNull();
|
||||
@@ -417,7 +432,7 @@ describe('send() — in-app notification content', () => {
|
||||
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
|
||||
|
||||
const notifs = getInAppNotifications(admin.id);
|
||||
const notifs = await getInAppNotifications(admin.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].navigate_target).toBe('/admin');
|
||||
expect(notifs[0].title_key).toBe('notif.version_available.title');
|
||||
@@ -443,7 +458,7 @@ describe('send() — in-app notification content', () => {
|
||||
params: { backend: 'minio', op: 'put', key: 'trips/1/b.jpg', error: 'ECONNRESET', suppressed: '0' },
|
||||
});
|
||||
|
||||
const notifs = getInAppNotifications(admin.id);
|
||||
const notifs = await getInAppNotifications(admin.id);
|
||||
expect(notifs.length).toBe(2);
|
||||
expect(notifs[0].text_key).toBe('notif.replica_failure.textSuppressed');
|
||||
expect(notifs[1].text_key).toBe('notif.replica_failure.text');
|
||||
@@ -461,7 +476,7 @@ describe('send() — in-app notification content', () => {
|
||||
params: { backend: 'minio', op: 'put', key: 'trips/1/c.jpg', error: 'ECONNRESET' },
|
||||
});
|
||||
|
||||
const notifs = getInAppNotifications(admin.id);
|
||||
const notifs = await getInAppNotifications(admin.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
expect(notifs[0].text_key).toBe('notif.replica_failure.text');
|
||||
});
|
||||
@@ -476,11 +491,11 @@ describe('send() — email/webhook links', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
// Set user language to French
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
|
||||
await setUserSetting(await orm(), user.id, 'language', 'fr');
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Paris', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
@@ -492,7 +507,7 @@ describe('send() — email/webhook links', () => {
|
||||
|
||||
it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
|
||||
await setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
|
||||
@@ -528,7 +543,7 @@ describe('send() — boolean in-app type', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const notifs = getInAppNotifications(user.id);
|
||||
const notifs = await getInAppNotifications(user.id);
|
||||
expect(notifs.length).toBe(1);
|
||||
const row = notifs[0] as any;
|
||||
expect(row.type).toBe('boolean');
|
||||
@@ -545,41 +560,41 @@ describe('send() — channel failure resilience', () => {
|
||||
it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
|
||||
// Make email throw
|
||||
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and webhook still fire despite email failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
expect(await countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
|
||||
// Make webhook throw
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
// In-app and email still fire despite webhook failure
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
expect(await countAllNotifications()).toBe(1);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -588,10 +603,10 @@ describe('send() reports what it delivered', () => {
|
||||
it('NSVC-022: counts in-app and every channel that went out', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setUserWebhookUrl(user.id);
|
||||
await setUserWebhookUrl(user.id);
|
||||
setNotificationChannels(testDb, 'email,webhook');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
await setRecipientEmail(user.id);
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await expect(
|
||||
send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }),
|
||||
@@ -602,10 +617,10 @@ describe('send() reports what it delivered', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSmtp();
|
||||
setNotificationChannels(testDb, 'email');
|
||||
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
|
||||
await setRecipientEmail(user.id);
|
||||
disableNotificationPref(testDb, user.id, 'trip_reminder', 'inapp');
|
||||
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await expect(
|
||||
send({ event: 'trip_reminder', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', tripId: String(tripId) } }),
|
||||
@@ -616,7 +631,7 @@ describe('send() reports what it delivered', () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Trip', user.id);
|
||||
|
||||
await expect(
|
||||
send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } }),
|
||||
@@ -629,8 +644,8 @@ describe('send() reports what it delivered', () => {
|
||||
|
||||
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void {
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic);
|
||||
async function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): Promise<void> {
|
||||
await setUserSetting(await orm(), userId, 'ntfy_topic', topic);
|
||||
}
|
||||
|
||||
function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void {
|
||||
@@ -644,9 +659,9 @@ describe('send() — ntfy channel dispatch', () => {
|
||||
|
||||
it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserNtfyTopic(user.id);
|
||||
await setUserNtfyTopic(user.id);
|
||||
setNotificationChannels(testDb, 'ntfy');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number;
|
||||
const tripId = await newTrip('Tokyo', user.id);
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
@@ -659,7 +674,7 @@ describe('send() — ntfy channel dispatch', () => {
|
||||
|
||||
it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserNtfyTopic(user.id);
|
||||
await setUserNtfyTopic(user.id);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
fetchMock.mockClear();
|
||||
@@ -794,7 +809,7 @@ describe('send() — plugin notification channels', () => {
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
expect(getInAppNotifications(user.id).length).toBe(1);
|
||||
expect((await getInAppNotifications(user.id)).length).toBe(1);
|
||||
});
|
||||
|
||||
it('NSVC-PLUG-006 — never receives an admin-scoped event', async () => {
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
createTestReservationEndpointsRepo,
|
||||
createTestReservationDayPositionsRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
|
||||
import { Reservations } from '../../../src/db/entities/Reservations.entity';
|
||||
import type { RoadtripPreferences } from '@trek/shared';
|
||||
import { bookendAssignmentId, type RoadtripStop } from '@trek/shared/roadtrip';
|
||||
|
||||
@@ -64,16 +70,50 @@ async function planRepos() {
|
||||
] as const;
|
||||
}
|
||||
|
||||
/** Sets columns on one day assignment. */
|
||||
async function setVisit(id: number, fields: Partial<Pick<DayAssignments, 'assignment_time' | 'assignment_end_time' | 'accommodation_id'>>) {
|
||||
await updateRows(await sharedTestOrm(db), DayAssignments, { id }, fields);
|
||||
}
|
||||
|
||||
/** Sets columns on one place. */
|
||||
async function setPlace(id: number, fields: Partial<Pick<Places, 'duration_minutes' | 'end_time'>>) {
|
||||
await updateRows(await sharedTestOrm(db), Places, { id }, fields);
|
||||
}
|
||||
|
||||
/** The day a visit sits on. */
|
||||
async function dayOf(assignmentId: number): Promise<{ day_id: number }> {
|
||||
const row = (await findRow(await sharedTestOrm(db), DayAssignments, { id: assignmentId }))!;
|
||||
return { day_id: row.day_id };
|
||||
}
|
||||
|
||||
/** Adds a booking and returns its id. */
|
||||
async function addReservation(fields: {
|
||||
trip: number; title: string; type: string; day?: number | null; endDay?: number | null;
|
||||
reservation_time?: string; reservation_end_time?: string; accommodation_id?: number; day_plan_position?: number;
|
||||
}): Promise<number> {
|
||||
const { accommodation_id, ...rest } = fields;
|
||||
return insertRow(await sharedTestOrm(db), Reservations, {
|
||||
...rest,
|
||||
...(accommodation_id === undefined ? {} : { accommodation_id: String(accommodation_id) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Adds one located terminal to a booking. */
|
||||
async function addEndpoint(reservation: number, role: string, sequence: number, name: string, code: string | null, lat: number, lng: number) {
|
||||
await insertRow(await sharedTestOrm(db), ReservationEndpoints, { reservation, role, sequence, name, code, lat, lng });
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const { user } = createUser(db);
|
||||
const trip = createTrip(db, user.id);
|
||||
const day = createDay(db, trip.id);
|
||||
const visits = ['Hamburg', 'Lueneburg', 'Celle'].map((name, i) => {
|
||||
const visits: ReturnType<typeof createDayAssignment>[] = [];
|
||||
for (const [i, name] of ['Hamburg', 'Lueneburg', 'Celle'].entries()) {
|
||||
const place = createPlace(db, trip.id, { name, lat: 53 - i * 0.3, lng: 10 });
|
||||
db.prepare('UPDATE places SET duration_minutes = ? WHERE id = ?').run(i === 0 ? 0 : 30, place.id);
|
||||
return createDayAssignment(db, day.id, place.id);
|
||||
});
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(visits[0].id);
|
||||
await setPlace(place.id, { duration_minutes: i === 0 ? 0 : 30 });
|
||||
visits.push(createDayAssignment(db, day.id, place.id));
|
||||
}
|
||||
await setVisit(visits[0].id, { assignment_time: '09:00' });
|
||||
const plans = new RoadtripPlanService(
|
||||
{ getUserSettings: () => ({}) } as never,
|
||||
{ read: () => ({}) } as never,
|
||||
@@ -92,10 +132,8 @@ beforeEach(() => {
|
||||
describe('a visit end time on the road trip', () => {
|
||||
it('is read from the visit, and from the place when the visit has none', async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
|
||||
db.prepare(
|
||||
"UPDATE places SET end_time = '18:00' WHERE id = (SELECT place_id FROM day_assignments WHERE id = ?)",
|
||||
).run(visits[2].id);
|
||||
await setVisit(visits[1].id, { assignment_end_time: '14:00' });
|
||||
await setPlace((await findRow(await sharedTestOrm(db), DayAssignments, { id: visits[2].id }))!.place_id, { end_time: '18:00' });
|
||||
|
||||
const context = await plans.context(trip.id, user.id);
|
||||
|
||||
@@ -108,7 +146,7 @@ describe('a visit end time on the road trip', () => {
|
||||
|
||||
it('is when the drive leaves the stop, in place of its stay', async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
|
||||
await setVisit(visits[1].id, { assignment_end_time: '14:00' });
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
const day = calculated.days[0];
|
||||
@@ -124,7 +162,7 @@ describe('a visit end time on the road trip', () => {
|
||||
|
||||
it('is reported when the drive gets there after it', async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_end_time = '09:30' WHERE id = ?").run(visits[1].id);
|
||||
await setVisit(visits[1].id, { assignment_end_time: '09:30' });
|
||||
|
||||
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
@@ -183,31 +221,25 @@ describe('a booked night on the road trip (#2410)', () => {
|
||||
});
|
||||
|
||||
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
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
|
||||
VALUES (?, ?, 'flight', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(tripId, 'LH 2020 HAM → MUC', dayId, over.end_day_id ?? dayId, over.dep ?? '13:20', over.arr ?? '14:30');
|
||||
const id = Number(result.lastInsertRowid);
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99);
|
||||
endpoint.run(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78);
|
||||
async function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) {
|
||||
const id = await addReservation({
|
||||
trip: tripId, title: 'LH 2020 HAM → MUC', type: 'flight', day: dayId, endDay: over.end_day_id ?? dayId,
|
||||
reservation_time: over.dep ?? '13:20', reservation_end_time: over.arr ?? '14:30',
|
||||
});
|
||||
await addEndpoint(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99);
|
||||
await addEndpoint(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('seats the terminals in the day and never asks the router for the ride', async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
|
||||
await setVisit(visits[1].id, { assignment_time: '10:00' });
|
||||
// Pinned to the minute the drive reaches it anyway, so the clock alone seats the
|
||||
// flight here. Untimed, Celle lies south of Lueneburg with the airport north of both,
|
||||
// and the flight would go where it adds the least road (#2461, see below).
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '15:30' WHERE id = ?").run(visits[2].id);
|
||||
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
|
||||
const flightId = withFlight(day.day_id, trip.id);
|
||||
await setVisit(visits[2].id, { assignment_time: '15:30' });
|
||||
const day = await dayOf(visits[0].id);
|
||||
const flightId = await withFlight(day.day_id, trip.id);
|
||||
|
||||
const { context, calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
@@ -243,10 +275,10 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
|
||||
it('lets a booking without located terminals fall through, a hire car without a desk among them', async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Cab', 'taxi', ?, '11:00')").run(trip.id, day.day_id);
|
||||
const day = await dayOf(visits[0].id);
|
||||
await addReservation({ trip: trip.id, title: 'Hire car', type: 'car', day: day.day_id });
|
||||
await addReservation({ trip: trip.id, title: 'Somewhere', type: 'train', day: day.day_id, reservation_time: '11:00' });
|
||||
await addReservation({ trip: trip.id, title: 'Cab', type: 'taxi', day: day.day_id, reservation_time: '11:00' });
|
||||
|
||||
const { context, calculated } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
@@ -256,21 +288,14 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
|
||||
it("puts a hire car's desks on the road: the pick-up opens the day, the return closes it, one run through both", async () => {
|
||||
const { user, trip, visits, plans } = await setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '12:00' WHERE id = ?").run(visits[2].id);
|
||||
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
|
||||
VALUES (?, 'Sixt', 'car', ?, ?, '08:00', '18:00')`,
|
||||
)
|
||||
.run(trip.id, day.day_id, day.day_id);
|
||||
const carId = Number(result.lastInsertRowid);
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0);
|
||||
endpoint.run(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99);
|
||||
await setVisit(visits[1].id, { assignment_time: '10:00' });
|
||||
await setVisit(visits[2].id, { assignment_time: '12:00' });
|
||||
const day = await dayOf(visits[0].id);
|
||||
const carId = await addReservation({
|
||||
trip: trip.id, title: 'Sixt', type: 'car', day: day.day_id, endDay: day.day_id, reservation_time: '08:00', reservation_end_time: '18:00',
|
||||
});
|
||||
await addEndpoint(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0);
|
||||
await addEndpoint(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99);
|
||||
const router = hourlyRouter();
|
||||
const service = new RoadtripPlanService(
|
||||
{ getUserSettings: () => ({}) } as never,
|
||||
@@ -324,28 +349,20 @@ describe('a ferry across the day, and one on no day (#2461)', () => {
|
||||
return { user, trip, day, router, plans };
|
||||
}
|
||||
|
||||
function withFerry(tripId: number, dayId: number | null, title: string, located = true): number {
|
||||
const id = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
|
||||
VALUES (?, ?, 'ferry', ?, ?, '09:30', '15:00')`,
|
||||
)
|
||||
.run(tripId, title, dayId, dayId).lastInsertRowid,
|
||||
);
|
||||
async function withFerry(tripId: number, dayId: number | null, title: string, located = true): Promise<number> {
|
||||
const id = await addReservation({
|
||||
trip: tripId, title, type: 'ferry', day: dayId, endDay: dayId, reservation_time: '09:30', reservation_end_time: '15:00',
|
||||
});
|
||||
if (located) {
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879);
|
||||
endpoint.run(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522);
|
||||
await addEndpoint(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879);
|
||||
await addEndpoint(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
it('drives to the pier and on from the far one, and never overland between the two shores', async () => {
|
||||
const { user, trip, day, router, plans } = await crossing();
|
||||
withFerry(trip.id, day.id, 'IJmuiden to Newcastle');
|
||||
await withFerry(trip.id, day.id, 'IJmuiden to Newcastle');
|
||||
|
||||
const { calculated, undatedRides } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
@@ -362,10 +379,10 @@ describe('a ferry across the day, and one on no day (#2461)', () => {
|
||||
|
||||
it('names a located ride on no day, and the tool answers with the same list', async () => {
|
||||
const { user, trip, day, plans } = await crossing();
|
||||
const undated = withFerry(trip.id, null, 'Ferry without a date');
|
||||
withFerry(trip.id, day.id, 'Ferry on the day');
|
||||
withFerry(trip.id, null, 'Ferry without terminals', false);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'restaurant')").run(trip.id);
|
||||
const undated = await withFerry(trip.id, null, 'Ferry without a date');
|
||||
await withFerry(trip.id, day.id, 'Ferry on the day');
|
||||
await withFerry(trip.id, null, 'Ferry without terminals', false);
|
||||
await addReservation({ trip: trip.id, title: 'Dinner', type: 'restaurant' });
|
||||
|
||||
const plan = await plans.calculate(trip.id, user.id);
|
||||
|
||||
@@ -409,17 +426,13 @@ describe('a booked night at both ends of its days', () => {
|
||||
);
|
||||
return { user, trip: created, router, plans };
|
||||
}
|
||||
const visit = (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => {
|
||||
const visit = async (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => {
|
||||
const place = createPlace(db, tripId, { name, ...at });
|
||||
db.prepare('UPDATE places SET duration_minutes = 0 WHERE id = ?').run(place.id);
|
||||
await setPlace(place.id, { duration_minutes: 0 });
|
||||
return { place, assignment: createDayAssignment(db, dayId, place.id) };
|
||||
};
|
||||
const linkBooking = (tripId: number, stayId: number) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO reservations (trip_id, title, type, accommodation_id) VALUES (?, 'Motel', 'hotel', ?)")
|
||||
.run(tripId, stayId).lastInsertRowid,
|
||||
);
|
||||
addReservation({ trip: tripId, title: 'Motel', type: 'hotel', accommodation_id: stayId });
|
||||
const shape = (stops: RoadtripStop[]) =>
|
||||
stops.map((s) => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name));
|
||||
const lats = (router: ReturnType<typeof hourlyRouter>) =>
|
||||
@@ -429,10 +442,10 @@ describe('a booked night at both ends of its days', () => {
|
||||
async function cam(settings?: RoadtripPreferences) {
|
||||
const t = await trip(settings);
|
||||
const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)];
|
||||
const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
|
||||
visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
|
||||
visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 });
|
||||
const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
|
||||
const getaway = await visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
|
||||
await visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
|
||||
await visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 });
|
||||
const wallinga = await visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
|
||||
const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id, {
|
||||
check_in: '14:00',
|
||||
check_out: '10:00',
|
||||
@@ -445,25 +458,24 @@ describe('a booked night at both ends of its days', () => {
|
||||
async function simeon(settings?: RoadtripPreferences) {
|
||||
const t = await trip(settings);
|
||||
const days = [1, 2, 3, 4].map(() => createDay(db, t.trip.id));
|
||||
const h = visit(t.trip.id, days[0].id, 'H', HOTEL);
|
||||
(
|
||||
[
|
||||
[0, 'P1', 45.1],
|
||||
[0, 'P2', 45.2],
|
||||
[1, 'P3', 45.3],
|
||||
[1, 'P4', 45.4],
|
||||
[2, 'P5', 45.5],
|
||||
[2, 'P6', 45.6],
|
||||
] as const
|
||||
).forEach(([day, name, lat]) => visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 }));
|
||||
const h = await visit(t.trip.id, days[0].id, 'H', HOTEL);
|
||||
const stops = [
|
||||
[0, 'P1', 45.1],
|
||||
[0, 'P2', 45.2],
|
||||
[1, 'P3', 45.3],
|
||||
[1, 'P4', 45.4],
|
||||
[2, 'P5', 45.5],
|
||||
[2, 'P6', 45.6],
|
||||
] as const;
|
||||
for (const [day, name, lat] of stops) await visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 });
|
||||
const stay = createDayAccommodation(db, t.trip.id, h.place.id, days[0].id, days[3].id, { check_in: '15:00' });
|
||||
return { ...t, days, stay };
|
||||
}
|
||||
|
||||
it('seats the check-in day back at the stay and starts the transfer day there, as the rule in shared does', async () => {
|
||||
const { user, trip: created, router, plans, days, stayA } = await cam();
|
||||
const reservationId = linkBooking(created.id, stayA.id);
|
||||
linkBooking(created.id, stayA.id);
|
||||
const reservationId = await linkBooking(created.id, stayA.id);
|
||||
await linkBooking(created.id, stayA.id);
|
||||
|
||||
const { calculated, failures } = await plans.calculate(created.id, user.id);
|
||||
|
||||
@@ -536,9 +548,8 @@ describe('a booked night at both ends of its days', () => {
|
||||
|
||||
it('drives out of the morning hotel the way the first place is reached from it, as the browser and the day plan do', async () => {
|
||||
const { user, trip: created, router, plans } = await simeon();
|
||||
db.prepare(
|
||||
"UPDATE day_assignments SET incoming_leg_transport_mode = 'walking' WHERE place_id = (SELECT id FROM places WHERE trip_id = ? AND name = 'P3')",
|
||||
).run(created.id);
|
||||
const p3 = (await findRow(await sharedTestOrm(db), Places, { trip: created.id, name: 'P3' }))!;
|
||||
await updateRows(await sharedTestOrm(db), DayAssignments, { place: p3.id }, { incoming_leg_transport_mode: 'walking' });
|
||||
|
||||
const { calculated } = await plans.calculate(created.id, user.id);
|
||||
|
||||
@@ -554,15 +565,15 @@ describe('a booked night at both ends of its days', () => {
|
||||
it('drops the stop tonight’s booking put first on its check-in day, as the browser does, and keeps it while off (#2546)', async () => {
|
||||
const t = await trip();
|
||||
const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)];
|
||||
const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
|
||||
visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
|
||||
const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
|
||||
visit(t.trip.id, d2.id, 'Cave', { lat: -34.2, lng: 150.8 });
|
||||
visit(t.trip.id, d2.id, 'Bakery', { lat: -34.3, lng: 150.7 });
|
||||
const getaway = await visit(t.trip.id, d1.id, 'Getaway', GETAWAY);
|
||||
await visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 });
|
||||
const wallinga = await visit(t.trip.id, d2.id, 'Wallinga', WALLINGA);
|
||||
await visit(t.trip.id, d2.id, 'Cave', { lat: -34.2, lng: 150.8 });
|
||||
await visit(t.trip.id, d2.id, 'Bakery', { lat: -34.3, lng: 150.7 });
|
||||
const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id);
|
||||
const stayB = createDayAccommodation(db, t.trip.id, wallinga.place.id, d2.id, d3.id);
|
||||
// The Wallinga stop is the one the booking wrote, seated first without a check-in.
|
||||
db.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stayB.id, wallinga.assignment.id);
|
||||
await setVisit(wallinga.assignment.id, { accommodation_id: stayB.id });
|
||||
|
||||
const { calculated } = await t.plans.calculate(t.trip.id, t.user.id);
|
||||
expect(shape(calculated.days[1].stops)).toEqual([`morning:${stayA.id}`, 'Cave', 'Bakery', `evening:${stayB.id}`]);
|
||||
@@ -593,8 +604,8 @@ describe('a booked night at both ends of its days', () => {
|
||||
|
||||
it('reads every stay with its place, its nights and its earliest booking into the context', async () => {
|
||||
const { user, trip: created, plans, days, stayA, stayB } = await cam();
|
||||
const first = linkBooking(created.id, stayA.id);
|
||||
linkBooking(created.id, stayA.id);
|
||||
const first = await linkBooking(created.id, stayA.id);
|
||||
await linkBooking(created.id, stayA.id);
|
||||
|
||||
const context = await plans.context(created.id, user.id);
|
||||
|
||||
@@ -618,7 +629,7 @@ describe('a booked night at both ends of its days', () => {
|
||||
it('names the hotel in the failures of a run it pushes past the router’s waypoint limit', async () => {
|
||||
const t = await trip({});
|
||||
const [d1, d2] = [createDay(db, t.trip.id), createDay(db, t.trip.id)];
|
||||
for (let i = 0; i < 100; i++) visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 });
|
||||
for (let i = 0; i < 100; i++) await visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 });
|
||||
const hotel = createPlace(db, t.trip.id, { name: 'Tonight', ...HOTEL });
|
||||
createDayAccommodation(db, t.trip.id, hotel.id, d1.id, d2.id);
|
||||
|
||||
@@ -643,24 +654,17 @@ describe('a booked night at both ends of its days', () => {
|
||||
async function trip45(settings?: RoadtripPreferences) {
|
||||
const t = await trip(settings);
|
||||
const days = [1, 2, 3, 4, 5].map(() => createDay(db, t.trip.id));
|
||||
const atlantic = visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 });
|
||||
const munich = visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 });
|
||||
const atlantic = await visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 });
|
||||
const munich = await visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 });
|
||||
const stays = { check_in: '15:00', check_out: '11:00' };
|
||||
const hamburgStay = createDayAccommodation(db, t.trip.id, atlantic.place.id, days[0].id, days[2].id, stays);
|
||||
createDayAccommodation(db, t.trip.id, munich.place.id, days[2].id, days[4].id, stays);
|
||||
const flight = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time, day_plan_position)
|
||||
VALUES (?, 'LH 2078 HAM-MUC', 'flight', ?, ?, '2026-11-04T15:15', '2026-11-04T17:20', 0.5)`,
|
||||
)
|
||||
.run(t.trip.id, days[2].id, days[2].id).lastInsertRowid,
|
||||
);
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823);
|
||||
endpoint.run(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861);
|
||||
const flight = await addReservation({
|
||||
trip: t.trip.id, title: 'LH 2078 HAM-MUC', type: 'flight', day: days[2].id, endDay: days[2].id,
|
||||
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', day_plan_position: 0.5,
|
||||
});
|
||||
await addEndpoint(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823);
|
||||
await addEndpoint(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861);
|
||||
return { ...t, flightDay: days[2], hamburgStay };
|
||||
}
|
||||
|
||||
@@ -693,9 +697,7 @@ describe('a booked night at both ends of its days', () => {
|
||||
|
||||
it('drives no road between two stays on a day a flight saved without its airports moves the traveller (#2476)', async () => {
|
||||
const { user, trip: created, router, plans, days, stayA } = await cam();
|
||||
db.prepare(
|
||||
"INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time) VALUES (?, 'Flight', 'flight', ?, ?, '12:00')",
|
||||
).run(created.id, days[1].id, days[1].id);
|
||||
await addReservation({ trip: created.id, title: 'Flight', type: 'flight', day: days[1].id, endDay: days[1].id, reservation_time: '12:00' });
|
||||
|
||||
const { calculated, failures } = await plans.calculate(created.id, user.id);
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ import { createUser } from '../../helpers/factories';
|
||||
import type { TestOrm } from '../../helpers/test-orm';
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo } from '../../helpers/test-uow';
|
||||
import { findRows } from '../../helpers/factories/rows';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { readUserSetting, setAppSetting, setUserSetting } from '../../helpers/factories/settings';
|
||||
|
||||
let svc: SettingsService;
|
||||
let t: TestOrm;
|
||||
@@ -84,16 +87,16 @@ describe('getUserSettings', () => {
|
||||
|
||||
it('SET-SVC-002 — returns stored plain string values', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'theme', 'dark');
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
expect(s.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('SET-SVC-003 — JSON-parses values that are valid JSON', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'count', '42');
|
||||
await setUserSetting(t, user.id, 'flag', 'true');
|
||||
await setUserSetting(t, user.id, 'obj', '{"x":1}');
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
expect(s.count).toBe(42);
|
||||
expect(s.flag).toBe(true);
|
||||
@@ -102,21 +105,21 @@ describe('getUserSettings', () => {
|
||||
|
||||
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'raw', 'not-json');
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
expect(s.raw).toBe('not-json');
|
||||
});
|
||||
|
||||
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'webhook_url', 'https://secret.example.com');
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
expect(s.webhook_url).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('SET-SVC-006 — webhook_url with empty value returns empty string', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'webhook_url', '');
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
expect(s.webhook_url).toBe('');
|
||||
});
|
||||
@@ -124,8 +127,8 @@ describe('getUserSettings', () => {
|
||||
it('SET-SVC-007 — only returns settings for the requesting user', async () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
|
||||
await setUserSetting(t, a.id, 'key_a', '"a"');
|
||||
await setUserSetting(t, b.id, 'key_b', '"b"');
|
||||
const s = await svc.getUserSettings(a.id);
|
||||
expect(s).toHaveProperty('key_a');
|
||||
expect(s).not.toHaveProperty('key_b');
|
||||
@@ -134,46 +137,44 @@ describe('getUserSettings', () => {
|
||||
// Admin "user defaults" fall-through (#1634) — a system-wide Mapbox token must
|
||||
// reach a user who left their own token blank.
|
||||
const setAdminDefault = (settingKey: string, value: string) =>
|
||||
testDb.prepare(
|
||||
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
).run(`default_user_setting_${settingKey}`, value);
|
||||
setAppSetting(t, `default_user_setting_${settingKey}`, value);
|
||||
|
||||
it('SET-SVC-020 — new user with no rows inherits the admin default token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
await setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin');
|
||||
});
|
||||
|
||||
it('SET-SVC-021 — an empty user token falls through to the admin default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', '')").run(user.id);
|
||||
await setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
await setUserSetting(t, user.id, 'mapbox_access_token', '');
|
||||
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin');
|
||||
});
|
||||
|
||||
it('SET-SVC-022 — a non-empty user token overrides the admin default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', 'pk.user')").run(user.id);
|
||||
await setAdminDefault('mapbox_access_token', 'pk.admin');
|
||||
await setUserSetting(t, user.id, 'mapbox_access_token', 'pk.user');
|
||||
expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.user');
|
||||
});
|
||||
|
||||
it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_style', '')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'mapbox_style', '');
|
||||
expect((await svc.getUserSettings(user.id)).mapbox_style).toBe('');
|
||||
});
|
||||
|
||||
it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// 'theme' is not a defaultable key: an empty stored value must be returned as-is.
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', '')").run(user.id);
|
||||
await setUserSetting(t, user.id, 'theme', '');
|
||||
expect((await svc.getUserSettings(user.id)).theme).toBe('');
|
||||
});
|
||||
|
||||
it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||||
await setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||||
// No user row → the value is inherited from the admin default; it is a secret
|
||||
// and must reach the client masked, not in cleartext.
|
||||
expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••');
|
||||
@@ -181,8 +182,8 @@ describe('getUserSettings', () => {
|
||||
|
||||
it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id);
|
||||
await setAdminDefault('llm_api_key', 'sk-admin-secret');
|
||||
await setUserSetting(t, user.id, 'llm_api_key', '');
|
||||
expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
@@ -192,9 +193,7 @@ describe('getUserSettings', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.upsertSetting(user.id, 'carto_api_key', 'carto-user');
|
||||
// Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned.
|
||||
const raw = testDb
|
||||
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'")
|
||||
.get(user.id) as { value: string };
|
||||
const raw = { value: await readUserSetting(t, user.id, 'carto_api_key') };
|
||||
expect(raw.value).toBe('carto-user');
|
||||
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-user');
|
||||
|
||||
@@ -206,15 +205,15 @@ describe('getUserSettings', () => {
|
||||
|
||||
it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('carto_api_key', 'carto-admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id);
|
||||
await setAdminDefault('carto_api_key', 'carto-admin');
|
||||
await setUserSetting(t, user.id, 'carto_api_key', '');
|
||||
expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-admin');
|
||||
});
|
||||
|
||||
it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setAdminDefault('carto_api_key', 'carto-admin');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id);
|
||||
await setAdminDefault('carto_api_key', 'carto-admin');
|
||||
await setUserSetting(t, user.id, 'carto_api_key', 'carto-user');
|
||||
vi.stubEnv('TREK_MANAGED', 'true');
|
||||
vi.stubEnv('CARTO_API_KEY', 'carto-operator');
|
||||
try {
|
||||
@@ -262,14 +261,14 @@ describe('upsertSetting', () => {
|
||||
it('SET-SVC-010 — serializes object values as JSON', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
|
||||
const raw = { value: await readUserSetting(t, user.id, 'prefs') };
|
||||
expect(raw.value).toBe('{"dark":true,"size":14}');
|
||||
});
|
||||
|
||||
it('SET-SVC-011 — serializes boolean values as strings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.upsertSetting(user.id, 'notifications', true);
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
|
||||
const raw = { value: await readUserSetting(t, user.id, 'notifications') };
|
||||
expect(raw.value).toBe('true');
|
||||
});
|
||||
|
||||
@@ -277,7 +276,7 @@ describe('upsertSetting', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
|
||||
// With passthrough mock, value is stored as-is
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
|
||||
const raw = { value: await readUserSetting(t, user.id, 'webhook_url') };
|
||||
expect(raw.value).toBe('https://hook.example.com');
|
||||
// But getUserSettings masks it
|
||||
const s = await svc.getUserSettings(user.id);
|
||||
@@ -321,8 +320,8 @@ describe('bulkUpsertSettings', () => {
|
||||
it('SET-SVC-017 — all changes are committed atomically (transaction)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.bulkUpsertSettings(user.id, { p: '1', q: '2' });
|
||||
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
|
||||
const keys = rows.map((r: any) => r.key);
|
||||
const rows = await findRows(t, Settings, { user: user.id });
|
||||
const keys = rows.map((r) => r.key);
|
||||
expect(keys).toContain('p');
|
||||
expect(keys).toContain('q');
|
||||
});
|
||||
@@ -339,7 +338,7 @@ describe('bulkUpsertSettings', () => {
|
||||
// Was a `vi.spyOn(testDb, 'prepare').mockImplementationOnce(...)` targeting
|
||||
// the legacy raw `INSERT INTO settings ...` statement. Now that
|
||||
// bulkUpsertSettings writes through SettingsRepository.upsertForUser
|
||||
// (em.upsert, via Kysely), the FIRST `.prepare()` call inside
|
||||
// (em.upsert, via Kysely), the FIRST `prepare` call inside
|
||||
// `uow.transactional(...)` is Kysely's own internal machinery, not the
|
||||
// application statement — intercepting it corrupted the connection's
|
||||
// transaction/mutex state for the rest of the file (every test after this
|
||||
@@ -364,7 +363,7 @@ describe('legacy quirk fixes', () => {
|
||||
it('SET-SVC-027 — null serializes as the empty string, not the string "null"', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.upsertSetting(user.id, 'llm_api_key', null);
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'llm_api_key'").get(user.id) as { value: string };
|
||||
const raw = { value: await readUserSetting(t, user.id, 'llm_api_key') };
|
||||
expect(raw.value).toBe('');
|
||||
// The legacy "null" storage leaked back out of getDecryptedUserSetting as
|
||||
// the literal string "null"; a cleared secret must read as null.
|
||||
@@ -400,9 +399,9 @@ describe('legacy quirk fixes', () => {
|
||||
const count = await svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' });
|
||||
expect(count).toBe(1);
|
||||
// Passthrough crypto mock: the stored value must still be the real token.
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_token'").get(user.id) as { value: string };
|
||||
const raw = { value: await readUserSetting(t, user.id, 'ntfy_token') };
|
||||
expect(raw.value).toBe('tok-real');
|
||||
expect(testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_topic'").get(user.id)).toEqual({ value: 'trek' });
|
||||
expect({ value: await readUserSetting(t, user.id, 'ntfy_topic') }).toEqual({ value: 'trek' });
|
||||
});
|
||||
|
||||
it('SET-SVC-030 — bulk returns the count of keys actually written', async () => {
|
||||
|
||||
Reference in New Issue
Block a user