From 869d1394128e6559ea07ad531af2c49aeeab20dd Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 06:11:41 +0200 Subject: [PATCH] refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork --- .../booking-import/booking-import.service.ts | 6 +- server/src/nest/calendar/calendar.service.ts | 8 +- server/src/nest/feeds/feeds.controller.ts | 8 +- server/src/nest/feeds/feeds.service.ts | 8 +- .../integrations/airtrail-import.service.ts | 2 +- .../integrations/airtrail-link.service.ts | 26 +- .../nest/integrations/airtrail-sync.job.ts | 2 +- .../integrations/airtrail-sync.service.ts | 12 +- .../src/nest/integrations/airtrail.service.ts | 18 +- .../dawarich-suggestions.service.ts | 46 +-- .../nest/integrations/dawarich-sync.job.ts | 2 +- .../integrations/dawarich-sync.service.ts | 44 +-- .../integrations/dawarich-tracks.service.ts | 4 +- .../nest/integrations/dawarich.controller.ts | 12 +- server/src/nest/integrations/dawarich.mcp.ts | 10 +- .../src/nest/integrations/dawarich.service.ts | 32 +- .../reservations-read.repository.ts | 10 +- .../reservations/reservations.controller.ts | 22 +- .../src/nest/reservations/reservations.mcp.ts | 36 +- .../src/nest/reservations/reservations.rpc.ts | 14 +- .../nest/reservations/reservations.service.ts | 112 +++--- .../upcoming-reservations.controller.ts | 4 +- .../roadtrip/day-boundaries.controller.ts | 12 +- .../src/nest/roadtrip/day-boundaries.mcp.ts | 8 +- .../nest/roadtrip/day-boundaries.service.ts | 6 +- .../nest/roadtrip/roadtrip-plan.service.ts | 8 +- .../roadtrip-preferences.controller.ts | 8 +- .../nest/roadtrip/roadtrip-preferences.mcp.ts | 4 +- .../roadtrip/roadtrip-preferences.service.ts | 10 +- .../src/nest/roadtrip/roadtrip.controller.ts | 73 ++-- server/src/nest/roadtrip/roadtrip.mcp.ts | 45 +-- server/src/nest/roadtrip/roadtrip.service.ts | 36 +- server/src/nest/transit/transit.mcp.ts | 2 +- .../trip-read-model.service.ts | 4 +- server/src/nest/trips/trips.controller.ts | 4 +- server/src/nest/trips/trips.mcp.ts | 2 +- server/src/nest/trips/trips.rpc.ts | 7 +- server/src/nest/trips/trips.service.ts | 2 +- server/tests/helpers/mcp-test-controllers.ts | 2 +- server/tests/unit/mcp/tools-transit.test.ts | 4 +- .../tests/unit/nest/cache-sweep-jobs.test.ts | 34 +- .../tests/unit/nest/calendar.service.test.ts | 340 +++++++++--------- .../nest/dawarich-suggestions.service.test.ts | 69 ++-- .../tests/unit/nest/dawarich-sync.job.test.ts | 24 +- .../unit/nest/dawarich-sync.service.test.ts | 6 +- .../unit/nest/dawarich.controller.test.ts | 36 +- server/tests/unit/nest/dawarich.mcp.test.ts | 32 +- .../tests/unit/nest/dawarich.service.test.ts | 179 ++++----- server/tests/unit/nest/day-boundaries.test.ts | 36 +- server/tests/unit/nest/feeds.service.test.ts | 70 ++-- .../unit/nest/reservations.controller.test.ts | 12 +- .../unit/nest/reservations.service.test.ts | 230 ++++++------ .../tests/unit/nest/roadtrip-planning.test.ts | 33 +- .../unit/nest/roadtrip.controller.test.ts | 88 ++--- .../tests/unit/nest/roadtrip.service.test.ts | 223 ++++++------ .../tests/unit/nest/trips.controller.test.ts | 16 +- .../unit/services/airtrailWriteback.test.ts | 20 +- 57 files changed, 1073 insertions(+), 1050 deletions(-) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index f8f392f4b..e83e040ea 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -35,7 +35,7 @@ export class BookingImportService { return this.dbs.connection; } - private resolveDayId(tripId: string, iso: string | null | undefined): number | null { + private async resolveDayId(tripId: string, iso: string | null | undefined): Promise { if (!iso) return null; const date = iso.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; @@ -284,8 +284,8 @@ export class BookingImportService { // the accommodation row is actually inserted (createReservation gates on them). let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined; if (item.type === 'hotel' && _accommodation) { - const startDayId = this.resolveDayId(tripId, _accommodation.check_in); - const endDayId = this.resolveDayId(tripId, _accommodation.check_out); + const startDayId = await this.resolveDayId(tripId, _accommodation.check_in); + const endDayId = await this.resolveDayId(tripId, _accommodation.check_out); createAccommodation = { place_id: placeId, start_day_id: startDayId ?? undefined, diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 61037343a..a3071b8b1 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -144,7 +144,7 @@ export class CalendarService { * because a user-supplied SUMMARY can legitimately contain the literal * "END:VEVENT". Handing out the parts removes the need to reassemble them. */ - buildTripCalendar(tripId: string | number): TripCalendar { + async buildTripCalendar(tripId: string | number): Promise { const trip = this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; if (!trip) throw new NotFoundError('Trip not found'); @@ -316,7 +316,7 @@ export class CalendarService { // Transport/flight reservations carry no top-level reservation_time; their // times live per endpoint (local_date + local_time) in reservation_endpoints. - const endpointsMap = this.reservations.loadEndpointsByTrip(tripId); + const endpointsMap = await this.reservations.loadEndpointsByTrip(tripId); const isDate = (s: string | null | undefined) => !!s && /^\d{4}-\d{2}-\d{2}$/.test(s); const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); @@ -746,8 +746,8 @@ export class CalendarService { } /** One trip's calendar as a finished, foldable VCALENDAR document. */ - exportICS(tripId: string | number): { ics: string; filename: string } { - const cal = this.buildTripCalendar(tripId); + async exportICS(tripId: string | number): Promise<{ ics: string; filename: string }> { + const cal = await this.buildTripCalendar(tripId); const ics = CALENDAR_HEADER + `X-WR-CALNAME:${cal.calName}\r\n` + diff --git a/server/src/nest/feeds/feeds.controller.ts b/server/src/nest/feeds/feeds.controller.ts index e3ee458b3..4f9e21962 100644 --- a/server/src/nest/feeds/feeds.controller.ts +++ b/server/src/nest/feeds/feeds.controller.ts @@ -42,8 +42,8 @@ export class FeedsPublicController { constructor(private readonly feeds: FeedsService) {} @Get('trip/:token.ics') - tripFeed(@Param('token') token: string, @Res() res: Response): void { - const result = this.feeds.buildTripIcs(token); + async tripFeed(@Param('token') token: string, @Res() res: Response): Promise { + const result = await this.feeds.buildTripIcs(token); if (!result) { res.status(404).json({ error: 'Feed not found' }); return; @@ -56,8 +56,8 @@ export class FeedsPublicController { } @Get('user/:token.ics') - userFeed(@Param('token') token: string, @Res() res: Response): void { - const result = this.feeds.buildUserIcs(token); + async userFeed(@Param('token') token: string, @Res() res: Response): Promise { + const result = await this.feeds.buildUserIcs(token); if (!result) { res.status(404).json({ error: 'Feed not found' }); return; diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index 9c62a9b90..7596f3df3 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -108,13 +108,13 @@ export class FeedsService { // ── ICS generation ─────────────────────────────────────────────────────── - buildTripIcs(token: string): { ics: string; filename: string } | null { + async buildTripIcs(token: string): Promise<{ ics: string; filename: string } | null> { const row = this.db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as | { id: number } | undefined; if (!row) return null; try { - const cal = this.calendar.buildTripCalendar(row.id); + const cal = await this.calendar.buildTripCalendar(row.id); // Same document as the one-time download, plus the subscription refresh // hints so clients re-fetch hourly. Assembled from the calendar's parts // rather than string-surgeried into the finished text. @@ -132,7 +132,7 @@ export class FeedsService { } } - buildUserIcs(token: string): { ics: string; calName: string } | null { + async buildUserIcs(token: string): Promise<{ ics: string; calName: string } | null> { const user = this.db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as | { id: number; username: string } | undefined; @@ -168,7 +168,7 @@ export class FeedsService { let events = ''; for (const { id } of trips) { try { - const cal = this.calendar.buildTripCalendar(id); + const cal = await this.calendar.buildTripCalendar(id); for (const [tzid, block] of cal.timezones) { if (!zones.has(tzid)) zones.set(tzid, block); } diff --git a/server/src/nest/integrations/airtrail-import.service.ts b/server/src/nest/integrations/airtrail-import.service.ts index 86ce06eae..9124ed9b6 100644 --- a/server/src/nest/integrations/airtrail-import.service.ts +++ b/server/src/nest/integrations/airtrail-import.service.ts @@ -122,7 +122,7 @@ export class AirtrailImportService { socketId: string | undefined, connections: string[][] = [], ): Promise { - const creds = this.airtrail.getAirtrailCredentials(userId); + const creds = await this.airtrail.getAirtrailCredentials(userId); if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400); const wanted = new Set(flightIds.map(String)); diff --git a/server/src/nest/integrations/airtrail-link.service.ts b/server/src/nest/integrations/airtrail-link.service.ts index e5164edfc..5a483039e 100644 --- a/server/src/nest/integrations/airtrail-link.service.ts +++ b/server/src/nest/integrations/airtrail-link.service.ts @@ -40,18 +40,18 @@ export class AirtrailLinkService { return row?.value !== 'false'; } - broadcastUpdated(tripId: number, reservationId: number): void { + async broadcastUpdated(tripId: number, reservationId: number): Promise { try { - const reservation = this.reads.getReservationWithJoins(reservationId); + const reservation = await this.reads.getReservationWithJoins(reservationId); if (reservation) this.realtime.broadcast(String(tripId), 'reservation:updated', { reservation } as never, undefined); } catch { /* broadcast failure is non-fatal */ } } - detach(tripId: number, reservationId: number): void { + async detach(tripId: number, reservationId: number): Promise { this.db.run('UPDATE reservations SET sync_enabled = 0 WHERE id = ?', reservationId); - this.broadcastUpdated(tripId, reservationId); + await this.broadcastUpdated(tripId, reservationId); } /** @@ -61,7 +61,7 @@ export class AirtrailLinkService { * side: a pull flattens the layover chain back to from→to, a push rewrites the * AirTrail flight to span the whole route (#1535). */ - hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): boolean { + async hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): Promise { try { const meta = metadataJson ? JSON.parse(metadataJson) : {}; if (Array.isArray(meta?.legs) && meta.legs.length > 1) return true; @@ -96,21 +96,21 @@ export class AirtrailLinkService { // 1:1 mapping to the AirTrail flight: pushing would rewrite that flight to the // full span, and the next pull would flatten the layover again. Detach — the // merge is a deliberate local restructuring, like a joined import (#1535). - const reservation = this.reads.getReservationWithJoins(row.id); + const reservation = await this.reads.getReservationWithJoins(row.id); if (!reservation) return; - if (this.hasLocalMultiLegShape(row.id, (reservation as { metadata?: string | null }).metadata)) { - this.detach(tripId, row.id); + if (await this.hasLocalMultiLegShape(row.id, (reservation as { metadata?: string | null }).metadata)) { + await this.detach(tripId, row.id); return; } // AirTrail is read-only by default (#1240). Only push when the flight's owner has // explicitly opted in. A no-op skip (not a detach): the link stays active so the // inbound, AirTrail-wins pull keeps the reservation up to date. - if (!row.external_owner_user_id || !this.airtrail.isAirtrailWriteEnabled(row.external_owner_user_id)) return; + if (!row.external_owner_user_id || !(await this.airtrail.isAirtrailWriteEnabled(row.external_owner_user_id))) return; - const creds: AirtrailCreds | null = this.airtrail.getAirtrailCredentials(row.external_owner_user_id); + const creds: AirtrailCreds | null = await this.airtrail.getAirtrailCredentials(row.external_owner_user_id); if (!creds) { - this.detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing + await this.detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing return; } @@ -118,12 +118,12 @@ export class AirtrailLinkService { try { existing = await this.client.getFlight(creds, Number(row.external_id)); } catch (err) { - if (err instanceof AirtrailAuthError) this.detach(tripId, row.id); + if (err instanceof AirtrailAuthError) await this.detach(tripId, row.id); else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`); return; } if (!existing) { - this.detach(tripId, row.id); // gone in AirTrail → treat like a remote delete + await this.detach(tripId, row.id); // gone in AirTrail → treat like a remote delete return; } diff --git a/server/src/nest/integrations/airtrail-sync.job.ts b/server/src/nest/integrations/airtrail-sync.job.ts index 5e76527b7..915a2dded 100644 --- a/server/src/nest/integrations/airtrail-sync.job.ts +++ b/server/src/nest/integrations/airtrail-sync.job.ts @@ -19,7 +19,7 @@ export class AirtrailSyncJob implements OnApplicationBootstrap { private readonly registrar: CronRegistrarService, ) {} - onApplicationBootstrap(): void { + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; const value = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'airtrail_poll_interval_minutes')?.value; const raw = Number.parseInt(value || '5', 10); diff --git a/server/src/nest/integrations/airtrail-sync.service.ts b/server/src/nest/integrations/airtrail-sync.service.ts index 6b897aaf2..4370e8232 100644 --- a/server/src/nest/integrations/airtrail-sync.service.ts +++ b/server/src/nest/integrations/airtrail-sync.service.ts @@ -52,7 +52,7 @@ export class AirtrailSyncService { * flights are never auto-added to a trip. Returns how many rows changed. */ private async syncOwner(uid: number): Promise { - const creds = this.airtrail.getAirtrailCredentials(uid); + const creds = await this.airtrail.getAirtrailCredentials(uid); if (!creds) return 0; // owner disconnected — leave their linked rows as-is let flights: AirtrailFlightRaw[]; @@ -73,7 +73,7 @@ export class AirtrailSyncService { for (const row of linked) { const flight = byId.get(String(row.external_id)); if (!flight) { - this.link.detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing + await this.link.detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing changed++; continue; } @@ -81,12 +81,12 @@ export class AirtrailSyncService { const hash = canonicalHash(flight); if (hash === row.external_hash) continue; - const current = this.reservations.getReservation(row.id, row.trip_id); + const current = await this.reservations.getReservation(row.id, row.trip_id); if (!current) continue; - if (this.link.hasLocalMultiLegShape(row.id, (current as any).metadata)) { + if (await this.link.hasLocalMultiLegShape(row.id, (current as any).metadata)) { // The user connected this flight into a multi-leg booking; applying the // remote single-flight shape would flatten it. Stop syncing instead. - this.link.detach(row.trip_id, row.id); + await this.link.detach(row.trip_id, row.id); changed++; continue; } @@ -98,7 +98,7 @@ export class AirtrailSyncService { new Date().toISOString(), row.id, ); - this.link.broadcastUpdated(row.trip_id, row.id); + await this.link.broadcastUpdated(row.trip_id, row.id); changed++; } catch (err) { logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`); diff --git a/server/src/nest/integrations/airtrail.service.ts b/server/src/nest/integrations/airtrail.service.ts index 5c2a43824..80a710547 100644 --- a/server/src/nest/integrations/airtrail.service.ts +++ b/server/src/nest/integrations/airtrail.service.ts @@ -38,7 +38,7 @@ export class AirtrailService { private readonly client: AirtrailClient, ) {} - private readRow(userId: number): UserConnRow | undefined { + private async readRow(userId: number): Promise { return this.db.get( 'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?', userId, @@ -46,7 +46,7 @@ export class AirtrailService { } /** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */ - isAirtrailWriteEnabled(userId: number): boolean { + async isAirtrailWriteEnabled(userId: number): Promise { const row = this.db.get<{ airtrail_write_enabled?: number | null }>( 'SELECT airtrail_write_enabled FROM users WHERE id = ?', userId, @@ -55,8 +55,8 @@ export class AirtrailService { } /** Decrypted creds for outbound calls, or null when the user has no connection. */ - getAirtrailCredentials(userId: number): AirtrailCreds | null { - const row = this.readRow(userId); + async getAirtrailCredentials(userId: number): Promise { + const row = await this.readRow(userId); if (!row?.airtrail_url || !row?.airtrail_api_key) return null; const apiKey = decrypt_api_key(row.airtrail_api_key); if (!apiKey) return null; @@ -68,8 +68,8 @@ export class AirtrailService { } /** Settings as shown in the UI — the key is never echoed, only masked. */ - getConnectionSettings(userId: number) { - const row = this.readRow(userId); + async getConnectionSettings(userId: number) { + const row = await this.readRow(userId); return { url: row?.airtrail_url || '', apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '', @@ -148,7 +148,7 @@ export class AirtrailService { async getConnectionStatus( userId: number, ): Promise<{ connected: boolean; flightCount?: number; error?: string }> { - const creds = this.getAirtrailCredentials(userId); + const creds = await this.getAirtrailCredentials(userId); if (!creds) return { connected: false, error: 'Not configured' }; return this.probe(creds); } @@ -173,7 +173,7 @@ export class AirtrailService { const trimmedUrl = (url || '').trim(); const provided = (apiKey || '').trim(); - const stored = this.getAirtrailCredentials(userId); + const stored = await this.getAirtrailCredentials(userId); const effectiveUrl = trimmedUrl || stored?.baseUrl; const typedKey = provided && provided !== KEY_MASK ? provided : ''; @@ -199,7 +199,7 @@ export class AirtrailService { /** The user's AirTrail flights, normalized for the import picker. */ async getFlightsForPicker(userId: number): Promise { - const creds = this.getAirtrailCredentials(userId); + const creds = await this.getAirtrailCredentials(userId); if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400); const raw = await this.client.listFlights(creds); return raw.map(normalizeFlight); diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index e45286778..8b939a5b3 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -64,8 +64,8 @@ export class DawarichSuggestionsService { * someone's location history, and a forgotten filter over a wider read is how * that leaks. */ - list(userId: number, filter: { tripId?: number; state?: string }): DawarichSuggestionList { - this.reopenOrphaned(userId); + async list(userId: number, filter: { tripId?: number; state?: string }): Promise { + await this.reopenOrphaned(userId); const clauses = ['s.user_id = ?']; const params: unknown[] = [userId]; @@ -88,7 +88,7 @@ export class DawarichSuggestionsService { ...params, ); - const connection = this.dawarich.getConnection(userId); + const connection = await this.dawarich.getConnection(userId); return { suggestions: rows.map(toWire), connected: connection.connected, @@ -115,7 +115,7 @@ export class DawarichSuggestionsService { * at read time, which would make the list say "back in review" while `accept` * still refused the row as already accepted. */ - private reopenOrphaned(userId: number): void { + private async reopenOrphaned(userId: number): Promise { this.db.run( `UPDATE dawarich_visit_suggestions SET state = 'new', target = NULL, accepted_hash = NULL, @@ -136,7 +136,7 @@ export class DawarichSuggestionsService { ); } - getOne(userId: number, id: number): DawarichSuggestion | null { + async getOne(userId: number, id: number): Promise { const row = this.db.get( `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name FROM dawarich_visit_suggestions s @@ -157,7 +157,7 @@ export class DawarichSuggestionsService { * not offered — the place or entry it produced is a separate thing now, and * "undo" would have to guess whether to delete it. */ - setState(userId: number, id: number, state: 'new' | 'dismissed'): DawarichSuggestion | null { + async setState(userId: number, id: number, state: 'new' | 'dismissed'): Promise { const row = this.db.get<{ id: number; state: string }>( 'SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?', id, @@ -256,7 +256,7 @@ export class DawarichSuggestionsService { this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id); const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined; - this.markAccepted(row.id, 'place', { placeId: id }); + await this.markAccepted(row.id, 'place', { placeId: id }); // Re-read, so what goes out on the wire carries the source: everyone else // on the trip should see the Dawarich mark on it too, not only the person // who accepted it. @@ -277,7 +277,7 @@ export class DawarichSuggestionsService { } return { - suggestion: this.getOne(userId, row.id)!, + suggestion: (await this.getOne(userId, row.id))!, createdPlaceId: placeId, createdJournalEntryId: null, bucketListItemId: null, @@ -331,11 +331,11 @@ export class DawarichSuggestionsService { sid, ); if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404); - this.markAccepted(row.id, 'journal', { journalEntryId: created.id }); + await this.markAccepted(row.id, 'journal', { journalEntryId: created.id }); return created; }); return { - suggestion: this.getOne(userId, row.id)!, + suggestion: (await this.getOne(userId, row.id))!, createdPlaceId: null, createdJournalEntryId: entry.id, bucketListItemId: null, @@ -343,7 +343,7 @@ export class DawarichSuggestionsService { } /** A stay ticks off a wish. The wish must be the caller's own. */ - private acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): DawarichAcceptResult { + private async acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): Promise { const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id; if (!itemId) { throw new AcceptError('bucket_required', 'A bucket-list entry is required', 400); @@ -355,18 +355,18 @@ export class DawarichSuggestionsService { ); if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404); - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run( "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?", row.started_at, itemId, userId, ); - this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId }); + await this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId }); }); return { - suggestion: this.getOne(userId, row.id)!, + suggestion: (await this.getOne(userId, row.id))!, createdPlaceId: null, createdJournalEntryId: null, bucketListItemId: itemId, @@ -400,11 +400,11 @@ export class DawarichSuggestionsService { * answerable at all: without it there is nothing to compare the current hash * against, and the flag would either never fire or fire forever. */ - private markAccepted( + private async markAccepted( id: number, target: 'place' | 'journal' | 'bucket_list', ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number }, - ): void { + ): Promise { this.db.run( `UPDATE dawarich_visit_suggestions SET state = 'accepted', target = ?, accepted_place_id = ?, @@ -435,7 +435,7 @@ export class DawarichSuggestionsService { * eighty wishes must not be told their other thirty had no match. */ async scanBucketList(userId: number): Promise { - const creds = this.dawarich.getCredentials(userId); + const creds = await this.dawarich.getCredentials(userId); if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400); const items = this.db.all( @@ -515,17 +515,17 @@ export class DawarichSuggestionsService { } /** Tick off wishes the user confirmed after reading a scan. */ - confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): number { + async confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): Promise { const now = new Date().toISOString(); let updated = 0; - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const itemId of itemIds) { // The date is the one the wish was actually reached on. A wish somebody // got to in 2023 ticked off with today's date is a wrong entry in a list // people keep for years, so "now" is only the answer when nothing else // knows better: the caller's own value first, then the stay this wish // was matched to, then the clock. - const stamp = visitedAt ?? this.matchedStayStart(userId, itemId) ?? now; + const stamp = visitedAt ?? (await this.matchedStayStart(userId, itemId)) ?? now; const result = this.db.run( "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at IS NULL", stamp, @@ -539,7 +539,7 @@ export class DawarichSuggestionsService { } /** When the stay that claimed this wish began, if one did. */ - private matchedStayStart(userId: number, itemId: number): string | null { + private async matchedStayStart(userId: number, itemId: number): Promise { const row = this.db.get<{ started_at: string }>( `SELECT started_at FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? @@ -551,7 +551,7 @@ export class DawarichSuggestionsService { } /** Undo a tick. Clears the source with it, so the entry reads as untouched again. */ - clearBucketVisit(userId: number, itemId: number): boolean { + async clearBucketVisit(userId: number, itemId: number): Promise { const result = this.db.run( 'UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?', itemId, @@ -577,7 +577,7 @@ export class DawarichSuggestionsService { * country the user went to, and silently losing it would be the worse bug. */ async atlasSuggestions(userId: number, from: Date, to: Date): Promise { - const creds = this.dawarich.getCredentials(userId); + const creds = await this.dawarich.getCredentials(userId); if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400); const countries = await this.client.listVisitedCities(creds, from, to); diff --git a/server/src/nest/integrations/dawarich-sync.job.ts b/server/src/nest/integrations/dawarich-sync.job.ts index 779ff7657..500d2882f 100644 --- a/server/src/nest/integrations/dawarich-sync.job.ts +++ b/server/src/nest/integrations/dawarich-sync.job.ts @@ -25,7 +25,7 @@ export class DawarichSyncJob implements OnApplicationBootstrap { private readonly registrar: CronRegistrarService, ) {} - onApplicationBootstrap(): void { + async onApplicationBootstrap(): Promise { if (!this.registrar.isEnabled()) return; const value = this.db.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', diff --git a/server/src/nest/integrations/dawarich-sync.service.ts b/server/src/nest/integrations/dawarich-sync.service.ts index f8b1d0cdc..66cde7f82 100644 --- a/server/src/nest/integrations/dawarich-sync.service.ts +++ b/server/src/nest/integrations/dawarich-sync.service.ts @@ -8,6 +8,7 @@ import { } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { AddonsService } from '../addons/addons.service'; import { logError, logInfo } from '../audit/audit-log.logger'; import { getCountryFromCoords } from '../atlas/atlas-geo'; @@ -70,6 +71,7 @@ export class DawarichSyncService { private readonly addons: AddonsService, private readonly client: DawarichClient, private readonly dawarich: DawarichService, + private readonly uow: UnitOfWork, ) {} /** The addon gate, evaluated per tick so an admin toggle lands without a restart. */ @@ -83,7 +85,7 @@ export class DawarichSyncService { if (this.running) return; this.running = true; try { - const userIds = this.dawarich.listSyncableUserIds(); + const userIds = await this.dawarich.listSyncableUserIds(); for (const userId of userIds) { try { await this.syncUser(userId); @@ -133,21 +135,21 @@ export class DawarichSyncService { /** The body of a sync, with the guard above already held. */ private async syncUserOnce(userId: number): Promise { if (!(await this.syncGloballyEnabled())) { - this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled'); + await this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled'); return { state: 'failed', created: 0, updated: 0, missing: 0 }; } - const creds = this.dawarich.getCredentials(userId); + const creds = await this.dawarich.getCredentials(userId); if (!creds) { - this.dawarich.recordSyncResult(userId, 'failed', 'not_connected'); + await this.dawarich.recordSyncResult(userId, 'failed', 'not_connected'); return { state: 'failed', created: 0, updated: 0, missing: 0 }; } - const trips = this.listTripsToSync(userId); + const trips = await this.listTripsToSync(userId); if (trips.length === 0) { // Nothing to ask about is a successful sync, not a failure — otherwise a // user with no dated trips sees a permanent red badge for doing nothing wrong. - this.dawarich.recordSyncResult(userId, 'ok', null); + await this.dawarich.recordSyncResult(userId, 'ok', null); return { state: 'ok', created: 0, updated: 0, missing: 0 }; } @@ -181,14 +183,14 @@ export class DawarichSyncService { const state: DawarichSyncState = failures === 0 ? 'ok' : failures === trips.length ? 'failed' : 'partial'; - this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError); + await this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError); // The probe is cheap next to the windows just fetched, and re-running it is // how a Dawarich upgrade that finally ships `updated_at` starts being used // without anyone reconnecting. if (state !== 'failed') { try { - this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds)); + await this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds)); } catch { // Capabilities are an optimisation; a failed probe is not a failed sync. } @@ -209,7 +211,7 @@ export class DawarichSyncService { * generous rather than tight, because someone who connects Dawarich for the * first time wants their last holiday filled in, not just today's. */ - private listTripsToSync(userId: number): TripRow[] { + private async listTripsToSync(userId: number): Promise { return this.db.all( `SELECT DISTINCT t.id, t.start_date, t.end_date FROM trips t @@ -301,7 +303,7 @@ export class DawarichSyncService { new Date().toISOString(), ); created++; - this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); + await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); continue; } @@ -336,7 +338,7 @@ export class DawarichSyncService { existing.id, ); if (changed) updated++; - this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); + await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); continue; } @@ -355,7 +357,7 @@ export class DawarichSyncService { if (changed) updated++; } - const missing = this.flagMissing(userId, tripId, from, to, seenIds); + const missing = await this.flagMissing(userId, tripId, from, to, seenIds); return { created, updated, missing }; } @@ -368,13 +370,13 @@ export class DawarichSyncService { * say "this is gone in Dawarich" next to an entry they wrote. Deleting that * would delete their work. */ - private flagMissing( + private async flagMissing( userId: number, tripId: number, from: Date, to: Date, seenIds: Set, - ): number { + ): Promise { // Bounded on `local_date`, not on `started_at`. Dawarich renders a visit's // start as local wall-clock plus an offset (`2026-09-01T14:23:00+02:00`) // while the window is UTC (`…Z`), and comparing those two shapes as strings @@ -398,7 +400,7 @@ export class DawarichSyncService { if (gone.length === 0) return 0; const stamp = new Date().toISOString(); - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const row of gone) { if (row.state === 'new') { this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id); @@ -423,13 +425,13 @@ export class DawarichSyncService { * from going past it. The link is only a hint; ticking the wish off still * needs the user. */ - private matchBucketList( + private async matchBucketList( userId: number, sourceVisitId: string, lat: number | null, lng: number | null, durationMinutes: number, - ): void { + ): Promise { if (lat === null || lng === null) return; if (durationMinutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) return; @@ -456,7 +458,7 @@ export class DawarichSyncService { } if (!best) return; - this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes); + await this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes); } /** @@ -473,14 +475,14 @@ export class DawarichSyncService { * Every previous claim on the same wish is cleared, so re-running a sync after * the recordings change cannot leave two stays holding it. */ - private claimWish( + private async claimWish( userId: number, sourceVisitId: string, wish: { id: number; lat: number; lng: number; distance: number }, lat: number, lng: number, durationMinutes: number, - ): void { + ): Promise { const holders = this.db.all<{ id: number; source_visit_id: string; @@ -505,7 +507,7 @@ export class DawarichSyncService { if (holderWins) return; } - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run( 'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?', userId, diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts index 386ee0f63..4543986d8 100644 --- a/server/src/nest/integrations/dawarich-tracks.service.ts +++ b/server/src/nest/integrations/dawarich-tracks.service.ts @@ -91,7 +91,7 @@ export class DawarichTracksService { toIso: string, offsetMinutes = offsetMinutesOf(fromIso), ): Promise { - const creds = this.dawarich.getCredentials(userId); + const creds = await this.dawarich.getCredentials(userId); if (!creds) return emptyTrack(); const from = new Date(fromIso); @@ -129,7 +129,7 @@ export class DawarichTracksService { to: Date, offsetMinutes: number, ): Promise { - const capabilities = this.dawarich.getCapabilities(userId); + const capabilities = await this.dawarich.getCapabilities(userId); if (capabilities?.tracks !== false) { try { diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts index 86b30b07e..d362d5497 100644 --- a/server/src/nest/integrations/dawarich.controller.ts +++ b/server/src/nest/integrations/dawarich.controller.ts @@ -148,12 +148,12 @@ export class DawarichController { @Put('suggestions/:id/state') @HttpCode(200) - setSuggestionState( + async setSuggestionState( @CurrentUser() user: User, @Param('id') id: string, @Body() body: DawarichSuggestionStateDto, ) { - const updated = this.suggestions.setState(user.id, parseId(id), body.state); + const updated = await this.suggestions.setState(user.id, parseId(id), body.state); if (!updated) throw new HttpException({ error: 'Suggestion not found' }, 404); return updated; } @@ -169,14 +169,14 @@ export class DawarichController { @Post('bucket-list/confirm') @HttpCode(200) - confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) { - return { updated: this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) }; + async confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) { + return { updated: await this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) }; } @Delete('bucket-list/:itemId/visit') @HttpCode(200) - clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) { - const cleared = this.suggestions.clearBucketVisit(user.id, parseId(itemId)); + async clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) { + const cleared = await this.suggestions.clearBucketVisit(user.id, parseId(itemId)); if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404); return { success: true }; } diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts index a4b5faaa5..7720898bb 100644 --- a/server/src/nest/integrations/dawarich.mcp.ts +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -70,11 +70,11 @@ export class DawarichMcp { access: { group: 'journey', mode: 'read' }, when: dawarichAddonOn, }) - listSuggestions( + async listSuggestions( { tripId, state, limit }: { tripId?: number; state?: 'new' | 'accepted' | 'dismissed'; limit?: number }, ctx: McpContext, ) { - const all = this.suggestions.list(ctx.userId, { tripId, state }); + const all = await this.suggestions.list(ctx.userId, { tripId, state }); const cap = limit ?? DEFAULT_LIMIT; const page = all.suggestions.slice(0, cap); return ok({ @@ -178,7 +178,7 @@ export class DawarichMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed'); + const updated = await this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed'); if (!updated) return errorResult('Suggestion not found'); return ok({ suggestion: updated }); } @@ -229,9 +229,9 @@ export class DawarichMcp { } /** The MCP echo of the controller's error shaping: a refusal, not an exception. */ - private run(action: () => T) { + private async run(action: () => Promise) { try { - return ok(action() as object); + return ok((await action()) as object); } catch (err) { if (err instanceof AcceptError) return errorResult(err.message); throw err; diff --git a/server/src/nest/integrations/dawarich.service.ts b/server/src/nest/integrations/dawarich.service.ts index 43f559fd3..422556e6a 100644 --- a/server/src/nest/integrations/dawarich.service.ts +++ b/server/src/nest/integrations/dawarich.service.ts @@ -8,6 +8,7 @@ import { } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; import { AuditService } from '../audit/audit.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { checkSsrf } from '../../utils/ssrfGuard'; import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client'; @@ -33,9 +34,10 @@ export class DawarichService { private readonly db: DatabaseService, private readonly audit: AuditService, private readonly client: DawarichClient, + private readonly uow: UnitOfWork, ) {} - private readRow(userId: number): ConnRow | undefined { + private async readRow(userId: number): Promise { return this.db.get( `SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled, last_sync_at, last_sync_state, last_sync_error, capabilities @@ -45,8 +47,8 @@ export class DawarichService { } /** Decrypted credentials for an outbound call, or null when nothing is connected. */ - getCredentials(userId: number): DawarichCreds | null { - const row = this.readRow(userId); + async getCredentials(userId: number): Promise { + const row = await this.readRow(userId); if (!row?.url || !row?.api_key) return null; const apiKey = decrypt_api_key(row.api_key); if (!apiKey) return null; @@ -54,13 +56,13 @@ export class DawarichService { } /** True when the user has both an address and a key on file. */ - isConnected(userId: number): boolean { - const row = this.readRow(userId); + async isConnected(userId: number): Promise { + const row = await this.readRow(userId); return !!(row?.url && row?.api_key); } /** Every user whose connection is usable and whose background sync is on. */ - listSyncableUserIds(): number[] { + async listSyncableUserIds(): Promise { const rows = this.db.all<{ user_id: number }>( `SELECT user_id FROM dawarich_connections WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL`, @@ -69,8 +71,8 @@ export class DawarichService { } /** The connection as the settings card shows it. The key is a mask, always. */ - getConnection(userId: number): DawarichConnection { - const row = this.readRow(userId); + async getConnection(userId: number): Promise { + const row = await this.readRow(userId); return { url: row?.url || '', apiKeyMasked: row?.api_key ? DAWARICH_KEY_MASK : '', @@ -86,8 +88,8 @@ export class DawarichService { }; } - getCapabilities(userId: number): DawarichCapabilities | null { - return parseCapabilities(this.readRow(userId)?.capabilities); + async getCapabilities(userId: number): Promise { + return parseCapabilities((await this.readRow(userId))?.capabilities); } /** @@ -145,7 +147,7 @@ export class DawarichService { // All three writes or none: a row that took the new address but not the new // key would point a credential minted for one instance at another one, and // the next sync would send it there. - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run( `INSERT INTO dawarich_connections (user_id, url, allow_insecure_tls, sync_enabled, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) @@ -236,7 +238,7 @@ export class DawarichService { allowInsecureTls: boolean, ): Promise { const typedKey = (apiKey || '').trim(); - const stored = this.getCredentials(userId); + const stored = await this.getCredentials(userId); const baseUrl = (url || '').trim() || stored?.baseUrl || ''; const typed = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : ''; @@ -258,7 +260,7 @@ export class DawarichService { const capabilities = await this.probeCapabilities(creds); // Only persist against a connection that is actually stored — a test run // from a half-filled form must not overwrite what is on file. - if (stored && baseUrl === stored.baseUrl) this.storeCapabilities(userId, capabilities); + if (stored && baseUrl === stored.baseUrl) await this.storeCapabilities(userId, capabilities); return { connected: true, visitCount: capabilities.visits ? await this.countRecentVisits(creds) : 0, @@ -335,7 +337,7 @@ export class DawarichService { } } - storeCapabilities(userId: number, capabilities: DawarichCapabilities): void { + async storeCapabilities(userId: number, capabilities: DawarichCapabilities): Promise { this.db.run( 'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?', JSON.stringify(capabilities), @@ -343,7 +345,7 @@ export class DawarichService { ); } - recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): void { + async recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): Promise { this.db.run( `UPDATE dawarich_connections SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = CURRENT_TIMESTAMP diff --git a/server/src/nest/reservations/reservations-read.repository.ts b/server/src/nest/reservations/reservations-read.repository.ts index 8c5174a67..50e6dab53 100644 --- a/server/src/nest/reservations/reservations-read.repository.ts +++ b/server/src/nest/reservations/reservations-read.repository.ts @@ -22,7 +22,7 @@ export function toTraveler(r: { user_id: number; username: string; avatar: strin export class ReservationsReadRepository { constructor(private readonly db: DatabaseService) {} - getReservationWithJoins(id: string | number): ReservationRow | undefined { + async getReservationWithJoins(id: string | number): Promise { const row = this.db.get(` SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, ap.place_id as accommodation_place_id, acc_p.name as accommodation_name, @@ -35,22 +35,22 @@ export class ReservationsReadRepository { WHERE r.id = ? `, id); if (!row) return undefined; - row.endpoints = this.loadEndpoints(row.id); - row.travelers = this.loadTravelers(row.id); + row.endpoints = await this.loadEndpoints(row.id); + row.travelers = await this.loadTravelers(row.id); // accommodation_id is a TEXT column; the integer FK reads back as a numeric // string (e.g. "14.0"). Normalize to an int so clients can parse it. row.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id)); return row; } - loadEndpoints(reservationId: number): ReservationEndpoint[] { + async loadEndpoints(reservationId: number): Promise { return this.db.all( 'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence', reservationId ); } - loadTravelers(reservationId: number | string): ReservationTraveler[] { + async loadTravelers(reservationId: number | string): Promise { const rows = this.db.all(` SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest FROM reservation_travelers rt diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index 4e873ee37..9d14ddbbe 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -55,8 +55,8 @@ export class ReservationsController { @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { reservations: this.reservations.list(tripId) }; + async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { reservations: await this.reservations.list(tripId) }; } @RequirePermission('reservation_edit') @@ -75,13 +75,13 @@ export class ReservationsController { } await this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId); this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId); - this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? ''); + await this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? ''); return { reservation }; } @RequirePermission('reservation_edit') @Put('positions') - updatePositions( + async updatePositions( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: ReservationPositionsDto, @@ -89,7 +89,7 @@ export class ReservationsController { ) { // The legacy signature declares day_plan_position required, but the wire // contract tolerates absent values (bind NULL) — see the shared schema. - this.reservations.updatePositions(tripId, body.positions as { id: number; day_plan_position: number }[], body.day_id); + await this.reservations.updatePositions(tripId, body.positions as { id: number; day_plan_position: number }[], body.day_id); this.reservations.broadcast(tripId, 'reservation:positions', { positions: body.positions, day_id: body.day_id }, socketId); return { success: true }; } @@ -104,7 +104,7 @@ export class ReservationsController { @Headers('x-socket-id') socketId?: string, ) { const body = rawBody as ReservationBody; - const current = this.reservations.getReservation(id, tripId); + const current = await this.reservations.getReservation(id, tripId); if (!current) { throw new HttpException({ error: 'Reservation not found' }, 404); } @@ -121,20 +121,20 @@ export class ReservationsController { if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) { void this.airtrailLink.pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {}); } - this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || ''); + await this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || ''); return { reservation }; } @RequirePermission('reservation_edit') @Put(':id/travelers') - updateTravelers( + async updateTravelers( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: ReservationTravelersDto, @Headers('x-socket-id') socketId?: string, ) { - const result = this.reservations.setTravelers(id, tripId, body.user_ids); + const result = await this.reservations.setTravelers(id, tripId, body.user_ids); if (!result) { throw new HttpException({ error: 'Reservation not found' }, 404); } @@ -161,7 +161,7 @@ export class ReservationsController { this.reservations.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, socketId); } this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId); - this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || ''); + await this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || ''); return { success: true }; } @@ -181,7 +181,7 @@ export class ReservationsController { * the wrong place. */ private async rejectForeignReferences(tripId: string, body: ReservationBody): Promise { - const offenders = this.reservations.referencesOutsideTrip(tripId, body as never); + const offenders = await this.reservations.referencesOutsideTrip(tripId, body as never); if (offenders.length > 0) { throw new HttpException({ error: `Not part of this trip: ${offenders.join(', ')}` }, 400); } diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 94ba40441..71dfe9fed 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -362,7 +362,7 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // Validate that all referenced IDs belong to this trip @@ -438,9 +438,9 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); - const existing = this.reservations.getReservation(reservationId, tripId); + const existing = await this.reservations.getReservation(reservationId, tripId); if (!existing) return errorResult('Reservation not found.'); if (place_id != null && !(await this.assignments.placeExists(place_id, tripId))) @@ -469,7 +469,7 @@ export class ReservationsMcp { }) async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const { deleted, accommodationDeleted } = await this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Reservation not found.'); @@ -496,12 +496,12 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // The service filters the ids against the trip roster on its own, so an // off-trip id cannot be attached; a missing booking is the only failure. - const result = this.reservations.setTravelers(String(reservationId), String(tripId), user_ids); + const result = await this.reservations.setTravelers(String(reservationId), String(tripId), user_ids); if (!result) return errorResult('Reservation not found.'); this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers }); @@ -534,7 +534,7 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); // The service scopes the write to the trip on its own, so a foreign id is @@ -543,7 +543,7 @@ export class ReservationsMcp { if (dayId && !(await this.days.getDay(dayId, tripId))) return errorResult('dayId does not belong to this trip.'); - this.reservations.updatePositions(tripId, positions, dayId); + await this.reservations.updatePositions(tripId, positions, dayId); this.guards.safeBroadcast(tripId, 'reservation:positions', { positions, dayId }); return ok({ success: true }); } @@ -571,9 +571,9 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); - const current = this.reservations.getReservation(reservationId, tripId); + const current = await this.reservations.getReservation(reservationId, tripId); if (!current) return errorResult('Reservation not found.'); if (current.type !== 'hotel') return errorResult('Reservation is not of type hotel.'); @@ -617,7 +617,7 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'read' }, }) async listUpcomingReservations({ limit }: { limit?: number }, ctx: McpContext) { - return ok({ reservations: this.reservations.listUpcoming(ctx.userId, limit) }); + return ok({ reservations: await this.reservations.listUpcoming(ctx.userId, limit) }); } @ResourceTemplate({ @@ -629,7 +629,7 @@ export class ReservationsMcp { }) async tripReservationsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.reservations.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.reservations.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -638,7 +638,7 @@ export class ReservationsMcp { }], }; } - const reservations = this.reservations.list(id); + const reservations = await this.reservations.list(id); return { contents: [{ uri: uri.href, @@ -695,7 +695,7 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR); @@ -803,12 +803,12 @@ export class ReservationsMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); if (metadata && 'legs' in metadata) return errorResult(LEGS_IN_METADATA_ERROR); - const existing = this.reservations.getReservation(reservationId, tripId); + const existing = await this.reservations.getReservation(reservationId, tripId); if (!existing) return errorResult('Transport not found.'); const resolvedType = type ?? existing.type; @@ -841,7 +841,7 @@ export class ReservationsMcp { type: resolvedType, // Endpoints the caller did not replace stay the geometry the legs run // over, so read them back (the row carries them) instead of guessing. - endpoints: resolvedEndpoints ?? (this.reservations.getReservationWithJoins(reservationId)?.endpoints ?? []).map(e => ({ + endpoints: resolvedEndpoints ?? ((await this.reservations.getReservationWithJoins(reservationId))?.endpoints ?? []).map(e => ({ role: e.role, sequence: e.sequence, name: e.name, code: e.code, lat: e.lat, lng: e.lng, timezone: e.timezone, local_time: e.local_time, local_date: e.local_date, @@ -899,7 +899,7 @@ export class ReservationsMcp { }) async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const { deleted } = await this.reservations.remove(reservationId, tripId); if (!deleted) return errorResult('Transport not found.'); diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index 868717163..92f8d58ef 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -44,7 +44,7 @@ export class ReservationsRpc { const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; await this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined); - this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? ''); + await this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? ''); return reservation; } @@ -58,7 +58,7 @@ export class ReservationsRpc { const input = parsed.data as Record; this.requireValidEndpoints(input.endpoints); await this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); - const current = this.reservations.getReservation(String(reservationId), String(tripId)); + const current = await this.reservations.getReservation(String(reservationId), String(tripId)); if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`); await this.requireOwnReferences(tripId, input); const { reservation, accommodationChanged } = await this.reservations.update(String(reservationId), String(tripId), input as never, current as never); @@ -67,7 +67,7 @@ export class ReservationsRpc { const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; await this.reservations.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined); this.realtime.broadcast(tripId, 'reservation:updated', { reservation }, undefined); - this.notifyBooking(actor, tripId, i.title || cur.title, i.type || cur.type || ''); + await this.notifyBooking(actor, tripId, i.title || cur.title, i.type || cur.type || ''); return reservation; } @@ -82,7 +82,7 @@ export class ReservationsRpc { if (accommodationDeleted) this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined); if (deletedBudgetItemId) this.realtime.broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined); this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId }, undefined); - this.notifyBooking(actor, tripId, deleted.title, deleted.type || ''); + await this.notifyBooking(actor, tripId, deleted.title, deleted.type || ''); return { deleted: true }; } @@ -110,14 +110,14 @@ export class ReservationsRpc { * so it is named here as well, after the ownership check. */ private async requireOwnReferences(tripId: number, input: Record): Promise { - const offenders = this.reservations.referencesOutsideTrip(String(tripId), input as never); + const offenders = await this.reservations.referencesOutsideTrip(String(tripId), input as never); if (offenders.length > 0) throw new ForbiddenResource(`not part of trip ${tripId}: ${offenders.join(', ')}`); const unknown = await this.reservations.unresolvedReferences(String(tripId), input as never); if (unknown.length > 0) throw new BadParams(`unknown reference: ${unknown.join(', ')}`); } /** Fire-and-forget, exactly as the REST controller sends it, so it never blocks the write. */ - private notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void { - this.reservations.notifyBookingChange(tripId, actingUserId, booking, type); + private async notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): Promise { + await this.reservations.notifyBookingChange(tripId, actingUserId, booking, type); } } diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index a15f45ccd..79fd52fe9 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -155,7 +155,7 @@ export class ReservationsService { private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -185,7 +185,7 @@ export class ReservationsService { } /** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */ - notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): void { + async notifyBookingChange(tripId: string | number, actorId: number, booking: string, type: string): Promise { // Injected, not a lazy import of the old notifications bridge. The laziness bought // nothing the module graph does not already give — NotificationsModule // reaches nothing in this direction — and it hid the edge while handing the @@ -212,7 +212,7 @@ export class ReservationsService { } } - loadEndpointsByTrip(tripId: string | number): Map { + async loadEndpointsByTrip(tripId: string | number): Promise> { const rows = this.db.all(` SELECT e.* FROM reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id @@ -229,7 +229,7 @@ export class ReservationsService { } /** Users assignable on a trip: its members (guests included) plus the owner. */ - private assignableUserIds(tripId: string | number): Set { + private async assignableUserIds(tripId: string | number): Promise> { const members = this.db.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ?', tripId); const ids = new Set(members.map(m => m.user_id)); const owner = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId); @@ -237,7 +237,7 @@ export class ReservationsService { return ids; } - loadTravelersByTrip(tripId: string | number): Map { + async loadTravelersByTrip(tripId: string | number): Promise> { const rows = this.db.all(` SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest FROM reservation_travelers rt @@ -255,7 +255,7 @@ export class ReservationsService { return map; } - loadTravelers(reservationId: number | string): ReservationTraveler[] { + async loadTravelers(reservationId: number | string): Promise { return this.reads.loadTravelers(reservationId); } @@ -264,10 +264,10 @@ export class ReservationsService { * accepted — ids that aren't on the trip are silently dropped so a stale client * can't leak a cross-trip user. #1517. */ - setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void { - const allowed = this.assignableUserIds(tripId); + async setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): Promise { + const allowed = await this.assignableUserIds(tripId); const ids = [...new Set(userIds)].filter(uid => allowed.has(uid)); - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId); if (ids.length > 0) { const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)'); @@ -277,10 +277,10 @@ export class ReservationsService { } /** Assign trip members / named guests to a reservation (#1517). Null when off-trip. */ - setTravelers(id: string, tripId: string, userIds: number[]) { - if (!this.getReservation(id, tripId)) return null; - this.setReservationTravelers(id, tripId, userIds); - return { travelers: this.loadTravelers(id), reservation: this.getReservationWithJoins(Number(id)) }; + async setTravelers(id: string, tripId: string, userIds: number[]) { + if (!(await this.getReservation(id, tripId))) return null; + await this.setReservationTravelers(id, tripId, userIds); + return { travelers: await this.loadTravelers(id), reservation: await this.getReservationWithJoins(Number(id)) }; } // Resolve the day row whose date matches the date portion of an ISO-ish @@ -288,11 +288,11 @@ export class ReservationsService { // `reservation_time` / `reservation_end_time` so non-transport bookings // (tours, restaurants, events, ...) end up on the right day in the UI, // which now filters by day_id instead of reservation_time. - private resolveDayIdFromTime( + private async resolveDayIdFromTime( tripId: string | number, time: string | null | undefined, clampToNearest = true, - ): number | null { + ): Promise { if (!time) return null; const datePart = time.slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null; @@ -318,7 +318,7 @@ export class ReservationsService { // the new range is left untouched. Hotels linked to a day_accommodation are excluded here — // resyncAccommodationDays re-anchors the accommodation span and its linked reservation; // unlinked dated hotels (e.g. imported ones) re-anchor like any other booking. - resyncReservationDays(tripId: string | number): void { + async resyncReservationDays(tripId: string | number): Promise { const rows = this.db.all<{ id: number; reservation_time: string | null; reservation_end_time: string | null; day_id: number | null; end_day_id: number | null; @@ -329,12 +329,12 @@ export class ReservationsService { tripId ); const update = this.db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const r of rows) { - const newDayId = this.resolveDayIdFromTime(tripId, r.reservation_time, false); + const newDayId = await this.resolveDayIdFromTime(tripId, r.reservation_time, false); if (newDayId == null) continue; const newEndDayId = r.reservation_end_time - ? (this.resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id) + ? ((await this.resolveDayIdFromTime(tripId, r.reservation_end_time, false)) ?? r.end_day_id) : r.end_day_id; if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) { update.run(newDayId, newEndDayId, r.id); @@ -343,7 +343,7 @@ export class ReservationsService { }); } - private saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void { + private async saveEndpoints(reservationId: number, endpoints: EndpointInput[]): Promise { // Run the transaction through DatabaseService (which re-derives the // statement from the injected connection on each call). The bridge // instance is built over the reinitialize-proof `db` Proxy, so a @@ -351,7 +351,7 @@ export class ReservationsService { // connection can't leave this holding a dead handle — the legacy module // bound its transaction lazily per call for the same reason ("The // database connection is not open"). - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM reservation_endpoints WHERE reservation_id = ?', reservationId); const insert = this.db.prepare(` INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) @@ -369,7 +369,7 @@ export class ReservationsService { }); } - list(tripId: string | number) { + async list(tripId: string | number) { const reservations = this.db.all(` SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, ap.place_id as accommodation_place_id, acc_p.name as accommodation_name, @@ -396,8 +396,8 @@ export class ReservationsService { posMap.get(dp.reservation_id)![dp.day_id] = dp.position; } - const endpointsMap = this.loadEndpointsByTrip(tripId); - const travelersMap = this.loadTravelersByTrip(tripId); + const endpointsMap = await this.loadEndpointsByTrip(tripId); + const travelersMap = await this.loadTravelersByTrip(tripId); for (const r of reservations) { r.day_positions = posMap.get(r.id) || null; @@ -432,7 +432,7 @@ export class ReservationsService { * old date. Both halves of the report come from reading a hotel's date off * fields hotels do not use. */ - listUpcoming(userId: number, limit = 6) { + async listUpcoming(userId: number, limit = 6) { const today = new Date().toISOString().slice(0, 10); const now = new Date().toISOString(); @@ -512,7 +512,7 @@ export class ReservationsService { return reservations; } - getReservationWithJoins(id: string | number) { + async getReservationWithJoins(id: string | number) { return this.reads.getReservationWithJoins(id); } @@ -527,7 +527,7 @@ export class ReservationsService { * * Returns the offending field names, empty when the body is clean. */ - referencesOutsideTrip(tripId: string | number, data: CreateReservationData | UpdateReservationData): string[] { + async referencesOutsideTrip(tripId: string | number, data: CreateReservationData | UpdateReservationData): Promise { const offenders: string[] = []; // An id that resolves to nothing is not an offender. accommodation_id in // particular carries no foreign key, so shortening a trip's date range @@ -621,7 +621,7 @@ export class ReservationsService { /** Is there still a row behind this id? Existence only — which trip it sits * on is the guards' question, and they answer it before the write. */ - private referenceExists(table: 'days' | 'places' | 'day_assignments', id: unknown): boolean { + private async referenceExists(table: 'days' | 'places' | 'day_assignments', id: unknown): Promise { return !!this.db.get(`SELECT id FROM ${table} WHERE id = ?`, id); } @@ -635,8 +635,8 @@ export class ReservationsService { * (#2355), and an update rebinds whatever the row already held, so a guard * on the body alone never reaches it. */ - private resolvedOrNull(table: 'days' | 'places' | 'day_assignments', id: number | null): number | null { - return id != null && this.referenceExists(table, id) ? id : null; + private async resolvedOrNull(table: 'days' | 'places' | 'day_assignments', id: number | null): Promise { + return id != null && (await this.referenceExists(table, id)) ? id : null; } /** @@ -655,11 +655,11 @@ export class ReservationsService { * unplaceable class would collapse to (#2355). Its message survives, so the * importer that logs and moves on still names the field. */ - private requireResolvableStay(acc: CreateAccommodation): void { + private async requireResolvableStay(acc: CreateAccommodation): Promise { const missing: string[] = []; - if (acc.place_id && !this.referenceExists('places', acc.place_id)) missing.push('place_id'); - if (!this.referenceExists('days', acc.start_day_id)) missing.push('start_day_id'); - if (!this.referenceExists('days', acc.end_day_id)) missing.push('end_day_id'); + if (acc.place_id && !(await this.referenceExists('places', acc.place_id))) missing.push('place_id'); + if (!(await this.referenceExists('days', acc.start_day_id))) missing.push('start_day_id'); + if (!(await this.referenceExists('days', acc.end_day_id))) missing.push('end_day_id'); if (missing.length > 0) { throw new BadRequestException(`Unknown reference: ${missing.map((field) => `create_accommodation.${field}`).join(', ')}`); } @@ -694,7 +694,7 @@ export class ReservationsService { if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) { const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; if (start_day_id && end_day_id) { - this.requireResolvableStay(create_accommodation); + await this.requireResolvableStay(create_accommodation); const accResult = this.db.run( 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)', tripId, accPlaceId || null, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null @@ -715,17 +715,17 @@ export class ReservationsService { const resolvedType = type || 'other'; let resolvedDayId: number | null = day_id ?? null; if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) { - resolvedDayId = this.resolveDayIdFromTime(tripId, reservation_time); + resolvedDayId = await this.resolveDayIdFromTime(tripId, reservation_time); } let resolvedEndDayId: number | null = end_day_id ?? null; if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) { - resolvedEndDayId = this.resolveDayIdFromTime(tripId, reservation_end_time); + resolvedEndDayId = await this.resolveDayIdFromTime(tripId, reservation_end_time); } - resolvedDayId = this.resolvedOrNull('days', resolvedDayId); - resolvedEndDayId = this.resolvedOrNull('days', resolvedEndDayId); - const resolvedPlaceId = this.resolvedOrNull('places', place_id || null); - const resolvedAssignmentId = this.resolvedOrNull('day_assignments', assignment_id || null); + resolvedDayId = await this.resolvedOrNull('days', resolvedDayId); + resolvedEndDayId = await this.resolvedOrNull('days', resolvedEndDayId); + const resolvedPlaceId = await this.resolvedOrNull('places', place_id || null); + const resolvedAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id || null); const result = this.db.run(` INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, accommodation_id, metadata, needs_review) @@ -751,7 +751,7 @@ export class ReservationsService { ); if (endpoints && endpoints.length > 0) { - this.saveEndpoints(Number(result.lastInsertRowid), endpoints); + await this.saveEndpoints(Number(result.lastInsertRowid), endpoints); } // Sync check-in/out to accommodation if linked. Keyed off the RESOLVED id @@ -775,11 +775,11 @@ export class ReservationsService { } // The row was just inserted, so the re-select can't miss (legacy typed this any). - const reservation = this.getReservationWithJoins(Number(result.lastInsertRowid))!; + const reservation = (await this.getReservationWithJoins(Number(result.lastInsertRowid)))!; return { reservation, accommodationCreated, stayMirror }; } - updatePositions(tripId: string | number, positions: { id: number; day_plan_position?: number }[], dayId?: number | string | null) { + async updatePositions(tripId: string | number, positions: { id: number; day_plan_position?: number }[], dayId?: number | string | null): Promise { if (dayId) { // Per-day positions for multi-day reservations, scoped the way the legacy // branch below already scopes its update. The table carries no trip_id and @@ -795,7 +795,7 @@ export class ReservationsService { JOIN days d ON d.trip_id = r.trip_id WHERE r.id = ? AND d.id = ? AND r.trip_id = ? `); - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const item of positions) { // position is NOT NULL while the wire contract leaves the value optional. stmt.run(item.day_plan_position ?? 0, item.id, dayId, tripId); @@ -804,7 +804,7 @@ export class ReservationsService { } else { // Legacy: update global position const stmt = this.db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const item of positions) { stmt.run(item.day_plan_position, item.id, tripId); } @@ -812,7 +812,7 @@ export class ReservationsService { } } - getReservation(id: string | number, tripId: string | number) { + async getReservation(id: string | number, tripId: string | number) { return this.db.get('SELECT * FROM reservations WHERE id = ? AND trip_id = ?', id, tripId); } @@ -846,7 +846,7 @@ export class ReservationsService { if (type === 'hotel' && create_accommodation) { const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; if (start_day_id && end_day_id) { - this.requireResolvableStay(create_accommodation); + await this.requireResolvableStay(create_accommodation); if (resolvedAccId) { const prior = this.db.get<{ check_in: string | null }>('SELECT check_in FROM day_accommodations WHERE id = ?', resolvedAccId); this.db.run( @@ -897,7 +897,7 @@ export class ReservationsService { // No day set but we have a date — pin it to the matching day so the booking // still shows in the Plan (covers bookings saved without a selected day, and // the case where an earlier edit cleared day_id). - nextDayId = this.resolveDayIdFromTime(tripId, nextReservationTime); + nextDayId = await this.resolveDayIdFromTime(tripId, nextReservationTime); } else if (day_id === undefined) { // Field absent and nothing to derive from — keep whatever it had. nextDayId = current.day_id ?? null; @@ -909,15 +909,15 @@ export class ReservationsService { if (end_day_id !== undefined) { nextEndDayId = end_day_id ?? null; } else if (reservation_end_time !== undefined && resolvedType !== 'hotel') { - nextEndDayId = this.resolveDayIdFromTime(tripId, nextReservationEndTime); + nextEndDayId = await this.resolveDayIdFromTime(tripId, nextReservationEndTime); } else { nextEndDayId = current.end_day_id ?? null; } - nextDayId = this.resolvedOrNull('days', nextDayId); - nextEndDayId = this.resolvedOrNull('days', nextEndDayId); - const nextPlaceId = this.resolvedOrNull('places', place_id !== undefined ? (place_id || null) : (current.place_id ?? null)); - const nextAssignmentId = this.resolvedOrNull('day_assignments', assignment_id !== undefined ? (assignment_id || null) : (current.assignment_id ?? null)); + nextDayId = await this.resolvedOrNull('days', nextDayId); + nextEndDayId = await this.resolvedOrNull('days', nextEndDayId); + const nextPlaceId = await this.resolvedOrNull('places', place_id !== undefined ? (place_id || null) : (current.place_id ?? null)); + const nextAssignmentId = await this.resolvedOrNull('day_assignments', assignment_id !== undefined ? (assignment_id || null) : (current.assignment_id ?? null)); this.db.run(` UPDATE reservations SET @@ -959,7 +959,7 @@ export class ReservationsService { ); if (endpoints !== undefined) { - this.saveEndpoints(Number(id), endpoints); + await this.saveEndpoints(Number(id), endpoints); } // Sync check-in/out to accommodation if linked @@ -983,7 +983,7 @@ export class ReservationsService { // The caller passed the pre-checked `current` row, so the re-select can't // miss (legacy typed this any). - const reservation = this.getReservationWithJoins(id)!; + const reservation = (await this.getReservationWithJoins(id))!; return { reservation, accommodationChanged, stayMirror }; } diff --git a/server/src/nest/reservations/upcoming-reservations.controller.ts b/server/src/nest/reservations/upcoming-reservations.controller.ts index fc7944499..8623f5472 100644 --- a/server/src/nest/reservations/upcoming-reservations.controller.ts +++ b/server/src/nest/reservations/upcoming-reservations.controller.ts @@ -18,7 +18,7 @@ export class UpcomingReservationsController { constructor(private readonly reservations: ReservationsService) {} @Get('upcoming') - upcoming(@CurrentUser() user: User) { - return { reservations: this.reservations.listUpcoming(user.id) }; + async upcoming(@CurrentUser() user: User) { + return { reservations: await this.reservations.listUpcoming(user.id) }; } } diff --git a/server/src/nest/roadtrip/day-boundaries.controller.ts b/server/src/nest/roadtrip/day-boundaries.controller.ts index aca3d9ca7..191b99062 100644 --- a/server/src/nest/roadtrip/day-boundaries.controller.ts +++ b/server/src/nest/roadtrip/day-boundaries.controller.ts @@ -18,22 +18,22 @@ export class DayBoundariesController { constructor(private readonly boundaries: DayBoundariesService, private readonly realtime: RealtimeService) {} @Get() - list(@Param('tripId') tripId: string) { - return { boundaries: this.boundaries.list(tripId) }; + async list(@Param('tripId') tripId: string) { + return { boundaries: await this.boundaries.list(tripId) }; } @Put() @RequirePermission('day_edit') - save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) { - const boundaries = this.boundaries.save(tripId, body); + async save(@Param('tripId') tripId: string, @Body() body: DayBoundaryDto, @Headers('x-socket-id') socketId?: string) { + const boundaries = await this.boundaries.save(tripId, body); this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId); return { boundaries }; } @Delete(':dayNumber') @RequirePermission('day_edit') - remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) { - const boundaries = this.boundaries.remove(tripId, dayNumber); + async remove(@Param('tripId') tripId: string, @Param('dayNumber', ParseIntPipe) dayNumber: number, @Headers('x-socket-id') socketId?: string) { + const boundaries = await this.boundaries.remove(tripId, dayNumber); this.realtime.broadcast(tripId, 'roadtripBoundary:changed', { boundaries }, socketId); return { boundaries }; } diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts index 192f391f5..ffd569f20 100644 --- a/server/src/nest/roadtrip/day-boundaries.mcp.ts +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -32,7 +32,7 @@ export class DayBoundariesMcp { }) async list({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - return ok({ boundaries: this.boundaries.list(tripId) }); + return ok({ boundaries: await this.boundaries.list(tripId) }); } @Tool({ @@ -46,10 +46,10 @@ export class DayBoundariesMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A stop from another trip is refused by the service, with the reason. - return answeringRefusals(() => { + return answeringRefusals(async () => { const boundaries = boundary - ? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber }) - : this.boundaries.remove(tripId, dayNumber); + ? await this.boundaries.save(tripId, { ...boundary, day_number: dayNumber }) + : await this.boundaries.remove(tripId, dayNumber); this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries }); return ok({ boundaries }); }); diff --git a/server/src/nest/roadtrip/day-boundaries.service.ts b/server/src/nest/roadtrip/day-boundaries.service.ts index bcc74fc15..d31d501b8 100644 --- a/server/src/nest/roadtrip/day-boundaries.service.ts +++ b/server/src/nest/roadtrip/day-boundaries.service.ts @@ -6,13 +6,13 @@ import { DatabaseService } from '../database/database.service'; export class DayBoundariesService { constructor(private readonly db: DatabaseService) {} - list(tripId: string | number): RoadtripDayBoundary[] { + async list(tripId: string | number): Promise { return this.db.all( 'SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number', tripId, ); } - save(tripId: string | number, boundary: RoadtripDayBoundary): RoadtripDayBoundary[] { + async save(tripId: string | number, boundary: RoadtripDayBoundary): Promise { const belongs = (id: number) => this.db.get( 'SELECT a.id FROM day_assignments a JOIN days d ON d.id = a.day_id WHERE a.id = ? AND d.trip_id = ?', id, tripId, ); @@ -26,7 +26,7 @@ export class DayBoundariesService { return this.list(tripId); } - remove(tripId: string | number, dayNumber: number): RoadtripDayBoundary[] { + async remove(tripId: string | number, dayNumber: number): Promise { this.db.run('DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?', tripId, dayNumber); return this.list(tripId); } diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts index be53c4a17..152220f2e 100644 --- a/server/src/nest/roadtrip/roadtrip-plan.service.ts +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -88,11 +88,11 @@ export class RoadtripPlanService { return { days, visits, - settings: this.preferences.read(tripId), + settings: await this.preferences.read(tripId), profiles: await this.router.profiles(), - vias: this.roadtrip.listForTrip(tripId), - tracks: this.roadtrip.tracksForTrip(tripId), - boundaries: this.boundaries.list(tripId), + vias: await this.roadtrip.listForTrip(tripId), + tracks: await this.roadtrip.tracksForTrip(tripId), + boundaries: await this.boundaries.list(tripId), }; } diff --git a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts index 90a4cb761..5f99fcf39 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts @@ -18,13 +18,13 @@ export class RoadtripPreferencesController { constructor(private readonly preferences: RoadtripPreferencesService) {} @Get() - read(@Param('tripId') tripId: string) { - return { tripId: Number(tripId), preferences: this.preferences.read(Number(tripId)) }; + async read(@Param('tripId') tripId: string) { + return { tripId: Number(tripId), preferences: await this.preferences.read(Number(tripId)) }; } @Put() @RequirePermission('day_edit') - update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto, @Headers('x-socket-id') socketId?: string) { - return { tripId: Number(tripId), preferences: this.preferences.update(Number(tripId), patch, socketId) }; + async update(@Param('tripId') tripId: string, @Body() patch: PreferencesDto, @Headers('x-socket-id') socketId?: string) { + return { tripId: Number(tripId), preferences: await this.preferences.update(Number(tripId), patch, socketId) }; } } diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts index 7a537ade2..097085112 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -43,7 +43,7 @@ export class RoadtripPreferencesMcp { }) async read({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); - return ok({ tripId, settings: this.preferences.read(tripId), scope: 'trip' }); + return ok({ tripId, settings: await this.preferences.read(tripId), scope: 'trip' }); } @Tool({ @@ -60,6 +60,6 @@ export class RoadtripPreferencesMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A day window that ends before it starts is refused by the service, with the reason. - return answeringRefusals(() => ok({ tripId, settings: this.preferences.update(tripId, settings), scope: 'trip' })); + return answeringRefusals(async () => ok({ tripId, settings: await this.preferences.update(tripId, settings), scope: 'trip' })); } } diff --git a/server/src/nest/roadtrip/roadtrip-preferences.service.ts b/server/src/nest/roadtrip/roadtrip-preferences.service.ts index b612dbe7f..8b6e6a4c8 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.service.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.service.ts @@ -1,5 +1,6 @@ import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { HttpException, Injectable } from '@nestjs/common'; import { ROADTRIP_PREFERENCE_KEYS, @@ -13,9 +14,10 @@ export class RoadtripPreferencesService { constructor( private readonly db: DatabaseService, private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, ) {} - read(tripId: number): RoadtripPreferences { + async read(tripId: number): Promise { const settings: Record = {}; for (const row of this.db.all<{ key: string; value: string }>( 'SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?', @@ -35,10 +37,10 @@ export class RoadtripPreferencesService { return roadtripPreferencesSchema.parse(preferences); } - update(tripId: number, patch: RoadtripPreferences, socketId?: string): RoadtripPreferences { + async update(tripId: number, patch: RoadtripPreferences, socketId?: string): Promise { const validated = roadtripPreferencesUpdateSchema.parse(patch); - const saved = this.db.transaction(() => { - const next = { ...this.read(tripId), ...validated }; + const saved = await this.uow.transactional(async () => { + const next = { ...(await this.read(tripId)), ...validated }; if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) { throw new HttpException({ error: 'Day end must be later than day start.' }, 400); } diff --git a/server/src/nest/roadtrip/roadtrip.controller.ts b/server/src/nest/roadtrip/roadtrip.controller.ts index 7efcb7042..141d98e99 100644 --- a/server/src/nest/roadtrip/roadtrip.controller.ts +++ b/server/src/nest/roadtrip/roadtrip.controller.ts @@ -33,27 +33,27 @@ export class RoadtripController { /** Every via of the trip, so the client can route all days without a request per day. */ @Get('vias') - listAll(@Param('tripId') tripId: string): { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] } { - return { vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) }; + async listAll(@Param('tripId') tripId: string): Promise<{ vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }> { + return { vias: await this.roadtrip.listForTrip(tripId), tracks: await this.roadtrip.tracksForTrip(tripId) }; } @Get('days/:dayId/vias') - list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): { vias: RoadtripVia[] } { - this.requireDay(dayId, tripId); - return { vias: this.roadtrip.listForDay(dayId) }; + async list(@Param('tripId') tripId: string, @Param('dayId') dayId: string): Promise<{ vias: RoadtripVia[] }> { + await this.requireDay(dayId, tripId); + return { vias: await this.roadtrip.listForDay(dayId) }; } @RequirePermission('day_edit') @Post('days/:dayId/vias') - create( + async create( @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaCreateDto, @Headers('x-socket-id') socketId?: string, - ): { via: RoadtripVia } { - this.requireDay(dayId, tripId); - const via = this.roadtrip.create(dayId, body); - this.announce(tripId, dayId, socketId); + ): Promise<{ via: RoadtripVia }> { + await this.requireDay(dayId, tripId); + const via = await this.roadtrip.create(dayId, body); + await this.announce(tripId, dayId, socketId); return { via }; } @@ -68,26 +68,27 @@ export class RoadtripController { @RequirePermission('day_edit') @Post('days/:dayId/vias/batch') @HttpCode(200) - createMany( + async createMany( @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaBatchDto, @Headers('x-socket-id') socketId?: string, - ): { vias: RoadtripVia[] } { - this.requireDay(dayId, tripId); + ): Promise<{ vias: RoadtripVia[] }> { + await this.requireDay(dayId, tripId); // Permission is not enough on its own: a place id from somebody else's trip would // otherwise become this day's label, and a place that is not a track would become a // label that can never be drawn. - if (body.track && !this.roadtrip.trackExists(body.track.place_id, tripId)) { + if (body.track && !(await this.roadtrip.trackExists(body.track.place_id, tripId))) { throw new HttpException({ error: 'Track not found' }, 404); } - const vias = this.roadtrip.createMany(dayId, body); - this.announce(tripId, dayId, socketId); + const vias = await this.roadtrip.createMany(dayId, body); + await this.announce(tripId, dayId, socketId); // A batch is how a day starts following a recorded track, so the track it now follows // is news in its own right: the rail draws a badge for it and the map a line. + const tracks = await this.roadtrip.tracksForTrip(tripId); this.roadtrip.broadcast(tripId, 'roadtripTrack:changed', { dayId, - track: this.roadtrip.tracksForTrip(tripId).find(t => String(t.day_id) === String(dayId)) ?? null, + track: tracks.find(t => String(t.day_id) === String(dayId)) ?? null, }, socketId); return { vias }; } @@ -102,48 +103,48 @@ export class RoadtripController { @RequirePermission('day_edit') @Put('days/:dayId/vias') @HttpCode(200) - reanchor( + async reanchor( @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaReanchorDto, @Headers('x-socket-id') socketId?: string, - ): { vias: RoadtripVia[] } { - this.requireDay(dayId, tripId); - const vias = this.roadtrip.reanchor(dayId, body); - this.announce(tripId, dayId, socketId); + ): Promise<{ vias: RoadtripVia[] }> { + await this.requireDay(dayId, tripId); + const vias = await this.roadtrip.reanchor(dayId, body); + await this.announce(tripId, dayId, socketId); return { vias }; } @RequirePermission('day_edit') @Put('days/:dayId/vias/:id') @HttpCode(200) - update( + async update( @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Param('id') id: string, @Body() body: RoadtripViaUpdateDto, @Headers('x-socket-id') socketId?: string, - ): { via: RoadtripVia } { - this.requireDay(dayId, tripId); - const via = this.roadtrip.move(id, dayId, body.lat, body.lng, body.after_order_index); + ): Promise<{ via: RoadtripVia }> { + await this.requireDay(dayId, tripId); + const via = await this.roadtrip.move(id, dayId, body.lat, body.lng, body.after_order_index); if (!via) throw new HttpException({ error: 'Via not found' }, 404); - this.announce(tripId, dayId, socketId); + await this.announce(tripId, dayId, socketId); return { via }; } @RequirePermission('day_edit') @Delete('days/:dayId/vias/:id') - remove( + async remove( @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, - ): { success: true } { - this.requireDay(dayId, tripId); - if (!this.roadtrip.remove(id, dayId)) { + ): Promise<{ success: true }> { + await this.requireDay(dayId, tripId); + if (!(await this.roadtrip.remove(id, dayId))) { throw new HttpException({ error: 'Via not found' }, 404); } - this.announce(tripId, dayId, socketId); + await this.announce(tripId, dayId, socketId); return { success: true }; } @@ -155,10 +156,10 @@ export class RoadtripController { * routes means a client applies them all the same way. The originating socket is * excluded, so the person dragging does not get their own point handed back mid-drag. */ - private announce(tripId: string, dayId: string, socketId: string | undefined): void { + private async announce(tripId: string, dayId: string, socketId: string | undefined): Promise { this.roadtrip.broadcast(tripId, 'roadtripVia:changed', { dayId, - vias: this.roadtrip.listForDay(dayId), + vias: await this.roadtrip.listForDay(dayId), }, socketId); } @@ -167,8 +168,8 @@ export class RoadtripController { * Without it a valid day id from someone else's trip would be editable through a trip * the caller does have access to. */ - private requireDay(dayId: string, tripId: string): void { - if (!this.roadtrip.dayExists(dayId, tripId)) { + private async requireDay(dayId: string, tripId: string): Promise { + if (!(await this.roadtrip.dayExists(dayId, tripId))) { throw new HttpException({ error: 'Day not found' }, 404); } } diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index 579a6bda2..f833782bc 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -50,10 +50,10 @@ export class RoadtripMcp { async listVias({ tripId, dayId }: { tripId: number; dayId?: number }, ctx: McpContext) { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (dayId != null) { - if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - return ok({ vias: this.roadtrip.listForDay(dayId) }); + if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); + return ok({ vias: await this.roadtrip.listForDay(dayId) }); } - return ok({ vias: this.roadtrip.listForTrip(tripId), tracks: this.roadtrip.tracksForTrip(tripId) }); + return ok({ vias: await this.roadtrip.listForTrip(tripId), tracks: await this.roadtrip.tracksForTrip(tripId) }); } @Tool({ @@ -77,9 +77,9 @@ export class RoadtripMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - const via = this.roadtrip.create(dayId, { after_order_index, lat, lng }); - this.announce(tripId, dayId); + if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); + const via = await this.roadtrip.create(dayId, { after_order_index, lat, lng }); + await this.announce(tripId, dayId); return ok({ via }); } @@ -119,16 +119,17 @@ export class RoadtripMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); + if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); // The same check the REST route makes, in the same change: permission alone would let // a place id from another trip, or a place that is not a track at all, become this // day's label. - if (track && !this.roadtrip.trackExists(track.place_id, tripId)) return noAccess(); - const made = this.roadtrip.createMany(dayId, { vias, replace_legs, track }); - this.announce(tripId, dayId); + if (track && !(await this.roadtrip.trackExists(track.place_id, tripId))) return noAccess(); + const made = await this.roadtrip.createMany(dayId, { vias, replace_legs, track }); + await this.announce(tripId, dayId); + const tracks = await this.roadtrip.tracksForTrip(String(tripId)); this.roadtrip.broadcast(String(tripId), 'roadtripTrack:changed', { dayId, - track: this.roadtrip.tracksForTrip(String(tripId)).find(t => String(t.day_id) === String(dayId)) ?? null, + track: tracks.find(t => String(t.day_id) === String(dayId)) ?? null, }, undefined); return ok({ vias: made }); } @@ -157,9 +158,9 @@ export class RoadtripMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - const next = this.roadtrip.reanchor(dayId, { vias, remove }); - this.announce(tripId, dayId); + if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); + const next = await this.roadtrip.reanchor(dayId, { vias, remove }); + await this.announce(tripId, dayId); return ok({ vias: next }); } @@ -179,9 +180,9 @@ export class RoadtripMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - if (!this.roadtrip.remove(viaId, dayId)) return noAccess(); - this.announce(tripId, dayId); + if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess(); + if (!(await this.roadtrip.remove(viaId, dayId))) return noAccess(); + await this.announce(tripId, dayId); return ok({ success: true }); } @@ -195,10 +196,10 @@ export class RoadtripMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied(); - if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess(); - const via = this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); + if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess(); + const via = await this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); if (!via) return noAccess(); - this.announce(input.tripId, input.dayId); + await this.announce(input.tripId, input.dayId); return ok({ via }); } @@ -210,10 +211,10 @@ export class RoadtripMcp { * No originating socket to exclude here — a tool call has no socket of its own, so the * client that asked for it hears about it like everyone else. */ - private announce(tripId: number, dayId: number): void { + private async announce(tripId: number, dayId: number): Promise { this.roadtrip.broadcast(String(tripId), 'roadtripVia:changed', { dayId, - vias: this.roadtrip.listForDay(String(dayId)), + vias: await this.roadtrip.listForDay(String(dayId)), }, undefined); } } diff --git a/server/src/nest/roadtrip/roadtrip.service.ts b/server/src/nest/roadtrip/roadtrip.service.ts index 44da412b5..3f65a6bd7 100644 --- a/server/src/nest/roadtrip/roadtrip.service.ts +++ b/server/src/nest/roadtrip/roadtrip.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; +import { UnitOfWork } from '../database/unit-of-work'; /** * Via points: the places a day's drive is made to pass through without stopping. @@ -16,6 +17,7 @@ export class RoadtripService { constructor( private readonly db: DatabaseService, private readonly realtime: RealtimeService, + private readonly uow: UnitOfWork, ) {} /** @@ -34,7 +36,7 @@ export class RoadtripService { } /** The day exists and belongs to this trip. 404 material, checked before every write. */ - dayExists(dayId: string | number, tripId: string | number): boolean { + async dayExists(dayId: string | number, tripId: string | number): Promise { return !!this.db.get<{ id: number }>( 'SELECT id FROM days WHERE id = ? AND trip_id = ?', dayId, @@ -42,7 +44,7 @@ export class RoadtripService { ); } - listForDay(dayId: string | number): RoadtripVia[] { + async listForDay(dayId: string | number): Promise { return this.db.all( `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias @@ -53,7 +55,7 @@ export class RoadtripService { } /** Every via of a trip, so the client can route all days without one request per day. */ - listForTrip(tripId: string | number): RoadtripVia[] { + async listForTrip(tripId: string | number): Promise { return this.db.all( `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, v.created_at FROM roadtrip_vias v @@ -64,7 +66,7 @@ export class RoadtripService { ); } - create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): RoadtripVia { + async create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): Promise { // Appended after whatever already follows that stop, unless the caller says where. const sequence = input.sequence ?? ( this.db.get<{ next: number }>( @@ -81,7 +83,7 @@ export class RoadtripService { input.lat, input.lng, ); - return this.byId(Number(result.lastInsertRowid))!; + return (await this.byId(Number(result.lastInsertRowid)))!; } /** @@ -94,15 +96,15 @@ export class RoadtripService { * `replace_legs` clears by leg rather than by day, so vias the traveller placed by hand * on other legs survive a track being laid on this one. */ - createMany( + async createMany( dayId: string | number, input: { vias: { after_order_index: number; lat: number; lng: number }[]; replace_legs?: number[]; track?: { place_id: number; stray_km?: number | null } | null; }, - ): RoadtripVia[] { - return this.db.transaction(() => { + ): Promise { + return this.uow.transactional(async () => { // Inside the same transaction as the chain it describes. A day that says it follows // a road whose vias never landed is worse than a day that says nothing. if (input.track === null) { @@ -152,7 +154,7 @@ export class RoadtripService { * Read with the vias in one go: both are wanted on every load of road-trip mode, and a * second route for a handful of rows would be a second round trip for nothing. */ - tracksForTrip(tripId: string | number): RoadtripDayTrack[] { + async tracksForTrip(tripId: string | number): Promise { return this.db.all( `SELECT t.day_id, t.place_id, t.stray_km FROM roadtrip_day_tracks t @@ -164,7 +166,7 @@ export class RoadtripService { } /** Whether a place is on this trip, and is a track rather than an ordinary place. */ - trackExists(placeId: number, tripId: string | number): boolean { + async trackExists(placeId: number, tripId: string | number): Promise { return !!this.db.get<{ id: number }>( "SELECT id FROM places WHERE id = ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != ''", placeId, @@ -173,13 +175,13 @@ export class RoadtripService { } /** Moving a via is the whole edit; where it sits in the chain does not change. */ - move( + async move( id: string | number, dayId: string | number, lat: number, lng: number, afterOrderIndex?: number, - ): RoadtripVia | null { + ): Promise { const existing = this.db.get<{ id: number }>( 'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, @@ -216,11 +218,11 @@ export class RoadtripService { * the day, and a via somebody else deleted in the meantime is not a failure of the * re-anchoring; failing the batch over it would leave the rest of the day mis-pinned. */ - reanchor( + async reanchor( dayId: string | number, input: { vias: { id: number; after_order_index: number }[]; remove?: number[] }, - ): RoadtripVia[] { - return this.db.transaction(() => { + ): Promise { + return this.uow.transactional(async () => { // Read before writing: the OLD anchor is what says which of two merged // legs came first, and after the updates that information is gone. const before = new Map( @@ -286,12 +288,12 @@ export class RoadtripService { }); } - remove(id: string | number, dayId: string | number): boolean { + async remove(id: string | number, dayId: string | number): Promise { const result = this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId); return result.changes > 0; } - private byId(id: number): RoadtripVia | null { + private async byId(id: number): Promise { return this.db.get( 'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?', id, diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 5944c04a5..2453a662c 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -249,7 +249,7 @@ export class TransitMcp { needs_review: false, }); this.guards.safeBroadcast(tripId, 'reservation:created', { reservation }); - this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || ''); + await this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || ''); return ok({ reservation }); } } diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 4f1aad605..ea71d22f0 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -79,7 +79,7 @@ export class TripReadModelService { checked: (packingItems as { checked: number }[]).filter(i => i.checked).length, }; - const reservations = this.reservations.list(tripId); + const reservations = await this.reservations.list(tripId); const collab_notes = await this.collab.listNotes(tripId); return { @@ -109,7 +109,7 @@ export class TripReadModelService { packingItems: await this.packing.listItems(tripId, viewerId), todoItems: await this.todo.listItems(tripId), budgetItems: await this.budget.listBudgetItems(tripId), - reservations: this.reservations.list(tripId), + reservations: await this.reservations.list(tripId), files: await this.files.listFiles(tripId, false), accommodations: await this.accommodations.list(tripId), members: [owner, ...(members || [])].filter(Boolean), diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index dc520282c..23d09e9fa 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -272,12 +272,12 @@ export class TripsController { } @Get(':id/export.ics') - exportIcs(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response) { + async exportIcs(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response) { if (!this.trips.canAccessTrip(id, user.id)) { throw new HttpException({ error: 'Trip not found' }, 404); } try { - const { ics, filename } = this.calendar.exportICS(id); + const { ics, filename } = await this.calendar.exportICS(id); res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment')); res.send(ics); diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 1ae87b189..f8cdde40a 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -533,7 +533,7 @@ export class TripsMcp { async exportTripIcs({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); try { - const { ics, filename } = this.calendar.exportICS(tripId); + const { ics, filename } = await this.calendar.exportICS(tripId); return ok({ ics, filename }); } catch { return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true }; diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts index 9b22ce30d..1d015dd64 100644 --- a/server/src/nest/trips/trips.rpc.ts +++ b/server/src/nest/trips/trips.rpc.ts @@ -67,7 +67,7 @@ export class TripsRpc { @PluginMethod('trips.getReservations', { permission: 'db:read:trips' }) getReservations(params: Record, ctx: PluginRpcContext): unknown { - return this.guards.tripRead(params, ctx, () => this.reservations.list(String(num(params.tripId, 'tripId')))); + return this.guards.tripRead(params, ctx, async () => this.reservations.list(String(num(params.tripId, 'tripId')))); } @PluginMethod('trips.getDays', { permission: 'db:read:trips' }) @@ -95,12 +95,13 @@ export class TripsRpc { } @PluginMethod('reservations.listMine', { permission: 'db:read:trips' }) - listMyReservations(_params: Record, ctx: PluginRpcContext): unknown { + async listMyReservations(_params: Record, ctx: PluginRpcContext): Promise { if (ctx.actingUserId === undefined) { throw new ForbiddenResource('reservation reads require an authenticated user context'); } const trips = this.trips.list(ctx.actingUserId, null) as Array<{ id: number }>; - return trips.flatMap((t) => this.reservations.list(String(t.id))); + const perTrip = await Promise.all(trips.map((t) => this.reservations.list(String(t.id)))); + return perTrip.flat(); } @PluginMethod('trips.members', { permission: 'db:read:trips' }) diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 2765828cc..612d66910 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -440,7 +440,7 @@ export class TripsService { // Default: generateDays re-dates day rows positionally; re-anchor dated bookings to // the day matching their absolute reservation_time, and accommodations (+ their // linked hotel reservations) to the days now holding their pre-change dates (#1288). - this.reservations.resyncReservationDays(tripId); + await this.reservations.resyncReservationDays(tripId); await this.days.resyncAccommodationDays(tripId, prevDateByDayId); } }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index daa37fd28..fa66293e4 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -224,7 +224,7 @@ export async function createMcpTestRegistry(): Promise { new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards), new DaysMcp(daysService, authService, guards), - new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService), + new RoadtripMcp(new RoadtripService(dbService, realtimeService, await createTestUnitOfWork(dbService.connection)), dbService, guards, authService, addonsService), new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards), new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)), new AssignmentsMcp(assignmentsService, daysService, authService, guards), diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 9bc2c0ceb..1eaba121f 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -68,7 +68,7 @@ const geocodeMock = vi.spyOn(TransitService.prototype, 'geocode'); const planMock = vi.spyOn(TransitService.prototype, 'plan'); const notifyBookingChangeMock = vi .spyOn(ReservationsService.prototype, 'notifyBookingChange') - .mockImplementation(() => {}); + .mockImplementation(async () => {}); const from = { name: 'Namba', lat: 34.667, lng: 135.501 }; const to = { name: 'Umeda', lat: 34.702, lng: 135.496 }; @@ -137,7 +137,7 @@ beforeEach(() => { planMock.mockReset(); broadcastMock.mockReset(); // mockReset would fall back to the real notification write — keep it stubbed. - notifyBookingChangeMock.mockReset().mockImplementation(() => {}); + notifyBookingChangeMock.mockReset().mockImplementation(async () => {}); delete process.env.DEMO_MODE; invalidatePermissionsCache(); }); diff --git a/server/tests/unit/nest/cache-sweep-jobs.test.ts b/server/tests/unit/nest/cache-sweep-jobs.test.ts index de41f7d46..0ca02f93f 100644 --- a/server/tests/unit/nest/cache-sweep-jobs.test.ts +++ b/server/tests/unit/nest/cache-sweep-jobs.test.ts @@ -39,28 +39,28 @@ describe('TrekPhotoCacheJob', () => { return { job, registrar, cache }; } - it('CSJOB-001 — boot sweep runs immediately, then the 2-hourly server-local cron registers', () => { + it('CSJOB-001 — boot sweep runs immediately, then the 2-hourly server-local cron registers', async () => { const { job, registrar, cache } = make(); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(cache.sweepExpired).toHaveBeenCalledTimes(1); expect(registrar.register).toHaveBeenCalledWith('trek-photo-cache', '0 */2 * * *', expect.any(Function), { timezone: 'none' }); }); - it('CSJOB-002 — a throwing boot sweep is swallowed (cache dir may not exist yet) and the cron still registers', () => { + it('CSJOB-002 — a throwing boot sweep is swallowed (cache dir may not exist yet) and the cron still registers', async () => { const { job, registrar } = make(() => { throw new Error('ENOENT'); }); expect(() => job.onApplicationBootstrap()).not.toThrow(); expect(registrar.register).toHaveBeenCalled(); expect(logMock.logError).not.toHaveBeenCalled(); }); - it('CSJOB-003 — the test gate skips the boot sweep entirely', () => { + it('CSJOB-003 — the test gate skips the boot sweep entirely', async () => { const { job, registrar, cache } = make(vi.fn(), false); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(cache.sweepExpired).not.toHaveBeenCalled(); expect(registrar.register).not.toHaveBeenCalled(); }); - it('CSJOB-004 — a throwing tick is contained to the cleanup log line', () => { + it('CSJOB-004 — a throwing tick is contained to the cleanup log line', async () => { const { job } = make(() => { throw new Error('disk gone'); }); expect(() => job.tick()).not.toThrow(); expect(logMock.logError).toHaveBeenCalledWith('Trek photo cache cleanup: disk gone'); @@ -75,9 +75,9 @@ describe('PlacePhotoCacheJob', () => { return { job, registrar, cache }; } - it('CSJOB-005 — boot sweep runs immediately, then the nightly app-tz cron registers', () => { + it('CSJOB-005 — boot sweep runs immediately, then the nightly app-tz cron registers', async () => { const { job, registrar, cache } = make(); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(cache.sweepOrphans).toHaveBeenCalledTimes(1); expect(registrar.register).toHaveBeenCalledWith('place-photo-cache', '30 3 * * *', expect.any(Function)); }); @@ -89,7 +89,7 @@ describe('PlacePhotoCacheJob', () => { expect(logMock.logInfo).toHaveBeenCalledWith('Place-photo cache cleanup: removed 3 orphaned file(s)/row(s)'); }); - it('CSJOB-007 — a throwing sweep is contained to the cleanup log line, and the gate skips the boot sweep', () => { + it('CSJOB-007 — a throwing sweep is contained to the cleanup log line, and the gate skips the boot sweep', async () => { const { job } = make(() => { throw new Error('fs down'); }); expect(() => job.sweep()).not.toThrow(); expect(logMock.logError).toHaveBeenCalledWith('Place-photo cache cleanup: fs down'); @@ -109,9 +109,9 @@ describe('JourneyThumbsJob', () => { return { job, registrar, thumbnails }; } - it('CSJOB-010 — boot sweep runs immediately, then the daily app-tz cron registers', () => { + it('CSJOB-010 — boot sweep runs immediately, then the daily app-tz cron registers', async () => { const { job, registrar, thumbnails } = make(); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(thumbnails.sweepOrphanThumbs).toHaveBeenCalledTimes(1); expect(registrar.register).toHaveBeenCalledWith('journey-thumbs', '0 4 * * *', expect.any(Function)); }); @@ -150,14 +150,14 @@ describe('AirtrailSyncJob', () => { return { job, registrar, airtrail }; } - it('CSJOB-008 — registers */N from the app-setting and logs the banner', () => { + it('CSJOB-008 — registers */N from the app-setting and logs the banner', async () => { const { job, registrar } = make('10'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(logMock.logInfo).toHaveBeenCalledWith('AirTrail sync: scheduled every 10m'); expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', '*/10 * * * *', expect.any(Function)); }); - it('CSJOB-009 — clamps the interval to 1–59 and defaults to 5', () => { + it('CSJOB-009 — clamps the interval to 1–59 and defaults to 5', async () => { for (const [setting, minutes] of [ [undefined, 5], ['0', 5], @@ -168,15 +168,15 @@ describe('AirtrailSyncJob', () => { ] as const) { vi.clearAllMocks(); const { job, registrar } = make(setting); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('airtrail-sync', `*/${minutes} * * * *`, expect.any(Function)); expect(logMock.logInfo).toHaveBeenCalledWith(`AirTrail sync: scheduled every ${minutes}m`); } }); - it('CSJOB-010 — the gate skips registration and the banner', () => { + it('CSJOB-010 — the gate skips registration and the banner', async () => { const { job, registrar } = make('5', false); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); expect(logMock.logInfo).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 4acb12ed1..d53b066ac 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -88,7 +88,7 @@ afterAll(() => { }); describe('exportICS', () => { - it('TRIP-SVC-001: returns VCALENDAR wrapper', () => { + it('TRIP-SVC-001: returns VCALENDAR wrapper', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'My Vacation', @@ -96,13 +96,13 @@ describe('exportICS', () => { end_date: '2025-06-07', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('BEGIN:VCALENDAR'); expect(ics).toContain('END:VCALENDAR'); }); - it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => { + it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Summer Holiday', @@ -110,7 +110,7 @@ describe('exportICS', () => { end_date: '2025-06-07', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20250601'); // DTEND is exclusive — the day *after* the last day, or the trip loses a day. @@ -130,7 +130,7 @@ describe('exportICS', () => { // lands a day early, and since DTEND is exclusive the trip's last day was dropped. // Only invisible in CI because containers default to TZ=UTC. for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) { - it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => { + it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, async () => { process.env.TZ = tz; const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { @@ -139,28 +139,28 @@ describe('exportICS', () => { end_date: '2026-03-30', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260328'); expect(ics).toContain('DTEND;VALUE=DATE:20260331'); }); } - it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => { + it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', async () => { process.env.TZ = 'Asia/Tokyo'; const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' }); const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 }); createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260330'); expect(ics).toContain('DTEND;VALUE=DATE:20260331'); }); }); - it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => { + it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -171,13 +171,13 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') .run('2025-06-02T09:00', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART:20250602T090000'); expect(ics).not.toContain('DTSTART;VALUE=DATE'); }); - it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => { + it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -188,12 +188,12 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=? WHERE id=?') .run('2025-06-02', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20250602'); }); - it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => { + it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -213,51 +213,51 @@ describe('exportICS', () => { reservation.id ); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('Airline: Air Test'); expect(ics).toContain('Flight: AT100'); }); - it('TRIP-SVC-006: special characters in title are escaped', () => { + it('TRIP-SVC-006: special characters in title are escaped', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('Trip\\; First\\, Best'); }); - it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => { - expect(() => svc.exportICS(99999)).toThrow(); + it('TRIP-SVC-007: throws NotFoundError for non-existent trip', async () => { + await expect(svc.exportICS(99999)).rejects.toThrow(); }); - it('TRIP-SVC-008: returns a filename derived from trip title', () => { + it('TRIP-SVC-008: returns a filename derived from trip title', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' }); - const { filename } = svc.exportICS(trip.id); + const { filename } = await svc.exportICS(trip.id); expect(filename).toMatch(/My.Trip.2025\.ics/); }); // JS \s admitted U+3000 (and \v/\f) into the filename, and Node's header // validation then threw ERR_INVALID_CHAR on the export route (#2165). - it('TRIP-SVC-008b: ideographic whitespace in the title folds to _ in the filename', () => { + it('TRIP-SVC-008b: ideographic whitespace in the title folds to _ in the filename', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' }); - expect(svc.exportICS(trip.id).filename).toBe('___4_5_.ics'); + expect((await svc.exportICS(trip.id)).filename).toBe('___4_5_.ics'); }); - it('TRIP-SVC-008c: vertical tab and form feed fold too', () => { + it('TRIP-SVC-008c: vertical tab and form feed fold too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'A\vB\fC' }); - expect(svc.exportICS(trip.id).filename).toBe('A_B_C.ics'); + expect((await svc.exportICS(trip.id)).filename).toBe('A_B_C.ics'); }); - it('TRIP-SVC-009: reservation with end time includes DTEND', () => { + it('TRIP-SVC-009: reservation with end time includes DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -268,12 +268,12 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTEND:20250602T160000'); }); - it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', () => { + it('TRIP-SVC-024: flight with endpoint times but no reservation_time is included', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -288,7 +288,7 @@ describe('exportICS', () => { insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:CDG → JFK'); // Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the @@ -302,7 +302,7 @@ describe('exportICS', () => { expect(ics).toContain('Route: CDG → JFK'); }); - it('TRIP-SVC-024a: a flight that also carries reservation_time still uses per-side endpoint zones', () => { + it('TRIP-SVC-024a: a flight that also carries reservation_time still uses per-side endpoint zones', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -320,7 +320,7 @@ describe('exportICS', () => { insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); @@ -329,7 +329,7 @@ describe('exportICS', () => { expect(ics).not.toContain('DTEND:20250602T120000'); }); - it('TRIP-SVC-024c: a one-sided transport keeps the multi-day DTEND from reservation_end_time', () => { + it('TRIP-SVC-024c: a one-sided transport keeps the multi-day DTEND from reservation_end_time', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rental Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Rental car', type: 'car_rental' }); @@ -342,13 +342,13 @@ describe('exportICS', () => { 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run(reservation.id, 'from', 0, 'Berlin', null, 52.5, 13.4, 'Europe/Berlin', '10:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T100000'); expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250609T100000'); }); - it('TRIP-SVC-024d: an untimed arrival endpoint still takes its DTEND from reservation_end_time', () => { + it('TRIP-SVC-024d: an untimed arrival endpoint still takes its DTEND from reservation_end_time', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Train Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'ICE 1234', type: 'train' }); @@ -362,13 +362,13 @@ describe('exportICS', () => { // on the reservation itself. insertEp.run(reservation.id, 'to', 1, 'Wien Hbf', null, 48.2, 16.4, 'Europe/Vienna', null, '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T080000'); expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250602T143000'); }); - it('TRIP-SVC-024e: a lone timed endpoint without reservation_end_time stays a DTSTART', () => { + it('TRIP-SVC-024e: a lone timed endpoint without reservation_end_time stays a DTSTART', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Ferry Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Ferry', type: 'ferry' }); @@ -378,7 +378,7 @@ describe('exportICS', () => { 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run(reservation.id, 'from', 0, 'Dover', null, 51.1, 1.3, 'Europe/London', '07:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/London:20250602T070000'); // Nothing to end it with, so no DTEND may be invented (the trip's own all-day @@ -386,7 +386,7 @@ describe('exportICS', () => { expect(ics).not.toContain('DTEND;TZID='); }); - it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { + it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' }); @@ -399,15 +399,15 @@ describe('exportICS', () => { insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02'); insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02'); - let ics = ''; - expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow(); + let ics: string; + ics = (await svc.exportICS(trip.id)).ics; // Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE. expect(ics).toContain('DTSTART:20250602T090000'); expect(ics).not.toContain('TZID=Not/AZone'); expect(ics).not.toContain('garbage'); }); - it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', () => { + it('TRIP-SVC-025: flight endpoint with no local_date is skipped (relative Day-N trips)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Relative Trip' }); const reservation = createReservation(testDb, trip.id, { @@ -419,12 +419,12 @@ describe('exportICS', () => { 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('SUMMARY:Timeless Flight'); }); - it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', () => { + it('TRIP-SVC-026: timed assignment gets a TZID derived from the place coordinates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' }); const day = createDay(testDb, trip.id, { date: '2025-06-02' }); @@ -435,14 +435,14 @@ describe('exportICS', () => { .prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?') .run('09:00', assignment.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000'); expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); expect(ics).not.toContain('DTSTART:20250602T090000'); }); - it('CAL-011: a reservation_time with seconds is kept as is and a date-only end time emits no DTEND', () => { + it('CAL-011: a reservation_time with seconds is kept as is and a date-only end time emits no DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Second Precision' }); const reservation = createReservation(testDb, trip.id, { title: 'Guided Walk', type: 'activity' }); @@ -453,7 +453,7 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2025-06-02T14:00:00', '2025-06-03', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART:20250602T140000'); // The end value carries no clock time, so it cannot be a DTEND for a timed @@ -462,7 +462,7 @@ describe('exportICS', () => { expect(ics).not.toContain('DTEND'); }); - it('CAL-012: reservations with no placeable time are dropped instead of emitting a broken VEVENT', () => { + it('CAL-012: reservations with no placeable time are dropped instead of emitting a broken VEVENT', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Timeless' }); // Relative "Day N" trips store a clock time without a date; there is nothing @@ -473,7 +473,7 @@ describe('exportICS', () => { const noEndpoints = createReservation(testDb, trip.id, { title: 'Endpointless Train', type: 'transport' }); testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(noEndpoints.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('SUMMARY:Floating Dinner'); expect(ics).not.toContain('SUMMARY:Endpointless Train'); @@ -482,7 +482,7 @@ describe('exportICS', () => { expect(ics).not.toContain('BEGIN:VEVENT'); }); - it('CAL-013: endpoints without a stored timezone fall back to their coordinates', () => { + it('CAL-013: endpoints without a stored timezone fall back to their coordinates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'No Stored Zone' }); const reservation = createReservation(testDb, trip.id, { title: 'CDG to JFK', type: 'flight' }); @@ -496,13 +496,13 @@ describe('exportICS', () => { insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, null, '09:00', '2025-06-02'); insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, null, '12:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); }); - it('CAL-014: a single-endpoint transport emits DTSTART only', () => { + it('CAL-014: a single-endpoint transport emits DTSTART only', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'One Way' }); const reservation = createReservation(testDb, trip.id, { title: 'Airport Transfer', type: 'transport' }); @@ -513,13 +513,13 @@ describe('exportICS', () => { 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); expect(ics).not.toContain('DTEND'); }); - it('CAL-015: a timed assignment carries its notes and address into DESCRIPTION and LOCATION', () => { + it('CAL-015: a timed assignment carries its notes and address into DESCRIPTION and LOCATION', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Guided Day' }); const day = createDay(testDb, trip.id, { date: '2025-06-02' }); @@ -530,7 +530,7 @@ describe('exportICS', () => { const withoutNotes = createDayAssignment(testDb, day.id, place.id); testDb.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?').run('11:00', withoutNotes.id); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); // Notes come first and the address is appended on its own line; dropping the // separator would glue them into one unreadable run. @@ -540,7 +540,7 @@ describe('exportICS', () => { expect(ics).toContain('LOCATION:Rue de Rivoli'); }); - it('CAL-016: a day without a date is skipped and a titled day uses its own title', () => { + it('CAL-016: a day without a date is skipped and a titled day uses its own title', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Mixed Days' }); // Days of a relative "Day N" trip have no date; they cannot be placed on a @@ -551,7 +551,7 @@ describe('exportICS', () => { const place = createPlace(testDb, trip.id, { name: 'Bare Spot' }); createDayAssignment(testDb, named.id, place.id); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); expect(ics).not.toContain('Ghost Day'); expect(ics).not.toContain('never exported'); @@ -561,7 +561,7 @@ describe('exportICS', () => { expect(ics).toContain('DESCRIPTION:• Bare Spot\r\n'); }); - it('CAL-017: a reservation with no type, metadata or notes emits no empty DESCRIPTION', () => { + it('CAL-017: a reservation with no type, metadata or notes emits no empty DESCRIPTION', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Bare' }); const reservation = createReservation(testDb, trip.id, { title: 'Bare Booking', type: 'hotel' }); @@ -571,13 +571,13 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET type=NULL, reservation_time=? WHERE id=?') .run('2025-06-02', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Bare Booking'); expect(ics).not.toContain('DESCRIPTION'); }); - it('CAL-018: flight metadata with only one airport emits only that side of the route', () => { + it('CAL-018: flight metadata with only one airport emits only that side of the route', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Half Routes' }); const inbound = createReservation(testDb, trip.id, { title: 'Inbound', type: 'flight' }); @@ -588,13 +588,13 @@ describe('exportICS', () => { setMeta.run('2025-06-02T09:00', JSON.stringify({ arrival_airport: 'JFK' }), inbound.id); setMeta.run('2025-06-09T09:00', JSON.stringify({ departure_airport: 'CDG' }), outbound.id); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); expect(ics).toContain('DESCRIPTION:Type: flight\\nTo: JFK\r\n'); expect(ics).toContain('DESCRIPTION:Type: flight\\nFrom: CDG\r\n'); }); - it('CAL-019: legs and endpoints without names produce no Route line', () => { + it('CAL-019: legs and endpoints without names produce no Route line', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Nameless' }); const legs = createReservation(testDb, trip.id, { title: 'Codeless Legs', type: 'flight' }); @@ -612,14 +612,14 @@ describe('exportICS', () => { insertEp.run(ferry.id, 'from', 0, '', null, 1.0, 1.0, null, null, '2025-06-03'); insertEp.run(ferry.id, 'to', 1, '', null, 1.1, 1.1, null, null, '2025-06-03'); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); expect(ics).toContain('SUMMARY:Codeless Legs'); expect(ics).toContain('SUMMARY:Nameless Ferry'); expect(ics).not.toContain('Route:'); }); - it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', () => { + it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Layover' }); const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' }); @@ -632,7 +632,7 @@ describe('exportICS', () => { flight.id, ); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); expect(ics).toContain( 'DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND' @@ -640,7 +640,7 @@ describe('exportICS', () => { ); }); - it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', () => { + it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Layover' }); const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' }); @@ -650,27 +650,27 @@ describe('exportICS', () => { flight.id, ); - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); // Byte parity for every feed that exists today: the description ends after // the route line, so no subscriber sees a changed event. expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n'); }); - it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => { + it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', async () => { const { user } = createUser(testDb); // The title is only NOT NULL, not non-empty; an empty one used to produce // "SUMMARY:" and a filename of ".ics". const trip = createTrip(testDb, user.id, { title: '', start_date: '2025-06-01', end_date: '2025-06-02' }); - const { ics, filename } = svc.exportICS(trip.id); + const { ics, filename } = await svc.exportICS(trip.id); expect(ics).toContain('X-WR-CALNAME:TREK Trip'); expect(ics).toContain('SUMMARY:Trip'); expect(filename).toBe('trek-trip.ics'); }); - it('CAL-021: a corrupt day date degrades the VTIMEZONE offset instead of throwing', () => { + it('CAL-021: a corrupt day date degrades the VTIMEZONE offset instead of throwing', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Corrupt Date' }); // days.date is free-form TEXT; a legacy/imported row can hold a date that no @@ -682,8 +682,8 @@ describe('exportICS', () => { const assignment = createDayAssignment(testDb, day.id, place.id); testDb.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?').run('09:00', assignment.id); - let ics = ''; - expect(() => { ics = svc.exportICS(trip.id).ics; }).not.toThrow(); + let ics: string; + ics = (await svc.exportICS(trip.id)).ics; expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20251345T090000'); expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo'); @@ -691,7 +691,7 @@ describe('exportICS', () => { expect(ics).toContain('TZOFFSETTO:+0000'); }); - it('CAL-022: the timezone cache stays correct after it hits its 1000-entry bound', () => { + it('CAL-022: the timezone cache stays correct after it hits its 1000-entry bound', async () => { const { user } = createUser(testDb); const bulkTrip = createTrip(testDb, user.id, { title: 'Cache Filler' }); const realTrip = createTrip(testDb, user.id, { title: 'After The Bound' }); @@ -710,7 +710,7 @@ describe('exportICS', () => { } })(); - const bulk = svc.exportICS(bulkTrip.id).ics; + const bulk = (await svc.exportICS(bulkTrip.id)).ics; // Every one of them has to reach the zone check — otherwise the bound is never // approached and this case would pass without exercising anything. expect(bulk).toContain('SUMMARY:Bulk 0'); @@ -725,12 +725,12 @@ describe('exportICS', () => { // Clearing the cache must make later zones be re-checked. A regression that // evicted by writing `false` instead would silently strip the TZID from every // export after the thousandth distinct zone string. - const { ics } = svc.exportICS(realTrip.id); + const { ics } = await svc.exportICS(realTrip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); }); - it('CAL-032: an all-day booking with an end date spans the whole range', () => { + it('CAL-032: an all-day booking with an end date spans the whole range', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Festival Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Music Festival', type: 'event' }); @@ -741,13 +741,13 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-08-26', '2026-08-30', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // DTEND is exclusive, so the day after the last day of the booking. expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831'); }); - it('CAL-033: a timed end time on an all-day booking still yields a VALUE=DATE DTEND', () => { + it('CAL-033: a timed end time on an all-day booking still yields a VALUE=DATE DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Mixed Precision' }); const reservation = createReservation(testDb, trip.id, { title: 'Cottage', type: 'other' }); @@ -757,13 +757,13 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-08-26', '2026-08-30T11:00', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260826\r\nDTEND;VALUE=DATE:20260831'); expect(ics).not.toContain('DTEND:20260830T110000'); }); - it('CAL-034: an end date before the start date emits no DTEND', () => { + it('CAL-034: an end date before the start date emits no DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Backwards' }); const reservation = createReservation(testDb, trip.id, { title: 'Swapped Dates', type: 'event' }); @@ -773,13 +773,13 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-08-26', '2026-08-20', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260826'); expect(ics).not.toContain('DTEND'); }); - it('CAL-035: a clock-only end time on an all-day booking emits no DTEND', () => { + it('CAL-035: a clock-only end time on an all-day booking emits no DTEND', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Half Timed' }); const reservation = createReservation(testDb, trip.id, { title: 'Workshop', type: 'other' }); @@ -789,7 +789,7 @@ describe('exportICS', () => { .prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-08-26', '11:00', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260826'); expect(ics).not.toContain('DTEND'); @@ -850,19 +850,19 @@ describe('accommodations', () => { return { stayId, placeId: place.id }; }; - it('CAL-025: a stay covers every night as one all-day event, not just the arrival day', () => { + it('CAL-025: a stay covers every night as one all-day event, not just the arrival day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // DTEND is exclusive, so the day after checkout. expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); expect(ics).toContain('SUMMARY:Hotel Bellevue'); }); - it('CAL-025b: a stay that records both ends of its clock drops the all-day block (#2136)', () => { + it('CAL-025b: a stay that records both ends of its clock drops the all-day block (#2136)', async () => { // The two markers already say when to arrive and when to leave, which is the // part a subscriber can act on. Keeping the block as well buries the week // under a bar that repeats what the markers say. @@ -873,7 +873,7 @@ describe('accommodations', () => { check_in: '15:00', check_out: '11:00', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); @@ -884,7 +884,7 @@ describe('accommodations', () => { expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000'); }); - it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', () => { + it('CAL-025d: a second room on the same stay keeps its block, since no marker names it', async () => { // The markers are emitted once per stay and titled from its lowest-id // booking, so dropping every block would leave the second one with nothing. const { user } = createUser(testDb); @@ -899,13 +899,13 @@ describe('accommodations', () => { VALUES (?, ?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?) `).run(trip.id, day.id, String(stayId)); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Bellevue second room'); expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); }); - it('CAL-025e: the markers of a fully timed stay carry what the dropped block said (#2136)', () => { + it('CAL-025e: the markers of a fully timed stay carry what the dropped block said (#2136)', async () => { // Losing the block must not lose the confirmation number and the notes with // it, the same handover the split window bookings do (#2068). const { user } = createUser(testDb); @@ -916,7 +916,7 @@ describe('accommodations', () => { confirmation: 'HTL-77291', notes: 'Key box code 4711', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); const unfolded = ics.replaceAll('\r\n ', ''); const descriptions = unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel')); @@ -925,7 +925,7 @@ describe('accommodations', () => { expect(descriptions[0]).toContain('Key box code 4711'); }); - it('CAL-025f: a stay whose place has no address takes the booking location instead', () => { + it('CAL-025f: a stay whose place has no address takes the booking location instead', async () => { // place_id is nullable (ON DELETE SET NULL) and an address is optional, so // without the fallback the only two events left name no address at all. const { user } = createUser(testDb); @@ -936,12 +936,12 @@ describe('accommodations', () => { address: null, location: '12 Hotel Street', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics.match(/LOCATION:12 Hotel Street/g)).toHaveLength(2); }); - it('CAL-025g: a stay that keeps its block does not repeat the description on its markers', () => { + it('CAL-025g: a stay that keeps its block does not repeat the description on its markers', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { @@ -949,7 +949,7 @@ describe('accommodations', () => { check_in: '15:00', confirmation: 'HTL-77291', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // The block is still emitted and still says it; saying it a second time on // the check-in marker would change a feed that already went out. @@ -957,19 +957,19 @@ describe('accommodations', () => { expect(unfolded.split('\r\n').filter(l => l.startsWith('DESCRIPTION:Type: hotel'))).toHaveLength(1); }); - it('CAL-025c: knowing only one end keeps the block, since nothing else carries the other', () => { + it('CAL-025c: knowing only one end keeps the block, since nothing else carries the other', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); expect(ics).not.toContain('SUMMARY:Check-out'); }); - it('CAL-026: check-in and check-out become their own timed events in the stay zone', () => { + it('CAL-026: check-in and check-out become their own timed events in the stay zone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { @@ -977,7 +977,7 @@ describe('accommodations', () => { check_in: '15:00', check_in_end: '22:00', check_out: '11:00', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T150000'); @@ -988,12 +988,12 @@ describe('accommodations', () => { expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); }); - it('CAL-026b: a check-in without an until-clock reads as one hour, not a point (#2136)', () => { + it('CAL-026b: a check-in without an until-clock reads as one hour, not a point (#2136)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_in: '15:00' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // Reporter variant 2: the lone timed end becomes a one-hour slot AND the // all-day block stays, since it is the only carrier of the other end's date. @@ -1002,23 +1002,23 @@ describe('accommodations', () => { expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); }); - it('CAL-026c: the check-out marker carries the same default hour', () => { + it('CAL-026c: the check-out marker carries the same default hour', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '11:00' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260712T110000'); expect(ics).toContain('DTEND;TZID=Europe/Paris:20260712T120000'); }); - it('CAL-026d: a late check-out rolls the DTEND over midnight', () => { + it('CAL-026d: a late check-out rolls the DTEND over midnight', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12', check_out: '23:30' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // Without the day shift the DTEND would land before its DTSTART, and clients // drop an event whose end precedes its start. @@ -1026,7 +1026,7 @@ describe('accommodations', () => { expect(ics).toContain('DTEND;TZID=Europe/Paris:20260713T003000'); }); - it('CAL-026e: a check-in window that ends before it starts runs past midnight (#2136)', () => { + it('CAL-026e: a check-in window that ends before it starts runs past midnight (#2136)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { @@ -1034,7 +1034,7 @@ describe('accommodations', () => { check_in: '22:00', check_in_end: '02:00', check_out: '11:00', }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // A late-arrival window, and nothing on the way in (REST, MCP, plugin SDK) // orders the two clocks. Emitted as recorded the DTEND precedes the DTSTART, @@ -1043,35 +1043,35 @@ describe('accommodations', () => { expect(ics).toContain('DTEND;TZID=Europe/Paris:20260708T020000'); }); - it('CAL-027: a stay without times emits the all-day range and nothing else', () => { + it('CAL-027: a stay without times emits the all-day range and nothing else', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('Check-in'); expect(ics).not.toContain('Check-out'); }); - it('CAL-028: a stay whose end day lost its date falls back to the arrival day', () => { + it('CAL-028: a stay whose end day lost its date falls back to the arrival day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: null, check_out: '11:00' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260708'); expect(ics).toContain('SUMMARY:Check-out: Hotel Bellevue'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T110000'); }); - it('CAL-029: a stay with no reservation still contributes its check-in event', () => { + it('CAL-029: a stay with no reservation still contributes its check-in event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); createStay(trip.id, { start: '2026-07-07', end: '2026-07-09', check_in: '15:00', withReservation: false }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Check-in: Hotel Bellevue'); // No stay event without a reservation to carry it — the accommodation itself @@ -1079,31 +1079,31 @@ describe('accommodations', () => { expect(ics).not.toContain('DTEND;VALUE=DATE:20260710'); }); - it('CAL-030: a hotel reservation without a stay keeps its old single-day event', () => { + it('CAL-030: a hotel reservation without a stay keeps its old single-day event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); const reservation = createReservation(testDb, trip.id, { title: 'Airbnb', type: 'hotel' }); testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('2026-07-07', reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260707'); expect(ics).not.toContain('DTEND;VALUE=DATE'); }); - it('CAL-031: a dateless stay is skipped instead of emitting a broken event', () => { + it('CAL-031: a dateless stay is skipped instead of emitting a broken event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Someday' }); createStay(trip.id, { start: null, end: null, check_in: '15:00', check_out: '11:00' }); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('Check-in'); expect(ics).not.toContain('Check-out'); expect(ics).not.toContain('DTSTART;VALUE=DATE:'); }); - it('CAL-036: two bookings on one stay emit the check-in/check-out markers once', () => { + it('CAL-036: two bookings on one stay emit the check-in/check-out markers once', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); const { stayId } = createStay(trip.id, { @@ -1118,7 +1118,7 @@ describe('accommodations', () => { VALUES (?, 'Bellevue second room', NULL, 'confirmed', 'hotel', ?) `).run(trip.id, String(stayId)); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics.match(/UID:trek-checkin-\d+@trek/g)).toHaveLength(1); expect(ics.match(/UID:trek-checkout-\d+@trek/g)).toHaveLength(1); @@ -1129,7 +1129,7 @@ describe('accommodations', () => { expect(ics).toContain('SUMMARY:Bellevue second room'); }); - it('CAL-060: the stop a booking puts on its check-in day does not list the hotel a second time', () => { + it('CAL-060: the stop a booking puts on its check-in day does not list the hotel a second time', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris' }); const { stayId, placeId } = createStay(trip.id, { start: '2026-07-07', end: '2026-07-12' }); @@ -1144,7 +1144,7 @@ describe('accommodations', () => { const museum = createPlace(testDb, trip.id, { name: 'Louvre' }); createDayAssignment(testDb, stay.start_day_id, museum.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;VALUE=DATE:20260707\r\nDTEND;VALUE=DATE:20260713'); expect(ics.match(/Hotel Bellevue/g)).toHaveLength(1); @@ -1172,7 +1172,7 @@ describe('car rentals', () => { ).run(reservationId, role, sequence, name, null, lat, lng, timezone, local_time, local_date); }; - it('CAL-037: a rental with from/to endpoints produces a pickup and a drop-off event at the right local times and zones', () => { + it('CAL-037: a rental with from/to endpoints produces a pickup and a drop-off event at the right local times and zones', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Hertz Rental', type: 'car' }); @@ -1181,7 +1181,7 @@ describe('car rentals', () => { insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); insertEndpoint(reservation.id, 'to', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Pickup: Hertz Rental'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); @@ -1192,7 +1192,7 @@ describe('car rentals', () => { expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin'); }); - it('CAL-038: role-less endpoints fall back to first/last by sequence', () => { + it('CAL-038: role-less endpoints fall back to first/last by sequence', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Avis Rental', type: 'car' }); @@ -1201,7 +1201,7 @@ describe('car rentals', () => { insertEndpoint(reservation.id, 'stop', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); insertEndpoint(reservation.id, 'stop', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Pickup: Avis Rental'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); @@ -1209,7 +1209,7 @@ describe('car rentals', () => { expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000'); }); - it('CAL-039: a rental with only reservation_time/reservation_end_time still produces both events', () => { + it('CAL-039: a rental with only reservation_time/reservation_end_time still produces both events', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const place = createPlace(testDb, trip.id, { name: 'Rental Desk', lat: 48.8566, lng: 2.3522 }); @@ -1217,7 +1217,7 @@ describe('car rentals', () => { testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=?, place_id=? WHERE id=?') .run('2026-07-07T09:00', '2026-07-14T10:30', place.id, reservation.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Pickup: Budget Rental'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); @@ -1225,7 +1225,7 @@ describe('car rentals', () => { expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260714T103000'); }); - it('CAL-040: a rental with a single endpoint does not emit a bogus second event', () => { + it('CAL-040: a rental with a single endpoint does not emit a bogus second event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Sixt Rental', type: 'car' }); @@ -1234,13 +1234,13 @@ describe('car rentals', () => { // booking. The lone endpoint must not be reused as the drop-off too. insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Pickup: Sixt Rental'); expect(ics).not.toContain('Drop-off'); }); - it('CAL-041: a rental with no usable clock emits no window events and its existing behaviour is unchanged', () => { + it('CAL-041: a rental with no usable clock emits no window events and its existing behaviour is unchanged', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const reservation = createReservation(testDb, trip.id, { title: 'Untimed Rental', type: 'car' }); @@ -1249,7 +1249,7 @@ describe('car rentals', () => { // fallback still applies and must be unaffected by the new marker events. insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', null, '2026-07-07'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('Pickup'); expect(ics).not.toContain('Drop-off'); @@ -1257,7 +1257,7 @@ describe('car rentals', () => { expect(ics).toContain('DTSTART;VALUE=DATE:20260707'); }); - it('CAL-042: a clock that already carries seconds keeps its zone', () => { + it('CAL-042: a clock that already carries seconds keeps its zone', async () => { // The booking import stores what KItinerary hands it, which is "10:00:00". // fmtDateTime's time-only branch appended "00" regardless, so the value came // out 17 characters long, dtLine refused to attach the TZID and the event @@ -1270,7 +1270,7 @@ describe('car rentals', () => { insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00:00', '2026-07-07'); insertEndpoint(reservation.id, 'to', 1, 'Lyon Office', 45.764, 4.8357, 'Europe/Paris', '18:30:00', '2026-07-07'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T183000'); @@ -1280,14 +1280,14 @@ describe('car rentals', () => { // ── Window bookings: two hand-overs, not one block (#2068) ───────────────── - it('CAL-045: multi-day parking becomes a drop-off and a pick-up instead of one block', () => { + it('CAL-045: multi-day parking becomes a drop-off and a pick-up instead of one block', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const parking = createReservation(testDb, trip.id, { title: 'Airport P4', type: 'parking' }); testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-07-01T06:30', '2026-07-10T22:15', parking.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // The two hand-overs, an hour each. Parking is dropped off first. expect(ics).toContain('SUMMARY:Drop-off: Airport P4'); @@ -1301,14 +1301,14 @@ describe('car rentals', () => { expect(ics).not.toContain('DTEND:20260710T221500'); }); - it('CAL-046: a same-day parking is one sitting and keeps its single event', () => { + it('CAL-046: a same-day parking is one sitting and keeps its single event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const parking = createReservation(testDb, trip.id, { title: 'Garage', type: 'parking' }); testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-07-01T08:00', '2026-07-01T18:30', parking.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Garage\r\n'); expect(ics).toContain('DTSTART:20260701T080000'); @@ -1317,7 +1317,7 @@ describe('car rentals', () => { expect(ics).not.toContain('Pickup: Garage'); }); - it('CAL-047: a rental typed with days but no clock finally reaches the feed', () => { + it('CAL-047: a rental typed with days but no clock finally reaches the feed', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const first = createDay(testDb, trip.id, { date: '2026-07-03' }); @@ -1328,7 +1328,7 @@ describe('car rentals', () => { testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=? WHERE id=?') .run(first.id, last.id, rental.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Pickup: Sixt'); expect(ics).toContain('DTSTART;VALUE=DATE:20260703'); @@ -1336,7 +1336,7 @@ describe('car rentals', () => { expect(ics).toContain('DTSTART;VALUE=DATE:20260708'); }); - it('CAL-048: a split rental keeps its zones and everything the block used to say', () => { + it('CAL-048: a split rental keeps its zones and everything the block used to say', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const rental = createReservation(testDb, trip.id, { title: 'Hertz', type: 'car' }); @@ -1345,7 +1345,7 @@ describe('car rentals', () => { insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); insertEndpoint(rental.id, 'to', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); expect(ics).toContain('DTEND;TZID=Europe/Paris:20260707T100000'); @@ -1362,7 +1362,7 @@ describe('car rentals', () => { // #1453 — the block is the only carrier of the return time when just one side // was geocoded, so a one-sided rental must keep it. - it('CAL-049: a one-sided rental keeps its block rather than losing the return', () => { + it('CAL-049: a one-sided rental keeps its block rather than losing the return', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const place = createPlace(testDb, trip.id, { name: 'Rental Desk', lat: 48.8566, lng: 2.3522 }); @@ -1370,7 +1370,7 @@ describe('car rentals', () => { testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=?, place_id=? WHERE id=?') .run('2026-07-02T10:00', '2026-07-09T10:00', place.id, rental.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // Both sides resolve off reservation_time/-_end_time, so this one does split, // and the return keeps the pickup's zone instead of floating. @@ -1379,21 +1379,21 @@ describe('car rentals', () => { expect(ics).not.toMatch(/DTSTART:20260709T100000/); }); - it('CAL-050: a rental with only a pickup endpoint is left exactly as it was', () => { + it('CAL-050: a rental with only a pickup endpoint is left exactly as it was', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const rental = createReservation(testDb, trip.id, { title: 'Solo', type: 'car' }); testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(rental.id); insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Solo\r\n'); expect(ics).toContain('SUMMARY:Pickup: Solo'); expect(ics).not.toContain('Drop-off'); }); - it('CAL-051: any transport pinned to days without a clock reaches the feed too', () => { + it('CAL-051: any transport pinned to days without a clock reaches the feed too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const first = createDay(testDb, trip.id, { date: '2026-07-03' }); @@ -1402,7 +1402,7 @@ describe('car rentals', () => { testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=? WHERE id=?') .run(first.id, last.id, bus.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); // Not a window booking, so it stays one block — it just exists now. expect(ics).toContain('SUMMARY:Night Bus'); @@ -1411,7 +1411,7 @@ describe('car rentals', () => { expect(ics).not.toContain('Pickup: Night Bus'); }); - it('CAL-052: a split rental whose endpoints carry no zone falls back to their coordinates', () => { + it('CAL-052: a split rental whose endpoints carry no zone falls back to their coordinates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const rental = createReservation(testDb, trip.id, { title: 'Europcar', type: 'car' }); @@ -1420,7 +1420,7 @@ describe('car rentals', () => { insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, null, '09:00', '2026-07-07'); insertEndpoint(rental.id, 'to', 1, 'Berlin Office', 52.5, 13.4, null, '10:30', '2026-07-14'); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000'); @@ -1428,21 +1428,21 @@ describe('car rentals', () => { // reservation_end_time is frequently a bare clock next to the booking's own // date, which puts both hand-overs on the same day — so it is one sitting. - it('CAL-053: a bare end clock resolves against the start date rather than splitting', () => { + it('CAL-053: a bare end clock resolves against the start date rather than splitting', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const parking = createReservation(testDb, trip.id, { title: 'Street Bay', type: 'parking' }); testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') .run('2026-07-01T08:00', '18:30', parking.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Street Bay\r\n'); expect(ics).not.toContain('Drop-off: Street Bay'); expect(ics).not.toContain('Pickup: Street Bay'); }); - it('CAL-054: an unsplit rental with a day but no clock emits no hand-over of its own', () => { + it('CAL-054: an unsplit rental with a day but no clock emits no hand-over of its own', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); const day = createDay(testDb, trip.id, { date: '2026-07-03' }); @@ -1452,7 +1452,7 @@ describe('car rentals', () => { testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=NULL WHERE id=?') .run(day.id, rental.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Dayless\r\n'); expect(ics).toContain('DTSTART;VALUE=DATE:20260703'); @@ -1481,7 +1481,7 @@ describe('staged bookings', () => { return { stayId, placeId: place.id }; }; - it('CAL-055: a staged booking is left out of the calendar, confirmation number included', () => { + it('CAL-055: a staged booking is left out of the calendar, confirmation number included', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Kyoto' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); @@ -1491,7 +1491,7 @@ describe('staged bookings', () => { const live = createReservation(testDb, trip.id, { title: 'Booked Flight', type: 'flight', day_id: day.id }); testDb.prepare("UPDATE reservations SET reservation_time='2026-09-01T12:00' WHERE id=?").run(live.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Booked Flight'); expect(ics).not.toContain('Parked Flight'); @@ -1499,7 +1499,7 @@ describe('staged bookings', () => { expect(ics).not.toContain('ABC123'); }); - it('CAL-056: a booking created before the column existed still exports', () => { + it('CAL-056: a booking created before the column existed still exports', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon' }); const day = createDay(testDb, trip.id, { date: '2026-09-02' }); @@ -1508,24 +1508,24 @@ describe('staged bookings', () => { const old = createReservation(testDb, trip.id, { title: 'Legacy Booking', type: 'flight', day_id: day.id }); testDb.prepare("UPDATE reservations SET reservation_time='2026-09-02T09:00' WHERE id=?").run(old.id); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('SUMMARY:Legacy Booking'); }); - it('CAL-057: an accommodation whose only booking is staged emits no stay, check-in or check-out', () => { + it('CAL-057: an accommodation whose only booking is staged emits no stay, check-in or check-out', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Osaka' }); stayWith(trip.id, [{ state: 'staged', title: 'Parked Hotel' }]); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).not.toContain('Parked Hotel'); expect(ics).not.toMatch(/UID:trek-checkin-\d+@trek/); expect(ics).not.toMatch(/UID:trek-checkout-\d+@trek/); }); - it('CAL-058: an accommodation with no linked booking at all still emits its stay', () => { + it('CAL-058: an accommodation with no linked booking at all still emits its stay', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Nara' }); // The regression this pins: a plain EXISTS(live) instead of @@ -1533,20 +1533,20 @@ describe('staged bookings', () => { // trips that are shared today. stayWith(trip.id, []); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('Hotel Bellevue'); expect(ics).toMatch(/UID:trek-checkin-\d+@trek/); }); - it('CAL-059: a stay with both a staged and a live booking takes its title from the live one', () => { + it('CAL-059: a stay with both a staged and a live booking takes its title from the live one', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Kobe' }); // The staged row gets the lower id, so the subquery's ORDER BY r.id ASC // would pick it without the predicate. stayWith(trip.id, [{ state: 'staged', title: 'Parked Name' }, { state: 'live', title: 'Real Name' }]); - const { ics } = svc.exportICS(trip.id); + const { ics } = await svc.exportICS(trip.id); expect(ics).toContain('Real Name'); expect(ics).not.toContain('Parked Name'); @@ -1554,7 +1554,7 @@ describe('staged bookings', () => { }); describe('folded quirk branches', () => { - it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { + it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Branchy' }); const day = createDay(testDb, trip.id, { date: '2025-06-02' }); @@ -1582,7 +1582,7 @@ describe('folded quirk branches', () => { insertEp.run(transport.id, 'to', 1, 'Pier B', 'PB', 1.1, 1.1, null, null, '2025-06-03'); // Unfold the RFC 5545 75-octet folding so substring assertions see whole lines. - const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, ''); + const ics = (await svc.exportICS(trip.id)).ics.replace(/\r\n /g, ''); expect(ics).toContain('SUMMARY:Day 1'); expect(ics).toContain('• Untimed Spot (1 Rue Test) — bring hat'); @@ -1607,7 +1607,7 @@ describe('foldICS', () => { const octets = (s: string) => Buffer.from(s, 'utf8').length; const unfold = (s: string) => s.replace(/\r\n /g, ''); - it('CAL-023: lines up to 75 octets are untouched and longer ones fold at 75 then 74', () => { + it('CAL-023: lines up to 75 octets are untouched and longer ones fold at 75 then 74', async () => { const short = 'SUMMARY:' + 'a'.repeat(67); // exactly 75 octets expect(foldICS(short)).toBe(short); @@ -1622,7 +1622,7 @@ describe('foldICS', () => { expect(unfold(foldICS(long))).toBe(long); }); - it('CAL-024: a two-byte codepoint straddling the 75th octet is not split', () => { + it('CAL-024: a two-byte codepoint straddling the 75th octet is not split', async () => { // The fold boundary lands inside "é": without the backoff the two halves are // decoded separately and the title arrives as U+FFFD in every client. const line = 'DESCRIPTION:' + 'é'.repeat(60); @@ -1635,7 +1635,7 @@ describe('foldICS', () => { for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75); }); - it('CAL-025: a four-byte codepoint backs the split off by more than one octet', () => { + it('CAL-025: a four-byte codepoint backs the split off by more than one octet', async () => { // An emoji needs the backoff to run several times before it reaches a lead // byte; stopping after a single step still cuts the sequence. const line = 'SUMMARY:' + '🎌'.repeat(40); @@ -1647,7 +1647,7 @@ describe('foldICS', () => { for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75); }); - it('CAL-026: folding is applied per content line, so short lines around a long one survive', () => { + it('CAL-026: folding is applied per content line, so short lines around a long one survive', async () => { const ics = 'BEGIN:VEVENT\r\nSUMMARY:' + 'ü'.repeat(80) + '\r\nEND:VEVENT'; const folded = foldICS(ics); @@ -1664,7 +1664,7 @@ describe('foldICS', () => { // above pin individual lines, this one pins the whole document including the // order of the VTIMEZONE blocks and the folding. describe('serialised output', () => { - it('CAL-010: a trip with a zoned assignment, a note day and a flight serialises byte for byte', () => { + it('CAL-010: a trip with a zoned assignment, a note day and a flight serialises byte for byte', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Golden Trip', @@ -1689,7 +1689,7 @@ describe('serialised output', () => { // DTSTAMP is the wall clock and the UID numbers are autoincrement state shared // with every case above, so both are normalised: this pins the document, not // the minute it ran in or its position in the file. - const ics = svc.exportICS(trip.id).ics + const ics = (await svc.exportICS(trip.id)).ics .replace(/DTSTAMP:\d{8}T\d{6}Z/g, 'DTSTAMP:') .replace(/UID:trek-([a-z]+)-\d+@trek/g, 'UID:trek-$1-@trek'); @@ -1739,7 +1739,7 @@ describe('serialised output', () => { }); describe('CalendarService wiring', () => { - it('CAL-001: the module registers the service, so injection cannot silently fail', () => { + it('CAL-001: the module registers the service, so injection cannot silently fail', async () => { expectRegisteredProvider(CalendarModule, CalendarService); const exports = Reflect.getMetadata('exports', CalendarModule) as unknown[]; expect(Array.isArray(exports)).toBe(true); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index a24f65915..baf51fbe8 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -316,21 +316,21 @@ afterAll(() => { // ── list / getOne ──────────────────────────────────────────────────────────── describe('DawarichSuggestionsService — the review list', () => { - it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", () => { + it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", async () => { const { user: mine } = createUser(testDb); const { user: stranger } = createUser(testDb); const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' }); const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); - const list = svc.list(mine.id, {}); + const list = await svc.list(mine.id, {}); expect(list.suggestions.map((s) => s.id)).toEqual([mineId]); expect(list.suggestions.map((s) => s.name)).not.toContain('Their Kitchen'); // Asserted in both directions, so an id that happens to line up cannot carry it. - expect(svc.list(stranger.id, {}).suggestions.map((s) => s.id)).toEqual([strangerId]); + expect((await svc.list(stranger.id, {})).suggestions.map((s) => s.id)).toEqual([strangerId]); }); - it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', () => { + it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -342,24 +342,23 @@ describe('DawarichSuggestionsService — the review list', () => { seedSuggestion({ userId: user.id, tripId: otherTrip.id }); seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id }); - const byTrip = svc - .list(user.id, { tripId: trip.id }) + const byTrip = (await svc.list(user.id, { tripId: trip.id })) .suggestions.map((s) => s.id) .sort((a, b) => a - b); expect(byTrip).toEqual([onTrip, dismissed].sort((a, b) => a - b)); - expect(svc.list(user.id, { tripId: trip.id, state: 'dismissed' }).suggestions.map((s) => s.id)).toEqual([ + expect((await svc.list(user.id, { tripId: trip.id, state: 'dismissed' })).suggestions.map((s) => s.id)).toEqual([ dismissed, ]); // Another user's trip id is not a way in: the user clause still applies. - expect(svc.list(user.id, { tripId: strangerTrip.id }).suggestions).toEqual([]); + expect((await svc.list(user.id, { tripId: strangerTrip.id })).suggestions).toEqual([]); }); - it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', () => { + it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', async () => { const { user } = createUser(testDb); seedSuggestion({ userId: user.id }); - const list = svc.list(user.id, {}); + const list = await svc.list(user.id, {}); expect(dawarichStub.getConnection).toHaveBeenCalledWith(user.id); expect(list.connected).toBe(true); @@ -368,13 +367,13 @@ describe('DawarichSuggestionsService — the review list', () => { expect(list.lastSyncError).toBeNull(); }); - it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', () => { + it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); - expect(svc.getOne(user.id, theirs)).toBeNull(); - expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen'); + expect(await svc.getOne(user.id, theirs)).toBeNull(); + expect((await svc.getOne(stranger.id, theirs))?.name).toBe('Their Kitchen'); }); it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', async () => { @@ -396,7 +395,7 @@ describe('DawarichSuggestionsService — the review list', () => { ) .run(id); - const wire = svc.getOne(user.id, id)!; + const wire = (await svc.getOne(user.id, id))!; expect(wire.sourceStatus).toBe('confirmed'); expect(wire.sourceChanged).toBe(true); expect(wire.sourceMissing).toBe(true); @@ -798,29 +797,29 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { const id = seedSuggestion({ userId: user.id }); await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); - const back = svc.setState(user.id, id, 'new'); + const back = await svc.setState(user.id, id, 'new'); expect(back?.state).toBe('accepted'); expect(rowOf(id).state).toBe('accepted'); // Dismissing it is refused the same way — the tick it produced is its own thing now. - expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('accepted'); + expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('accepted'); expect(rowOf(id).target).toBe('bucket_list'); }); - it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', () => { + it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', async () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id }); - expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('dismissed'); - expect(svc.setState(user.id, id, 'new')?.state).toBe('new'); + expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('dismissed'); + expect((await svc.setState(user.id, id, 'new'))?.state).toBe('new'); }); - it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", () => { + it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const theirs = seedSuggestion({ userId: stranger.id }); - expect(svc.setState(user.id, theirs, 'dismissed')).toBeNull(); + expect(await svc.setState(user.id, theirs, 'dismissed')).toBeNull(); expect(rowOf(theirs).state).toBe('new'); }); }); @@ -828,7 +827,7 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => { // ── bucket-list confirmation ───────────────────────────────────────────────── describe('DawarichSuggestionsService — confirming a scan', () => { - it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', () => { + it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', async () => { const { user } = createUser(testDb); const open = seedBucketItem(user.id, { name: 'Hallstatt' }); const alreadyTicked = seedBucketItem(user.id, { @@ -837,7 +836,7 @@ describe('DawarichSuggestionsService — confirming a scan', () => { visitedSource: 'manual', }); - const updated = svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z'); + const updated = await svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z'); expect(updated).toBe(1); expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z'); @@ -847,20 +846,20 @@ describe('DawarichSuggestionsService — confirming a scan', () => { expect(bucketOf(alreadyTicked).visited_source).toBe('manual'); }); - it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", () => { + it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const mine = seedBucketItem(user.id); const theirs = seedBucketItem(stranger.id); - const updated = svc.confirmBucketVisits(user.id, [mine, theirs]); + const updated = await svc.confirmBucketVisits(user.id, [mine, theirs]); expect(updated).toBe(1); expect(bucketOf(theirs).visited_at).toBeNull(); expect(bucketOf(mine).visited_at).not.toBeNull(); }); - it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', () => { + it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', async () => { // A wish somebody reached in 2023, ticked off with today's date, is a wrong // entry in a list people keep for years. The clock is only the answer when // nothing else knows better. @@ -868,24 +867,24 @@ describe('DawarichSuggestionsService — confirming a scan', () => { const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' }); - const updated = svc.confirmBucketVisits(user.id, [wish]); + const updated = await svc.confirmBucketVisits(user.id, [wish]); expect(updated).toBe(1); expect(bucketOf(wish).visited_at).toBe('2023-07-14T08:20:00Z'); expect(bucketOf(wish).visited_source).toBe('dawarich'); }); - it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', () => { + it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); - expect(svc.clearBucketVisit(user.id, mine)).toBe(true); + expect(await svc.clearBucketVisit(user.id, mine)).toBe(true); expect(bucketOf(mine).visited_at).toBeNull(); expect(bucketOf(mine).visited_source).toBeNull(); - expect(svc.clearBucketVisit(user.id, theirs)).toBe(false); + expect(await svc.clearBucketVisit(user.id, theirs)).toBe(false); expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z'); }); }); @@ -1405,7 +1404,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); - const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!; + const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!; expect(listed.state).toBe('new'); expect(listed.target).toBeNull(); expect(listed.acceptedPlaceId).toBeNull(); @@ -1419,7 +1418,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const id = seedSuggestion({ userId: user.id, tripId: trip.id }); await svc.accept(user.id, id, { target: 'place', tripId: trip.id }); - const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!; + const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!; expect(listed.state).toBe('accepted'); expect(listed.acceptedPlaceId).not.toBeNull(); }); @@ -1444,11 +1443,11 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { journeyStub.createEntry.mockReturnValue({ id: entryId }); await svc.accept(user.id, id, { target: 'journal', journalId: journeyId }); - expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('accepted'); + expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('accepted'); testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); - expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('new'); + expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('new'); }); it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', async () => { @@ -1459,7 +1458,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { const result = await svc.accept(theirs.id, id, { target: 'place', tripId: trip.id }); testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); - svc.list(mine.id, {}); + await svc.list(mine.id, {}); expect(rowOf(id).state).toBe('accepted'); }); diff --git a/server/tests/unit/nest/dawarich-sync.job.test.ts b/server/tests/unit/nest/dawarich-sync.job.test.ts index 6d7d23459..d96879010 100644 --- a/server/tests/unit/nest/dawarich-sync.job.test.ts +++ b/server/tests/unit/nest/dawarich-sync.job.test.ts @@ -80,23 +80,23 @@ function makeJob(intervalSetting?: string, enabled = true) { beforeEach(() => vi.clearAllMocks()); describe('DawarichSyncJob bootstrap', () => { - it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', () => { + it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', async () => { const { job, registrar } = makeJob('30'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/30 * * * *', expect.any(Function)); expect(logMock.logInfo).toHaveBeenCalledWith('Dawarich sync: scheduled every 30m'); }); - it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', () => { + it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', async () => { // Both jobs run the identical statement against app_settings; a copy-paste // that kept 'airtrail_poll_interval_minutes' would silently inherit the // other integration's cadence and nothing anywhere would fail. const { job, db } = makeJob('20'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY); }); - it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', () => { + it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', async () => { for (const [setting, minutes] of [ [undefined, 15], // no row at all ['', 15], // row present but blank @@ -109,24 +109,24 @@ describe('DawarichSyncJob bootstrap', () => { ] as const) { vi.clearAllMocks(); const { job, registrar } = makeJob(setting); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', `*/${minutes} * * * *`, expect.any(Function)); expect(logMock.logInfo).toHaveBeenCalledWith(`Dawarich sync: scheduled every ${minutes}m`); } }); - it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', () => { + it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', async () => { // parseInt is lenient and the clamp is what makes that safe: '20 minutes' // out of a hand-edited setting is still a usable 20, and everything the // clamp dislikes has already gone to the default in the case above. const { job, registrar } = makeJob('20 minutes'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/20 * * * *', expect.any(Function)); }); - it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', () => { + it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', async () => { const { job, registrar, db } = makeJob('15', false); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); expect(db.get).not.toHaveBeenCalled(); expect(logMock.logInfo).not.toHaveBeenCalled(); @@ -134,7 +134,7 @@ describe('DawarichSyncJob bootstrap', () => { it('DAWARICH-JOB-006: the callback handed to the registrar is the tick, so a fired cron reaches runSync', async () => { const { job, sync, takeTick } = makeJob('15'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); const onTick = takeTick(); expect(onTick).toBeTypeOf('function'); await onTick?.(); @@ -147,7 +147,7 @@ describe('DawarichSyncJob bootstrap', () => { // Unregistering is the registrar's own business inside register(); the job // calling it would leave a window where no cron exists at all. const { job, db, registrar, takeTick } = makeJob('15'); - job.onApplicationBootstrap(); + await job.onApplicationBootstrap(); await takeTick()?.(); expect(db.get).toHaveBeenCalledTimes(1); expect(registrar.register).toHaveBeenCalledTimes(1); diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts index 466b6dce3..98849549f 100644 --- a/server/tests/unit/nest/dawarich-sync.service.test.ts +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -47,6 +47,7 @@ import { createUser, createTrip } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { AddonsService } from '../../../src/nest/addons/addons.service'; import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; import type { DawarichClient, DawarichCreds, DawarichVisitRaw } from '../../../src/nest/integrations/dawarich.client'; import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; @@ -112,7 +113,7 @@ const dawarich = { // (repo convention for DI-native service unit tests). const dbs = new DatabaseService(testDb); const addons = new AddonsService(dbs); -const svc = new DawarichSyncService(dbs, addons, client, dawarich); +let svc: DawarichSyncService; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -222,9 +223,10 @@ function withVisits(...visits: DawarichVisitRaw[]): void { listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' }); } -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new DawarichSyncService(dbs, addons, client, dawarich, await createTestUnitOfWork(dbs.connection)); }); beforeEach(() => { diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts index 6dd761246..3c71d8e6a 100644 --- a/server/tests/unit/nest/dawarich.controller.test.ts +++ b/server/tests/unit/nest/dawarich.controller.test.ts @@ -109,10 +109,10 @@ describe('DawarichController wiring', () => { // --------------------------------------------------------------------------- describe('DawarichController connection routes', () => { - it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', () => { + it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', async () => { const connection = { url: 'https://d.example', connected: true }; const getConnection = vi.fn().mockReturnValue(connection); - expect(makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection); + expect(await makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection); expect(getConnection).toHaveBeenCalledWith(7); }); @@ -200,21 +200,21 @@ describe('DawarichController connection routes', () => { // --------------------------------------------------------------------------- describe('DawarichController GET /suggestions filters', () => { - it('DAWARICH-CTRL-020: no filters means both are undefined', () => { + it('DAWARICH-CTRL-020: no filters means both are undefined', async () => { const list = vi.fn().mockReturnValue({ suggestions: [] }); - makeController({ suggestions: { list } }).listSuggestions(user); + await makeController({ suggestions: { list } }).listSuggestions(user); expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); }); - it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', () => { + it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', async () => { const list = vi.fn().mockReturnValue({ suggestions: [] }); - makeController({ suggestions: { list } }).listSuggestions(user, '', ''); + await makeController({ suggestions: { list } }).listSuggestions(user, '', ''); expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); }); - it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', () => { + it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', async () => { const list = vi.fn().mockReturnValue({ suggestions: [] }); - makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new'); + await makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new'); expect(list).toHaveBeenCalledWith(7, { tripId: 42, state: 'new' }); }); @@ -226,9 +226,9 @@ describe('DawarichController GET /suggestions filters', () => { }); }); - it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', (state) => { + it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', async (state) => { const list = vi.fn().mockReturnValue({ suggestions: [] }); - makeController({ suggestions: { list } }).listSuggestions(user, undefined, state); + await makeController({ suggestions: { list } }).listSuggestions(user, undefined, state); expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state }); }); @@ -269,10 +269,10 @@ describe('DawarichController parseId', () => { }); }); - it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', () => { + it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', async () => { const setState = vi.fn().mockReturnValue({ id: 12 }); const c = makeController({ suggestions: { setState } }); - expect(c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 }); + expect(await c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 }); expect(setState).toHaveBeenCalledWith(7, 12, 'new'); }); @@ -297,10 +297,10 @@ describe('DawarichController parseId', () => { // --------------------------------------------------------------------------- describe('DawarichController suggestion routes', () => { - it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', () => { + it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', async () => { const updated = { id: 3, state: 'dismissed' }; const setState = vi.fn().mockReturnValue(updated); - expect(makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated); + expect(await makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated); expect(setState).toHaveBeenCalledWith(7, 3, 'dismissed'); }); @@ -329,9 +329,9 @@ describe('DawarichController suggestion routes', () => { expect(accept.mock.calls[0][3]).toBeUndefined(); }); - it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', () => { + it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', async () => { const confirmBucketVisits = vi.fn().mockReturnValue(3); - expect(makeController({ suggestions: { confirmBucketVisits } }) + expect(await makeController({ suggestions: { confirmBucketVisits } }) .confirmBucketVisits(user, { itemIds: [1, 2, 3], visitedAt: '2026-05-01' })).toEqual({ updated: 3 }); expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01'); }); @@ -342,12 +342,12 @@ describe('DawarichController suggestion routes', () => { expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } })); }); - it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', () => { + it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', async () => { // The counterpart to 046: the service reports whether a row changed, and // only the "nothing changed" answer is a 404. A cleared tick must not come // back as one, or undoing a wish would look like it failed. const clearBucketVisit = vi.fn().mockReturnValue(true); - expect(makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({ + expect(await makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({ success: true, }); expect(clearBucketVisit).toHaveBeenCalledWith(7, 4); diff --git a/server/tests/unit/nest/dawarich.mcp.test.ts b/server/tests/unit/nest/dawarich.mcp.test.ts index 25dcf4ecc..f4130ae4b 100644 --- a/server/tests/unit/nest/dawarich.mcp.test.ts +++ b/server/tests/unit/nest/dawarich.mcp.test.ts @@ -228,7 +228,7 @@ describe('DawarichMcp surface', () => { }); const mcp = makeMcp({ suggestions: { list: listFn }, tracks: { forTrip }, auth: { isDemoUser } }); - payload(mcp.listSuggestions({}, ctx)); + payload(await mcp.listSuggestions({}, ctx)); payload(await mcp.tripTrack({ tripId: 5 }, ctx)); expect(isDemoUser).not.toHaveBeenCalled(); }); @@ -354,12 +354,12 @@ describe('DawarichMcp input schemas', () => { // --------------------------------------------------------------------------- describe('DawarichMcp list_dawarich_suggestions', () => { - it('DAWARICH-MCP-030: the filters reach the service for the calling user, and the sync provenance rides back', () => { + it('DAWARICH-MCP-030: the filters reach the service for the calling user, and the sync provenance rides back', async () => { // A disconnected instance with a failed poll is the interesting case: the // stays are still there, and without the provenance an assistant would // present a stale backlog as if it were current. const listFn = vi.fn().mockReturnValue(list(2, { connected: false, lastSyncState: 'failed', lastSyncAt: null })); - const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({ tripId: 3, state: 'new' }, ctx)); + const out = payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({ tripId: 3, state: 'new' }, ctx)); expect(listFn).toHaveBeenCalledWith(7, { tripId: 3, state: 'new' }); expect(out).toMatchObject({ @@ -371,53 +371,53 @@ describe('DawarichMcp list_dawarich_suggestions', () => { }); }); - it('DAWARICH-MCP-031: no arguments means no filters, undefined rather than a filter on nothing', () => { + it('DAWARICH-MCP-031: no arguments means no filters, undefined rather than a filter on nothing', async () => { const listFn = vi.fn().mockReturnValue(list(0)); - payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); + payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); expect(listFn).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); }); - it('DAWARICH-MCP-032: a backlog longer than the default page is cut to 50 and says so, because an assistant must not pay for a year of stays', () => { - const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }).listSuggestions({}, ctx)); + it('DAWARICH-MCP-032: a backlog longer than the default page is cut to 50 and says so, because an assistant must not pay for a year of stays', async () => { + const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }).listSuggestions({}, ctx)); expect((out.suggestions as unknown[]).length).toBe(50); expect(out.total).toBe(60); expect(out.truncated).toBe(true); }); - it('DAWARICH-MCP-033: a list that fits is not flagged truncated, so a caller can tell "all of it" from "the first page"', () => { - const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(50)) } }).listSuggestions({}, ctx)); + it('DAWARICH-MCP-033: a list that fits is not flagged truncated, so a caller can tell "all of it" from "the first page"', async () => { + const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(50)) } }).listSuggestions({}, ctx)); expect((out.suggestions as unknown[]).length).toBe(50); expect(out.total).toBe(50); expect(out.truncated).toBe(false); }); - it('DAWARICH-MCP-034: an explicit limit wins over the default in both directions', () => { + it('DAWARICH-MCP-034: an explicit limit wins over the default in both directions', async () => { const mcp = makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }); - const small = payload(mcp.listSuggestions({ limit: 3 }, ctx)); + const small = payload(await mcp.listSuggestions({ limit: 3 }, ctx)); expect((small.suggestions as unknown[]).length).toBe(3); expect(small.truncated).toBe(true); - const large = payload(mcp.listSuggestions({ limit: 200 }, ctx)); + const large = payload(await mcp.listSuggestions({ limit: 200 }, ctx)); expect((large.suggestions as unknown[]).length).toBe(60); expect(large.truncated).toBe(false); }); - it('DAWARICH-MCP-035: the stays go out as the service shaped them, because the tool pages and does not reshape', () => { - const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(2)) } }).listSuggestions({ limit: 1 }, ctx)); + it('DAWARICH-MCP-035: the stays go out as the service shaped them, because the tool pages and does not reshape', async () => { + const out = payload(await makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(2)) } }).listSuggestions({ limit: 1 }, ctx)); expect(out.suggestions).toEqual([{ id: 1, name: 'Stay 1' }]); }); - it('DAWARICH-MCP-036: the reason the last poll failed is the one field the tool drops', () => { + it('DAWARICH-MCP-036: the reason the last poll failed is the one field the tool drops', async () => { // lastSyncError is on DawarichSuggestionList and the REST list carries it; // this tool does not. Pinned so the omission stays a decision rather than an // oversight: an assistant can say the sync failed but not why, which is a // defensible privacy line (the string can name the host) and, at the same // time, a real gap in what it is able to explain to the person asking. const listFn = vi.fn().mockReturnValue(list(1, { lastSyncState: 'failed', lastSyncError: 'unauthorized' })); - const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); + const out = payload(await makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); expect(out).not.toHaveProperty('lastSyncError'); expect(out.lastSyncState).toBe('failed'); diff --git a/server/tests/unit/nest/dawarich.service.test.ts b/server/tests/unit/nest/dawarich.service.test.ts index 56711658e..11076a73e 100644 --- a/server/tests/unit/nest/dawarich.service.test.ts +++ b/server/tests/unit/nest/dawarich.service.test.ts @@ -93,6 +93,7 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; import { DawarichError, type DawarichClient, @@ -114,11 +115,7 @@ const client = { }; const dbs = new DatabaseService(testDb); -const svc = new DawarichService( - dbs, - audit as unknown as AuditService, - client as unknown as DawarichClient, -); +let svc: DawarichService; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -255,9 +252,15 @@ const countryCodeAbsentCases: Array<[string, Partial]> = [ ['a place with a null code', { place: { latitude: 1, longitude: 2, id: 3, country_code: null } }], ]; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new DawarichService( + dbs, + audit as unknown as AuditService, + client as unknown as DawarichClient, + await createTestUnitOfWork(dbs.connection), + ); }); beforeEach(() => { @@ -277,30 +280,30 @@ afterAll(() => { // --------------------------------------------------------------------------- describe('DawarichService getCredentials', () => { - it('DAWARICH-SVC-001: a user who never connected has no credentials', () => { - expect(svc.getCredentials(USER)).toBeNull(); + it('DAWARICH-SVC-001: a user who never connected has no credentials', async () => { + expect(await svc.getCredentials(USER)).toBeNull(); }); - it('DAWARICH-SVC-002: a row without an address is not a connection', () => { + it('DAWARICH-SVC-002: a row without an address is not a connection', async () => { connect(USER, { url: null }); - expect(svc.getCredentials(USER)).toBeNull(); + expect(await svc.getCredentials(USER)).toBeNull(); }); - it('DAWARICH-SVC-003: a row without a key is not a connection', () => { + it('DAWARICH-SVC-003: a row without a key is not a connection', async () => { connect(USER, { apiKey: null }); - expect(svc.getCredentials(USER)).toBeNull(); + expect(await svc.getCredentials(USER)).toBeNull(); }); - it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', () => { + it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => { // What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind: // the envelope is intact, the ciphertext is not ours. connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); - expect(svc.getCredentials(USER)).toBeNull(); + expect(await svc.getCredentials(USER)).toBeNull(); }); - it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', () => { + it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => { connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 }); - expect(svc.getCredentials(USER)).toEqual({ + expect(await svc.getCredentials(USER)).toEqual({ baseUrl: HOST, apiKey: 'plain-legacy-key', allowInsecureTls: false, @@ -309,7 +312,7 @@ describe('DawarichService getCredentials', () => { it('DAWARICH-SVC-006: the self-signed-certificate flag is carried into the credentials as a boolean', async () => { await svc.saveSettings(USER, HOST, 'typed-secret', true, true, IP); - expect(svc.getCredentials(USER)).toEqual({ + expect(await svc.getCredentials(USER)).toEqual({ baseUrl: HOST, apiKey: 'typed-secret', allowInsecureTls: true, @@ -322,32 +325,32 @@ describe('DawarichService getCredentials', () => { // --------------------------------------------------------------------------- describe('DawarichService isConnected', () => { - it('DAWARICH-SVC-010: both halves present means connected', () => { + it('DAWARICH-SVC-010: both halves present means connected', async () => { connect(USER); - expect(svc.isConnected(USER)).toBe(true); + expect(await svc.isConnected(USER)).toBe(true); }); - it('DAWARICH-SVC-011: an address without a key is not connected', () => { + it('DAWARICH-SVC-011: an address without a key is not connected', async () => { connect(USER, { apiKey: null }); - expect(svc.isConnected(USER)).toBe(false); + expect(await svc.isConnected(USER)).toBe(false); }); - it('DAWARICH-SVC-012: no row at all is not connected', () => { - expect(svc.isConnected(USER)).toBe(false); + it('DAWARICH-SVC-012: no row at all is not connected', async () => { + expect(await svc.isConnected(USER)).toBe(false); }); - it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', () => { + it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', async () => { // Deliberate difference from getCredentials: the settings card has to keep // showing a connection the user can repair, and only the outbound path // cares that the ciphertext is unusable. connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); - expect(svc.isConnected(USER)).toBe(true); - expect(svc.getCredentials(USER)).toBeNull(); + expect(await svc.isConnected(USER)).toBe(true); + expect(await svc.getCredentials(USER)).toBeNull(); }); }); describe('DawarichService listSyncableUserIds', () => { - it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', () => { + it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', async () => { const syncable = USER; const pollOff = createUser(testDb).user.id; const noUrl = createUser(testDb).user.id; @@ -363,14 +366,14 @@ describe('DawarichService listSyncableUserIds', () => { connect(blankUrl, { url: '' }); connect(noKey, { apiKey: null }); - expect(svc.listSyncableUserIds()).toEqual([syncable]); + expect(await svc.listSyncableUserIds()).toEqual([syncable]); }); - it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', () => { - expect(svc.listSyncableUserIds()).toEqual([]); + it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', async () => { + expect(await svc.listSyncableUserIds()).toEqual([]); }); - it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', () => { + it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', async () => { // The cron fans out over this list. A query that answered with one id would // leave everyone but the earliest registration silently unsynced, and the // settings card would still say the poll was on for all of them. @@ -380,7 +383,7 @@ describe('DawarichService listSyncableUserIds', () => { connect(second); connect(pollOff, { syncEnabled: 0 }); - const ids = svc.listSyncableUserIds(); + const ids = await svc.listSyncableUserIds(); // No ORDER BY upstream, so the set is the contract and the order is not. expect(new Set(ids)).toEqual(new Set([USER, second])); @@ -393,8 +396,8 @@ describe('DawarichService listSyncableUserIds', () => { // --------------------------------------------------------------------------- describe('DawarichService getConnection', () => { - it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', () => { - expect(svc.getConnection(USER)).toEqual({ + it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', async () => { + expect(await svc.getConnection(USER)).toEqual({ url: '', apiKeyMasked: '', allowInsecureTls: false, @@ -407,7 +410,7 @@ describe('DawarichService getConnection', () => { }); }); - it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', () => { + it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => { connect(USER, { apiKey: 'stored-key', allowInsecureTls: 1, @@ -417,7 +420,7 @@ describe('DawarichService getConnection', () => { lastSyncError: 'rate_limited', }); - const out = svc.getConnection(USER); + const out = await svc.getConnection(USER); expect(out).toEqual({ url: HOST, @@ -433,52 +436,52 @@ describe('DawarichService getConnection', () => { expect(JSON.stringify(out)).not.toContain('stored-key'); }); - it('DAWARICH-SVC-032: an address without a key reports the address and no mask', () => { + it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => { connect(USER, { apiKey: null }); - expect(svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false }); + expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false }); }); - it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', () => { + it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => { connect(USER, { url: null, apiKey: null }); - expect(svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true }); + expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true }); }); - it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', (state) => { + it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => { connect(USER, { lastSyncState: state }); - expect(svc.getConnection(USER).lastSyncState).toBe(state); + expect((await svc.getConnection(USER)).lastSyncState).toBe(state); }); it.each(['syncing', 'OK', 'success', ''])( 'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it', - (state) => { + async (state) => { connect(USER, { lastSyncState: state }); - expect(svc.getConnection(USER).lastSyncState).toBe('never'); + expect((await svc.getConnection(USER)).lastSyncState).toBe('never'); }, ); it.each(['{not json', '', 'undefined'])( 'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page', - (raw) => { + async (raw) => { connect(USER, { capabilities: raw }); - expect(svc.getConnection(USER).capabilities).toBeNull(); + expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); it.each(['null', '42', '"visits"', 'true'])( 'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed', - (raw) => { + async (raw) => { connect(USER, { capabilities: raw }); - expect(svc.getConnection(USER).capabilities).toBeNull(); + expect((await svc.getConnection(USER)).capabilities).toBeNull(); }, ); - it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', () => { + it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', async () => { // The column survives an upgrade, so the shape is whatever was current when // the last probe ran. Missing flags are off and a missing probe timestamp // is the epoch, which reads as "long ago" everywhere it is compared. connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) }); - expect(svc.getConnection(USER).capabilities).toEqual({ + expect((await svc.getConnection(USER)).capabilities).toEqual({ visits: true, tracks: false, points: false, @@ -491,25 +494,25 @@ describe('DawarichService getConnection', () => { }); }); - it('DAWARICH-SVC-039: a complete blob round-trips unchanged', () => { + it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => { connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); - expect(svc.getConnection(USER).capabilities).toEqual(FULL_CAPS); + expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS); }); }); describe('DawarichService getCapabilities', () => { - it('DAWARICH-SVC-040: null when there is no connection', () => { - expect(svc.getCapabilities(USER)).toBeNull(); + it('DAWARICH-SVC-040: null when there is no connection', async () => { + expect(await svc.getCapabilities(USER)).toBeNull(); }); - it('DAWARICH-SVC-041: null when the connection has never been probed', () => { + it('DAWARICH-SVC-041: null when the connection has never been probed', async () => { connect(USER); - expect(svc.getCapabilities(USER)).toBeNull(); + expect(await svc.getCapabilities(USER)).toBeNull(); }); - it('DAWARICH-SVC-042: the stored probe otherwise', () => { + it('DAWARICH-SVC-042: the stored probe otherwise', async () => { connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); - expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); }); @@ -529,7 +532,7 @@ describe('DawarichService saveSettings', () => { expect(stored?.api_key).toMatch(/^enc:v1:/); expect(stored?.api_key).not.toContain('super-secret'); // The round trip is the point: encrypted at rest, usable on the way out. - expect(svc.getCredentials(USER)?.apiKey).toBe('super-secret'); + expect((await svc.getCredentials(USER))?.apiKey).toBe('super-secret'); }); it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => { @@ -537,8 +540,8 @@ describe('DawarichService saveSettings', () => { expect(row()?.sync_enabled).toBe(0); expect(row()?.allow_insecure_tls).toBe(0); - expect(svc.getConnection(USER).syncEnabled).toBe(false); - expect(svc.listSyncableUserIds()).toEqual([]); + expect((await svc.getConnection(USER)).syncEnabled).toBe(false); + expect(await svc.listSyncableUserIds()).toEqual([]); }); it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => { @@ -560,7 +563,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, HOST, typed, false, true, IP); expect(row()?.api_key).toBe(encrypted); - expect(svc.getCredentials(USER)?.apiKey).toBe('first-key'); + expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key'); }, ); @@ -568,7 +571,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); await svc.saveSettings(USER, HOST, 'second-key', false, true, IP); - expect(svc.getCredentials(USER)?.apiKey).toBe('second-key'); + expect((await svc.getCredentials(USER))?.apiKey).toBe('second-key'); }); it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => { @@ -617,7 +620,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); - expect(svc.getCredentials(USER)).toEqual({ + expect(await svc.getCredentials(USER)).toEqual({ baseUrl: 'https://new.example', apiKey: 'minted-for-new', allowInsecureTls: false, @@ -815,7 +818,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); - expect(svc.getCredentials(USER)?.apiKey).toBe('minted-for-new'); + expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new'); const stored = row(); expect(stored?.url).toBe('https://new.example'); expect(stored?.capabilities).toBeNull(); @@ -834,7 +837,7 @@ describe('DawarichService saveSettings', () => { await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP); - expect(svc.getCredentials(USER)?.apiKey).toBe('rotated-key'); + expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key'); expect(row()?.capabilities).toBe(JSON.stringify(FULL_CAPS)); expect(row()?.last_sync_state).toBe('ok'); }); @@ -851,7 +854,7 @@ describe('DawarichService disconnect', () => { await svc.disconnect(USER, '198.51.100.7'); expect(row()).toBeUndefined(); - expect(svc.getConnection(USER).connected).toBe(false); + expect((await svc.getConnection(USER)).connected).toBe(false); expect(audit.writeAudit).toHaveBeenCalledWith({ userId: USER, action: 'dawarich.disconnected', @@ -921,7 +924,7 @@ describe('DawarichService testConnection', () => { const out = await svc.testConnection(USER, HOST, undefined, false); expect(out.connected).toBe(true); - expect(svc.getCapabilities(USER)).toEqual(out.capabilities); + expect(await svc.getCapabilities(USER)).toEqual(out.capabilities); }); it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => { @@ -929,7 +932,7 @@ describe('DawarichService testConnection', () => { await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false); - expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-096: a test from a form with nothing stored yet writes no capabilities row', async () => { @@ -1012,7 +1015,7 @@ describe('DawarichService testConnection', () => { await svc.testConnection(USER, HOST, undefined, false); - expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); }); it('DAWARICH-SVC-104: a stored key that no longer decrypts is never dialled with, so the form hears "not connected" instead of a ciphertext going upstream as a bearer token', async () => { @@ -1185,60 +1188,60 @@ describe('DawarichService probeCapabilities', () => { // --------------------------------------------------------------------------- describe('DawarichService storeCapabilities', () => { - it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', () => { + it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => { connect(USER); - svc.storeCapabilities(USER, FULL_CAPS); + await svc.storeCapabilities(USER, FULL_CAPS); - expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS); expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS); }); - it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', () => { - svc.storeCapabilities(USER, FULL_CAPS); + it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => { + await svc.storeCapabilities(USER, FULL_CAPS); expect(row()).toBeUndefined(); }); - it('DAWARICH-SVC-132: only the named user is touched', () => { + it('DAWARICH-SVC-132: only the named user is touched', async () => { const other = createUser(testDb).user.id; connect(USER); connect(other); - svc.storeCapabilities(USER, FULL_CAPS); + await svc.storeCapabilities(USER, FULL_CAPS); expect(row(other)?.capabilities).toBeNull(); }); }); describe('DawarichService recordSyncResult', () => { - it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', () => { + it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => { connect(USER); const before = Date.now(); - svc.recordSyncResult(USER, 'failed', 'unreachable'); + await svc.recordSyncResult(USER, 'failed', 'unreachable'); - const out = svc.getConnection(USER); + const out = await svc.getConnection(USER); expect(out.lastSyncState).toBe('failed'); expect(out.lastSyncError).toBe('unreachable'); expect(Date.parse(out.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before - 1000); }); - it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', () => { + it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => { connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' }); - svc.recordSyncResult(USER, 'ok', null); + await svc.recordSyncResult(USER, 'ok', null); - expect(svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null }); + expect(await svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null }); }); - it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', () => { + it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => { connect(USER); - svc.recordSyncResult(USER, 'partial', 'too_large'); - expect(svc.getConnection(USER).lastSyncState).toBe('partial'); + await svc.recordSyncResult(USER, 'partial', 'too_large'); + expect((await svc.getConnection(USER)).lastSyncState).toBe('partial'); }); - it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', () => { - svc.recordSyncResult(USER, 'ok', null); + it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => { + await svc.recordSyncResult(USER, 'ok', null); expect(row()).toBeUndefined(); }); }); diff --git a/server/tests/unit/nest/day-boundaries.test.ts b/server/tests/unit/nest/day-boundaries.test.ts index e65553066..06247e483 100644 --- a/server/tests/unit/nest/day-boundaries.test.ts +++ b/server/tests/unit/nest/day-boundaries.test.ts @@ -34,32 +34,32 @@ function setup() { } const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 }; -it('replaces one day only, persists place snapping and cascades deleted visits', () => { +it('replaces one day only, persists place snapping and cascades deleted visits', async () => { const { service, raw } = setup(); - service.save(10, boundary); - service.save(10, { ...boundary, day_number: 2, fraction: 0.8 }); - expect(service.save(10, { ...boundary, to_assignment_id: null })).toEqual([ + await service.save(10, boundary); + await service.save(10, { ...boundary, day_number: 2, fraction: 0.8 }); + expect(await service.save(10, { ...boundary, to_assignment_id: null })).toEqual([ { ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 }, ]); - expect(service.list(20)).toEqual([]); - service.remove(20, 1); - expect(service.list(10)).toHaveLength(2); + expect(await service.list(20)).toEqual([]); + await service.remove(20, 1); + expect(await service.list(10)).toHaveLength(2); raw.prepare('DELETE FROM day_assignments WHERE id = 12').run(); - expect(service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]); + expect(await service.list(10)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]); }); -it('rejects either endpoint from a different trip without writing', () => { +it('rejects either endpoint from a different trip without writing', async () => { const { service } = setup(); - expect(() => service.save(10, { ...boundary, from_assignment_id: 21 })).toThrow(HttpException); - expect(() => service.save(10, { ...boundary, to_assignment_id: 21 })).toThrow(HttpException); - expect(service.list(10)).toEqual([]); + await expect(service.save(10, { ...boundary, from_assignment_id: 21 })).rejects.toThrow(HttpException); + await expect(service.save(10, { ...boundary, to_assignment_id: 21 })).rejects.toThrow(HttpException); + expect(await service.list(10)).toEqual([]); }); -it('REST changes broadcast the complete state and exclude the saving socket', () => { +it('REST changes broadcast the complete state and exclude the saving socket', async () => { const { controller, realtime } = setup(); - controller.save('10', boundary, 'self'); + await controller.save('10', boundary, 'self'); expect(realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self'); - expect(controller.remove('10', 1)).toEqual({ boundaries: [] }); + expect(await controller.remove('10', 1)).toEqual({ boundaries: [] }); }); it('MCP checks demo, trip access and edit permission before saving', async () => { @@ -73,10 +73,10 @@ it('MCP checks demo, trip access and edit permission before saving', async () => s.db.canAccessTrip.mockReturnValue(true); s.guards.hasTripPermission.mockReturnValue(false); await s.mcp.save(request, ctx); - expect(s.service.list(10)).toEqual([]); + expect(await s.service.list(10)).toEqual([]); s.guards.hasTripPermission.mockReturnValue(true); await s.mcp.save(request, ctx); - expect(s.service.list(10)).toEqual([boundary]); + expect(await s.service.list(10)).toEqual([boundary]); await s.mcp.save({ ...request, boundary: null }, ctx); - expect(s.service.list(10)).toEqual([]); + expect(await s.service.list(10)).toEqual([]); }); diff --git a/server/tests/unit/nest/feeds.service.test.ts b/server/tests/unit/nest/feeds.service.test.ts index e1a9f97a8..3405b2641 100644 --- a/server/tests/unit/nest/feeds.service.test.ts +++ b/server/tests/unit/nest/feeds.service.test.ts @@ -118,13 +118,13 @@ function seedUserWithToken(token: string, overrides: Partial<{ username: string // ── Trip feed token ─────────────────────────────────────────────────────────── describe('trip feed token lifecycle', () => { - it('FEED-SVC-001: reports no URL while the trip has no token', () => { + it('FEED-SVC-001: reports no URL while the trip has no token', async () => { const { user, tripId } = seedTrip(); expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({ feed_url: null }); }); - it('FEED-SVC-002: reports the absolute feed URL once a token exists', () => { + it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => { const { user, tripId } = seedTrip('tok-trip'); expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({ @@ -132,7 +132,7 @@ describe('trip feed token lifecycle', () => { }); }); - it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', () => { + it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', async () => { // APP_URL is user-supplied config; pasted with a trailing slash it would // otherwise produce https://host//api/feed/... which some clients reject. const { user, tripId } = seedTrip('tok-trip'); @@ -142,7 +142,7 @@ describe('trip feed token lifecycle', () => { ); }); - it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', () => { + it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', async () => { // The token is the credential for the public feed, so leaking it through the // authenticated GET would hand a stranger the whole trip. const { tripId } = seedTrip('tok-trip'); @@ -154,7 +154,7 @@ describe('trip feed token lifecycle', () => { // Membership is what the service checks, and that stays true: whether the // caller may manage the credential at all is decided one layer up, by // TripAccessGuard + @RequirePermission('share_manage') on the controller. - it('FEED-SVC-005: a trip shared with the user as a member resolves too', () => { + it('FEED-SVC-005: a trip shared with the user as a member resolves too', async () => { const { trip, tripId } = seedTrip('tok-trip'); const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); @@ -164,7 +164,7 @@ describe('trip feed token lifecycle', () => { ); }); - it('FEED-SVC-006: generate mints a token once and stays idempotent', () => { + it('FEED-SVC-006: generate mints a token once and stays idempotent', async () => { // Enabling twice must not invalidate a URL the user already handed to their // calendar client — that is what rotate is for. const { user, tripId } = seedTrip(); @@ -176,7 +176,7 @@ describe('trip feed token lifecycle', () => { expect(second.feed_url).toBe(first.feed_url); }); - it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', () => { + it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', async () => { const { user, tripId } = seedTrip(); const before = svc.generateTripToken(tripId, user.id, BASE).feed_url; const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; @@ -184,10 +184,10 @@ describe('trip feed token lifecycle', () => { const after = svc.rotateTripToken(tripId, user.id, BASE).feed_url; expect(after).not.toBe(before); - expect(svc.buildTripIcs(oldToken)).toBeNull(); + expect(await svc.buildTripIcs(oldToken)).toBeNull(); }); - it('FEED-SVC-008: disable clears the column so the public URL dies', () => { + it('FEED-SVC-008: disable clears the column so the public URL dies', async () => { const { user, tripId } = seedTrip(); const url = svc.generateTripToken(tripId, user.id, BASE).feed_url; const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; @@ -195,10 +195,10 @@ describe('trip feed token lifecycle', () => { svc.disableTripToken(tripId, user.id); expect(svc.getTripToken(tripId, user.id, BASE)).toEqual({ feed_url: null }); - expect(svc.buildTripIcs(token)).toBeNull(); + expect(await svc.buildTripIcs(token)).toBeNull(); }); - it('FEED-SVC-008b: the writes refuse a trip the acting user cannot reach', () => { + it('FEED-SVC-008b: the writes refuse a trip the acting user cannot reach', async () => { // The route guard is what enforces share_manage; this is the second lock, so // a caller reaching the service another way cannot mint or clear a token on // a trip id it merely guessed. @@ -211,14 +211,14 @@ describe('trip feed token lifecycle', () => { expect(svc.getTripToken(tripId, user.id, BASE).feed_url).toBe(mine); svc.disableTripToken(tripId, outsider.id); - expect(svc.buildTripIcs(myToken)).not.toBeNull(); + expect(await svc.buildTripIcs(myToken)).not.toBeNull(); }); }); // ── User (all-trips) feed token ─────────────────────────────────────────────── describe('user feed token lifecycle', () => { - it('FEED-SVC-009: reports null before and the absolute URL after generation', () => { + it('FEED-SVC-009: reports null before and the absolute URL after generation', async () => { const { user } = createUser(testDb); expect(svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null }); @@ -229,7 +229,7 @@ describe('user feed token lifecycle', () => { expect(svc.getUserToken(user.id, BASE).feed_url).toBe(generated); }); - it('FEED-SVC-010: generate is idempotent — the existing URL is returned unchanged', () => { + it('FEED-SVC-010: generate is idempotent — the existing URL is returned unchanged', async () => { const { user } = createUser(testDb); const first = svc.generateUserToken(user.id, BASE); @@ -238,7 +238,7 @@ describe('user feed token lifecycle', () => { expect(second.feed_url).toBe(first.feed_url); }); - it('FEED-SVC-011: rotate issues a fresh token and the previous URL stops resolving', () => { + it('FEED-SVC-011: rotate issues a fresh token and the previous URL stops resolving', async () => { const { user } = createUser(testDb); const before = svc.generateUserToken(user.id, BASE).feed_url; const oldToken = before.match(/user\/([0-9a-f-]+)\.ics$/)![1]; @@ -246,10 +246,10 @@ describe('user feed token lifecycle', () => { const after = svc.rotateUserToken(user.id, BASE).feed_url; expect(after).not.toBe(before); - expect(svc.buildUserIcs(oldToken)).toBeNull(); + expect(await svc.buildUserIcs(oldToken)).toBeNull(); }); - it('FEED-SVC-012: disable clears the column so the public URL dies', () => { + it('FEED-SVC-012: disable clears the column so the public URL dies', async () => { const { user } = createUser(testDb); const url = svc.generateUserToken(user.id, BASE).feed_url; const token = url.match(/user\/([0-9a-f-]+)\.ics$/)![1]; @@ -257,21 +257,21 @@ describe('user feed token lifecycle', () => { svc.disableUserToken(user.id); expect(svc.getUserToken(user.id, BASE)).toEqual({ feed_url: null }); - expect(svc.buildUserIcs(token)).toBeNull(); + expect(await svc.buildUserIcs(token)).toBeNull(); }); }); // ── buildTripIcs ────────────────────────────────────────────────────────────── describe('buildTripIcs', () => { - it('FEED-SVC-013: an unknown token yields null without asking the calendar', () => { + it('FEED-SVC-013: an unknown token yields null without asking the calendar', async () => { seedTrip('tok-trip'); - expect(svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); + expect(await svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); expect(buildTripCalendar).not.toHaveBeenCalled(); }); - it('FEED-SVC-014: a calendar that throws yields null instead of propagating', () => { + it('FEED-SVC-014: a calendar that throws yields null instead of propagating', async () => { // The public feed is unauthenticated: a trip the calendar cannot render (a row // deleted mid-request, unparseable data) has to come back as a 404, not a 500 // that a subscribing client retries hourly forever. @@ -280,10 +280,10 @@ describe('buildTripIcs', () => { throw new Error('calendar exploded'); }); - expect(svc.buildTripIcs('tok-trip')).toBeNull(); + expect(await svc.buildTripIcs('tok-trip')).toBeNull(); }); - it('FEED-SVC-015: the refresh hints sit in the preamble, ahead of X-WR-CALNAME and every component', () => { + it('FEED-SVC-015: the refresh hints sit in the preamble, ahead of X-WR-CALNAME and every component', async () => { // REFRESH-INTERVAL/X-PUBLISHED-TTL are calendar properties: RFC 5545 puts them // before the first component, and clients that scan only the preamble stop // re-fetching if they slip behind a VTIMEZONE. The document is concatenated from @@ -297,7 +297,7 @@ describe('buildTripIcs', () => { }), ); - const result = svc.buildTripIcs('tok-trip'); + const result = await svc.buildTripIcs('tok-trip'); expect(result).not.toBeNull(); expect(result!.filename).toBe('golden-trip.ics'); @@ -314,14 +314,14 @@ describe('buildTripIcs', () => { // ── buildUserIcs ────────────────────────────────────────────────────────────── describe('buildUserIcs', () => { - it('FEED-SVC-016: an unknown token yields null without asking the calendar', () => { + it('FEED-SVC-016: an unknown token yields null without asking the calendar', async () => { seedUserWithToken('tok-user'); - expect(svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); + expect(await svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); expect(buildTripCalendar).not.toHaveBeenCalled(); }); - it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', () => { + it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', async () => { // One unrenderable trip must not take the whole all-trips subscription down — // the user would silently lose every calendar entry because of a single bad row. const user = seedUserWithToken('tok-user'); @@ -333,7 +333,7 @@ describe('buildUserIcs', () => { return calendarParts({ events: [vevent(`Trip${id}`)] }); }); - const result = svc.buildUserIcs('tok-user'); + const result = await svc.buildUserIcs('tok-user'); expect(buildTripCalendar).toHaveBeenCalledTimes(3); expect(result!.ics).toContain(`SUMMARY:Trip${good.id}\r\n`); @@ -342,7 +342,7 @@ describe('buildUserIcs', () => { expect(result!.ics.endsWith('END:VCALENDAR\r\n')).toBe(true); }); - it('FEED-SVC-018: a TZID shared by two trips is defined exactly once, ahead of every VEVENT', () => { + it('FEED-SVC-018: a TZID shared by two trips is defined exactly once, ahead of every VEVENT', async () => { // Two VTIMEZONE blocks with the same TZID make the document invalid and clients // drop the events referencing it; a block emitted after the VEVENT that uses it // does not resolve either (#1453). First definition wins. @@ -358,7 +358,7 @@ describe('buildUserIcs', () => { events: [vevent(`Trip${id}`)], })); - const { ics } = svc.buildUserIcs('tok-user')!; + const { ics } = (await svc.buildUserIcs('tok-user'))!; expect(ics.split('BEGIN:VTIMEZONE').length - 1).toBe(1); expect(ics).toContain('TZNAME:Asia/Tokyo\r\n'); @@ -366,7 +366,7 @@ describe('buildUserIcs', () => { expect(ics.indexOf('BEGIN:VTIMEZONE')).toBeLessThan(ics.indexOf('BEGIN:VEVENT')); }); - it('FEED-SVC-019: the header is never folded, the body always is', () => { + it('FEED-SVC-019: the header is never folded, the body always is', async () => { // Folding is applied to the body only, on purpose: a long display name would // otherwise wrap X-WR-CALNAME across two physical lines, which several clients // render as a truncated calendar title. The body still has to fold — RFC 5545 @@ -377,7 +377,7 @@ describe('buildUserIcs', () => { const longSummary = 'A'.repeat(120); buildTripCalendar.mockImplementation(() => calendarParts({ events: [vevent(longSummary)] })); - const { ics, calName } = svc.buildUserIcs('tok-user')!; + const { ics, calName } = (await svc.buildUserIcs('tok-user'))!; expect(calName).toBe(`${username} – All Trips`); const preamble = ics.slice(0, ics.indexOf('BEGIN:VEVENT')); @@ -388,14 +388,14 @@ describe('buildUserIcs', () => { expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${longSummary}\r\n`); }); - it('FEED-SVC-020: the display name is escaped for the header but returned raw', () => { + it('FEED-SVC-020: the display name is escaped for the header but returned raw', async () => { // An unescaped ; or , ends the property value early, so the calendar shows up // under a truncated name. The returned calName feeds the HTTP layer, not ICS, // and must stay verbatim. const user = seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' }); createTrip(testDb, user.id, { start_date: '2026-01-01' }); - const { ics, calName } = svc.buildUserIcs('tok-user')!; + const { ics, calName } = (await svc.buildUserIcs('tok-user'))!; expect(calName).toBe('Alice; Bob, Co\\Ltd – All Trips'); expect(ics).toContain('X-WR-CALNAME:Alice\\; Bob\\, Co\\\\Ltd – All Trips\r\n'); @@ -403,7 +403,7 @@ describe('buildUserIcs', () => { }); describe('FeedsService wiring', () => { - it('FEED-SVC-021: the module registers the service and all three controllers', () => { + it('FEED-SVC-021: the module registers the service and all three controllers', async () => { expectRegisteredProvider(FeedsModule, FeedsService); expectRegisteredController(FeedsModule, FeedsPublicController); expectRegisteredController(FeedsModule, TripFeedTokenController); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index a5097088e..672b45528 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -36,9 +36,9 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow describe('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => { - it('GET / returns reservations', () => { + it('GET / returns reservations', async () => { const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new ReservationsController(svc, airtrailLink).list(user, '5')).toEqual({ reservations: [{ id: 1 }] }); + expect(await new ReservationsController(svc, airtrailLink).list(user, '5')).toEqual({ reservations: [{ id: 1 }] }); }); describe('POST /', () => { @@ -99,11 +99,11 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese // The 'positions must be an array' 400 moved to the global // ZodValidationPipe (ReservationPositionsDto). - it('updates positions and broadcasts', () => { + it('updates positions and broadcasts', async () => { const updatePositions = vi.fn(); const broadcast = vi.fn(); const svc = makeService({ updatePositions, broadcast } as Partial); const positions = [{ id: 1, day_plan_position: 0 }]; - expect(new ReservationsController(svc, airtrailLink).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true }); + expect(await new ReservationsController(svc, airtrailLink).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true }); expect(updatePositions).toHaveBeenCalledWith('5', positions, 3); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:positions', { positions, day_id: 3 }, 'sock'); }); @@ -162,13 +162,13 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese expect(await thrown(() => new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } }); }); - it('assigns travelers, broadcasts, and returns { travelers, reservation }', () => { + it('assigns travelers, broadcasts, and returns { travelers, reservation }', async () => { const travelers = [{ user_id: 2, username: 'Sam', avatar: null, is_guest: 0 }]; const reservation = { id: 9, travelers }; const setTravelers = vi.fn().mockReturnValue({ travelers, reservation }); const broadcast = vi.fn(); const svc = makeService({ setTravelers, broadcast } as Partial); - expect(new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation }); + expect(await new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation }); expect(setTravelers).toHaveBeenCalledWith('9', '5', [2]); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:travelers-updated', { reservationId: 9, travelers }, 'sock'); }); diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index e5da80aa8..4eb75c065 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -182,7 +182,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { it('RESV-SVC-007: an empty-string title silently keeps the old value (COALESCE + `|| null`)', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old title' }); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation } = await svc.update(String(res.id), String(trip.id), { title: '', notes: 'updated' }, current); expect(reservation.title).toBe('Old title'); expect(reservation.notes).toBe('updated'); @@ -192,7 +192,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'X', type: 'tour' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-01T10:00:00', res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation } = await svc.update(String(res.id), String(trip.id), { type: 'hotel' }, current); expect(reservation.reservation_time).toBeNull(); expect(reservation.reservation_end_time).toBeNull(); @@ -202,7 +202,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run('999', res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation } = await svc.update(String(res.id), String(trip.id), { notes: 'n' }, current); expect(reservation.accommodation_id).toBeNull(); }); @@ -214,7 +214,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?') .run(JSON.stringify({ airline: 'CZ', seat: '72K', price: '2040', priceCurrency: 'CNY' }), res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; // What the booking form sends back: rebuilt from its fields, price not among them. const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { airline: 'CZ', seat: '12A' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ airline: 'CZ', seat: '12A', price: '2040', priceCurrency: 'CNY' }); @@ -227,13 +227,13 @@ describe('ReservationsService (DI-native, real SQL)', () => { testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '100' }), res.id); - let current = svc.getReservation(String(res.id), String(trip.id))!; + let current = (await svc.getReservation(String(res.id), String(trip.id)))!; let { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: '150' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ price: '150' }); // Naming the key with a null is how a caller removes the price on purpose; // it must not be read as "the form did not mention it". - current = svc.getReservation(String(res.id), String(trip.id))!; + current = (await svc.getReservation(String(res.id), String(trip.id)))!; ({ reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { price: null, seat: '1A' } }, current)); expect(JSON.parse(reservation.metadata as string)).toEqual({ price: null, seat: '1A' }); }); @@ -247,7 +247,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { const res = createReservation(testDb, trip.id, { title: 'Imported', type: 'flight' }); testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?') .run(JSON.stringify({ price: '999', priceCurrency: 'EUR' }), res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: { seat: '1A' } }, current); expect(JSON.parse(reservation.metadata as string)).toEqual({ seat: '1A', price: '999', priceCurrency: 'EUR' }); @@ -259,7 +259,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { const item = createBudgetItem(testDb, trip.id, { name: 'Flight', total_price: 50 }); testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(res.id, item.id); testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify({ price: '50' }), res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation } = await svc.update(String(res.id), String(trip.id), { metadata: null }, current); expect(reservation.metadata).toBeNull(); }); @@ -268,10 +268,10 @@ describe('ReservationsService (DI-native, real SQL)', () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Bus' }); testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A'); - let current = svc.getReservation(String(res.id), String(trip.id))!; + let current = (await svc.getReservation(String(res.id), String(trip.id)))!; await svc.update(String(res.id), String(trip.id), { notes: 'no endpoints field' }, current); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 1 }); - current = svc.getReservation(String(res.id), String(trip.id))!; + current = (await svc.getReservation(String(res.id), String(trip.id)))!; await svc.update(String(res.id), String(trip.id), { endpoints: [] }, current); expect(testDb.prepare('SELECT COUNT(*) as c FROM reservation_endpoints WHERE reservation_id = ?').get(res.id)).toEqual({ c: 0 }); }); @@ -304,28 +304,28 @@ describe('ReservationsService (DI-native, real SQL)', () => { }); describe('updatePositions', () => { - it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', () => { + it('RESV-SVC-013: without dayId updates the global day_plan_position (legacy branch)', async () => { const { trip } = ownerTrip(); const r1 = createReservation(testDb, trip.id, { title: 'A' }); const r2 = createReservation(testDb, trip.id, { title: 'B' }); - svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]); + await svc.updatePositions(String(trip.id), [{ id: r2.id, day_plan_position: 0 }, { id: r1.id, day_plan_position: 1 }]); expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r2.id)).toEqual({ day_plan_position: 0 }); expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: 1 }); }); - it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', () => { + it('RESV-SVC-014: with dayId upserts per-day positions into reservation_day_positions', async () => { const { trip } = ownerTrip(); const day = createDay(testDb, trip.id, { date: '2030-05-01' }); const r1 = createReservation(testDb, trip.id, { title: 'A' }); - svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id); - svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE + await svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 3 }], day.id); + await svc.updatePositions(String(trip.id), [{ id: r1.id, day_plan_position: 5 }], day.id); // OR REPLACE expect(testDb.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(r1.id, day.id)).toEqual({ position: 5 }); expect(testDb.prepare('SELECT day_plan_position FROM reservations WHERE id = ?').get(r1.id)).toEqual({ day_plan_position: null }); }); }); describe('list / getReservationWithJoins', () => { - it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', () => { + it('RESV-SVC-015: attaches day_positions, endpoints, travelers and normalizes the TEXT accommodation_id', async () => { const { user, trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; @@ -334,26 +334,26 @@ describe('ReservationsService (DI-native, real SQL)', () => { // The TEXT column quirk: the FK reads back as a numeric string like "14.0". testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(`${acc.id}.0`, res.id); testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, days[0].id, 2); - svc.setReservationTravelers(res.id, trip.id, [user.id]); + await svc.setReservationTravelers(res.id, trip.id, [user.id]); - const [row] = svc.list(String(trip.id)); + const [row] = (await svc.list(String(trip.id))); expect(row.accommodation_id).toBe(acc.id); expect(row.accommodation_name).toBe(place.name); expect(row.day_positions).toEqual({ [days[0].id]: 2 }); expect(row.travelers).toHaveLength(1); - const single = svc.getReservationWithJoins(res.id)!; + const single = (await svc.getReservationWithJoins(res.id))!; expect(single.accommodation_id).toBe(acc.id); expect(single.travelers).toHaveLength(1); }); - it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', () => { - expect(svc.getReservationWithJoins(999)).toBeUndefined(); + it('RESV-SVC-016: getReservationWithJoins returns undefined for a missing id', async () => { + expect((await svc.getReservationWithJoins(999))).toBeUndefined(); }); }); describe('listUpcoming', () => { - it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', () => { + it('RESV-SVC-017: returns future reservations across owned + member trips, skipping cancelled', async () => { const { user, trip } = ownerTrip(); const future = createReservation(testDb, trip.id, { title: 'Future' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', future.id); @@ -362,40 +362,40 @@ describe('ReservationsService (DI-native, real SQL)', () => { const cancelled = createReservation(testDb, trip.id, { title: 'Cancelled' }); testDb.prepare("UPDATE reservations SET reservation_time = ?, status = 'cancelled' WHERE id = ?").run('2999-01-02T10:00:00', cancelled.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Future']); }); // #1934 — a stay covers a range, so it would hold a widget slot for its // whole span. Both ways a hotel can be dated are kept out. - it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', () => { + it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', async () => { const { user, trip } = ownerTrip(); const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', hotel.id); const flight = createReservation(testDb, trip.id, { title: 'Flight' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-02T10:00:00', flight.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Flight']); }); - it('RESV-SVC-017c: leaves out a hotel dated through its day', () => { + it('RESV-SVC-017c: leaves out a hotel dated through its day', async () => { const { user, trip } = ownerTrip(); const day = createDay(testDb, trip.id, { date: '2999-06-01' }); createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel', day_id: day.id }); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual([]); }); // The type column defaults to 'other' but nothing stops a NULL, and a // NULL-unsafe `type != 'hotel'` would silently drop those rows. - it('RESV-SVC-017d: a reservation with no type at all still shows', () => { + it('RESV-SVC-017d: a reservation with no type at all still shows', async () => { const { user, trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Untyped' }); testDb.prepare('UPDATE reservations SET type = NULL, reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', res.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Untyped']); }); @@ -403,7 +403,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { // day-anchored rows hold a bare 'HH:MM'. Compared as strings against an ISO // timestamp, ':' sorts above '2', so a bare time was an arbitrary filter: // everything from 20:00 on passed and everything before it vanished (#1934). - it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', () => { + it('RESV-SVC-017e: a bare clock time falls back to its day instead of being read as a date', async () => { const { user, trip } = ownerTrip(); const day = createDay(testDb, trip.id, { date: '2999-06-01' }); const morning = createReservation(testDb, trip.id, { title: 'Ferry', day_id: day.id }); @@ -411,11 +411,11 @@ describe('ReservationsService (DI-native, real SQL)', () => { const evening = createReservation(testDb, trip.id, { title: 'Show', day_id: day.id }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('20:00', evening.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Ferry', 'Show']); }); - it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', () => { + it('RESV-SVC-017f: orders by the day and the clock, not by the raw column', async () => { const { user, trip } = ownerTrip(); const early = createDay(testDb, trip.id, { date: '2999-06-01' }); const late = createDay(testDb, trip.id, { date: '2999-06-02' }); @@ -428,27 +428,27 @@ describe('ReservationsService (DI-native, real SQL)', () => { const dayOnly = createReservation(testDb, trip.id, { title: 'No time', day_id: late.id }); testDb.prepare('UPDATE reservations SET reservation_time = NULL WHERE id = ?').run(dayOnly.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Bare morning', 'Bare evening', 'No time', 'Dated day two']); }); // The stay stays out, but arriving and leaving are moments, and they are the // two the traveller needs reminding of. - it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', () => { + it('RESV-SVC-017g: a stay contributes a check-in and a check-out named after its place', async () => { const { user, trip } = ownerTrip(); const start = createDay(testDb, trip.id, { date: '2999-06-01' }); const end = createDay(testDb, trip.id, { date: '2999-06-05' }); const place = createPlace(testDb, trip.id, { name: 'The Plaza' }); createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' }); - const rows = svc.listUpcoming(user.id) as { title: string; type: string; day_date: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string; type: string; day_date: string }[]; expect(rows).toEqual([ expect.objectContaining({ title: 'The Plaza', type: 'checkin', day_date: '2999-06-01' }), expect.objectContaining({ title: 'The Plaza', type: 'checkout', day_date: '2999-06-05' }), ]); }); - it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', () => { + it('RESV-SVC-017h: the two moments sort among the bookings rather than after them', async () => { const { user, trip } = ownerTrip(); const start = createDay(testDb, trip.id, { date: '2999-06-01' }); const end = createDay(testDb, trip.id, { date: '2999-06-03' }); @@ -459,21 +459,21 @@ describe('ReservationsService (DI-native, real SQL)', () => { const museum = createReservation(testDb, trip.id, { title: 'Museum' }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-06-02T09:00', museum.id); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Hostel', 'Dinner', 'Museum', 'Hostel']); }); - it('RESV-SVC-017i: a stay that is already over contributes nothing', () => { + it('RESV-SVC-017i: a stay that is already over contributes nothing', async () => { const { user, trip } = ownerTrip(); const start = createDay(testDb, trip.id, { date: '2000-06-01' }); const end = createDay(testDb, trip.id, { date: '2000-06-05' }); const place = createPlace(testDb, trip.id, { name: 'Old Inn' }); createDayAccommodation(testDb, trip.id, place.id, start.id, end.id, { check_in: '15:00', check_out: '11:00' }); - expect(svc.listUpcoming(user.id)).toEqual([]); + expect((await svc.listUpcoming(user.id))).toEqual([]); }); - it('RESV-SVC-017j: a nameless stay falls back to its linked booking', () => { + it('RESV-SVC-017j: a nameless stay falls back to its linked booking', async () => { const { user, trip } = ownerTrip(); const start = createDay(testDb, trip.id, { date: '2999-06-01' }); const end = createDay(testDb, trip.id, { date: '2999-06-02' }); @@ -483,13 +483,13 @@ describe('ReservationsService (DI-native, real SQL)', () => { const booking = createReservation(testDb, trip.id, { title: 'Hotel Ibis', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.lastInsertRowid), booking.id); - const rows = svc.listUpcoming(user.id) as { title: string; type: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string; type: string }[]; expect(rows.map(r => r.type + ':' + r.title)).toEqual(['checkin:Hotel Ibis', 'checkout:Hotel Ibis']); }); }); describe('resyncReservationDays', () => { - it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', () => { + it('RESV-SVC-018: re-anchors a dated booking to the day matching its time; out-of-range stays untouched', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const days = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string }[]; const res = createReservation(testDb, trip.id, { title: 'Tour', type: 'tour', day_id: days[0].id }); @@ -497,22 +497,22 @@ describe('ReservationsService (DI-native, real SQL)', () => { const outside = createReservation(testDb, trip.id, { title: 'Outside', type: 'tour', day_id: days[0].id }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2031-01-01T09:00:00', outside.id); - svc.resyncReservationDays(String(trip.id)); + await svc.resyncReservationDays(String(trip.id)); expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[2].id }); expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(outside.id)).toEqual({ day_id: days[0].id }); }); }); describe('setTravelers (controller-facing composition)', () => { - it('returns null when the reservation is not on the trip (off-trip guard)', () => { + it('returns null when the reservation is not on the trip (off-trip guard)', async () => { const { trip } = ownerTrip(); - expect(svc.setTravelers('999', String(trip.id), [2])).toBeNull(); + expect((await svc.setTravelers('999', String(trip.id), [2]))).toBeNull(); }); - it('assigns travelers and returns the refreshed travelers + reservation', () => { + it('assigns travelers and returns the refreshed travelers + reservation', async () => { const { user, trip } = ownerTrip(); const res = createReservation(testDb, trip.id); - const result = svc.setTravelers(String(res.id), String(trip.id), [user.id])!; + const result = (await svc.setTravelers(String(res.id), String(trip.id), [user.id]))!; expect(result.travelers).toHaveLength(1); expect(result.travelers[0]).toMatchObject({ user_id: user.id }); expect(result.reservation).toMatchObject({ id: res.id }); @@ -595,7 +595,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { describe('notifyBookingChange', () => { it('sends the booking_change notification with the legacy payload (fire-and-forget)', async () => { const { user, trip } = ownerTrip(); - expect(() => svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).not.toThrow(); + await expect(svc.notifyBookingChange(String(trip.id), user.id, 'Hotel', 'lodging')).resolves.not.toThrow(); await vi.waitFor(() => expect(notif.send).toHaveBeenCalled()); expect(notif.send).toHaveBeenCalledWith({ event: 'booking_change', @@ -608,7 +608,7 @@ describe('ReservationsService (DI-native, real SQL)', () => { it('is a silent no-op when the actor does not exist', async () => { const { trip } = ownerTrip(); - svc.notifyBookingChange(String(trip.id), 999, 'Hotel', ''); + await svc.notifyBookingChange(String(trip.id), 999, 'Hotel', ''); await new Promise(r => setTimeout(r, 10)); expect(notif.send).not.toHaveBeenCalled(); }); @@ -624,21 +624,21 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => { return user; } - it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', () => { + it('RES-TRAV-001: assigns trip members and returns them via loadTravelers', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); const res = createReservation(testDb, trip.id); - svc.setReservationTravelers(res.id, trip.id, [member.id]); + await svc.setReservationTravelers(res.id, trip.id, [member.id]); - const travelers = svc.loadTravelers(res.id); + const travelers = (await svc.loadTravelers(res.id)); expect(travelers).toHaveLength(1); expect(travelers[0]).toMatchObject({ user_id: member.id, username: member.username, is_guest: 0 }); }); - it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', () => { + it('RES-TRAV-002: silently drops a user id that is not on the trip (cross-trip guard)', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -650,27 +650,27 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => { const otherTrip = createTrip(testDb, outsider.id); addTripMember(testDb, otherTrip.id, outsider.id); - svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]); + await svc.setReservationTravelers(res.id, trip.id, [member.id, outsider.id]); - const ids = svc.loadTravelers(res.id).map(t => t.user_id); + const ids = (await svc.loadTravelers(res.id)).map(t => t.user_id); expect(ids).toEqual([member.id]); expect(ids).not.toContain(outsider.id); }); - it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', () => { + it('RES-TRAV-003: assigns a named guest (users.is_guest = 1) joined via trip_members', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const guest = addGuest(trip.id, 'Grandma'); const res = createReservation(testDb, trip.id); - svc.setReservationTravelers(res.id, trip.id, [guest.id]); + await svc.setReservationTravelers(res.id, trip.id, [guest.id]); - const travelers = svc.loadTravelers(res.id); + const travelers = (await svc.loadTravelers(res.id)); expect(travelers).toHaveLength(1); expect(travelers[0]).toMatchObject({ user_id: guest.id, username: 'Grandma', is_guest: 1 }); }); - it('RES-TRAV-004: re-setting replaces the previous set', () => { + it('RES-TRAV-004: re-setting replaces the previous set', async () => { const { user: owner } = createUser(testDb); const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); @@ -679,20 +679,20 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => { addTripMember(testDb, trip.id, b.id); const res = createReservation(testDb, trip.id); - svc.setReservationTravelers(res.id, trip.id, [a.id]); - expect(svc.loadTravelers(res.id).map(t => t.user_id)).toEqual([a.id]); + await svc.setReservationTravelers(res.id, trip.id, [a.id]); + expect((await svc.loadTravelers(res.id)).map(t => t.user_id)).toEqual([a.id]); - svc.setReservationTravelers(res.id, trip.id, [b.id]); - const ids = svc.loadTravelers(res.id).map(t => t.user_id); + await svc.setReservationTravelers(res.id, trip.id, [b.id]); + const ids = (await svc.loadTravelers(res.id)).map(t => t.user_id); expect(ids).toEqual([b.id]); expect(ids).not.toContain(a.id); // Clearing removes everyone. - svc.setReservationTravelers(res.id, trip.id, []); - expect(svc.loadTravelers(res.id)).toHaveLength(0); + await svc.setReservationTravelers(res.id, trip.id, []); + expect((await svc.loadTravelers(res.id))).toHaveLength(0); }); - it('RES-TRAV-005: list attaches travelers[] per reservation', () => { + it('RES-TRAV-005: list attaches travelers[] per reservation', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); @@ -700,9 +700,9 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => { const withTravelers = createReservation(testDb, trip.id, { title: 'Flight' }); const withoutTravelers = createReservation(testDb, trip.id, { title: 'Hotel' }); - svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]); + await await svc.setReservationTravelers(withTravelers.id, trip.id, [member.id]); - const list = svc.list(trip.id); + const list = await (await svc.list(trip.id)); const assigned = list.find(r => r.id === withTravelers.id)!; const empty = list.find(r => r.id === withoutTravelers.id)!; @@ -713,26 +713,26 @@ describe('setReservationTravelers / loadTravelers (#1517)', () => { }); describe('ReservationsService — the surface the deleted bridge exposed', () => { - it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', () => { + it('RESV-BRIDGE-001: listReservations delegates to ReservationsService.list', async () => { const { trip } = ownerTrip(); createReservation(testDb, trip.id, { title: 'A' }); - expect(bridge.listReservations(trip.id).map(r => r.title)).toEqual(['A']); + expect((await bridge.listReservations(trip.id)).map(r => r.title)).toEqual(['A']); }); - it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', () => { + it('RESV-BRIDGE-002: loadEndpointsByTrip groups endpoints per reservation', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); testDb.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, 0, ?, 1, 2)').run(res.id, 'from', 'A'); - const map = bridge.loadEndpointsByTrip(trip.id); + const map = await bridge.loadEndpointsByTrip(trip.id); expect(map.get(res.id)).toHaveLength(1); }); - it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', () => { + it('RESV-BRIDGE-003: resyncReservationDays re-anchors through the bridge', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' }); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id }); testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2030-05-02T09:00:00', res.id); - bridge.resyncReservationDays(trip.id); + await bridge.resyncReservationDays(trip.id); expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id }); }); @@ -742,23 +742,23 @@ describe('ReservationsService — the surface the deleted bridge exposed', () => expect(reservation).toMatchObject({ title: 'Bridge', type: 'other' }); }); - it('RESV-BRIDGE-005: getReservation is trip-scoped', () => { + it('RESV-BRIDGE-005: getReservation is trip-scoped', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); - expect(bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id }); - expect(bridge.getReservation(res.id, trip.id + 1)).toBeUndefined(); + expect(await bridge.getReservation(res.id, trip.id)).toMatchObject({ id: res.id }); + expect(await bridge.getReservation(res.id, trip.id + 1)).toBeUndefined(); }); - it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', () => { + it('RESV-BRIDGE-006: getReservationWithJoins attaches endpoints + travelers', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id); - expect(bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] }); + expect(await bridge.getReservationWithJoins(res.id)).toMatchObject({ id: res.id, endpoints: [], travelers: [] }); }); it('RESV-BRIDGE-007: updateReservation updates through the bridge', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old' }); - const current = bridge.getReservation(res.id, trip.id)!; + const current = (await bridge.getReservation(res.id, trip.id))!; const { reservation } = await bridge.updateReservation(res.id, trip.id, { title: 'New' }, current); expect(reservation.title).toBe('New'); }); @@ -768,12 +768,12 @@ describe('ReservationsService — the surface the deleted bridge exposed', () => const res = createReservation(testDb, trip.id); const { deleted } = await bridge.deleteReservation(res.id, trip.id); expect(deleted).toMatchObject({ id: res.id }); - expect(bridge.getReservation(res.id, trip.id)).toBeUndefined(); + expect(await bridge.getReservation(res.id, trip.id)).toBeUndefined(); }); it('RESV-BRIDGE-009: notifyBookingChange fires the notification through the bridge', async () => { const { user, trip } = ownerTrip(); - bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other'); + await bridge.notifyBookingChange(trip.id, user.id, 'Bridge booking', 'other'); await vi.waitFor(() => expect(notif.send).toHaveBeenCalled()); }); }); @@ -807,7 +807,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const acc = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id); const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(String(acc.id), res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { accommodationChanged } = await svc.update(String(res.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place2.id, start_day_id: days[1].id, end_day_id: days[2].id, check_in: '14:00' }, @@ -823,7 +823,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const place = createPlace(testDb, trip.id); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const { reservation, accommodationChanged } = await svc.update(String(res.id), String(trip.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id }, @@ -838,7 +838,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const place = createPlace(testDb, trip.id); const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' }); testDb.prepare("UPDATE reservations SET location = 'Loc', notes = 'N', url = 'U', confirmation_number = 'C' WHERE id = ?").run(res.id); - let current = svc.getReservation(String(res.id), String(trip.id))!; + let current = (await svc.getReservation(String(res.id), String(trip.id)))!; let { reservation } = await svc.update(String(res.id), String(trip.id), { location: 'NewLoc', notes: '', url: 'https://x', confirmation_number: 'NEW', status: 'confirmed', needs_review: true, metadata: { a: 1 }, place_id: place.id, @@ -847,7 +847,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c location: 'NewLoc', notes: null, url: 'https://x', confirmation_number: 'NEW', status: 'confirmed', needs_review: 1, metadata: JSON.stringify({ a: 1 }), place_id: place.id, }); - current = svc.getReservation(String(res.id), String(trip.id))!; + current = (await svc.getReservation(String(res.id), String(trip.id)))!; ({ reservation } = await svc.update(String(res.id), String(trip.id), { title: 'T2' }, current)); expect(reservation).toMatchObject({ title: 'T2', location: 'NewLoc', url: 'https://x', place_id: place.id }); }); @@ -856,14 +856,14 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour' }); - let current = svc.getReservation(String(res.id), String(trip.id))!; + let current = (await svc.getReservation(String(res.id), String(trip.id)))!; let { reservation } = await svc.update(String(res.id), String(trip.id), { day_id: days[1].id, end_day_id: days[2].id }, current); expect(reservation).toMatchObject({ day_id: days[1].id, end_day_id: days[2].id }); - current = svc.getReservation(String(res.id), String(trip.id))!; + current = (await svc.getReservation(String(res.id), String(trip.id)))!; ({ reservation } = await svc.update(String(res.id), String(trip.id), { end_day_id: null, reservation_end_time: '2030-05-02T18:00:00' }, current)); // explicit end_day_id null wins over the end-time derivation expect(reservation.end_day_id).toBeNull(); - current = svc.getReservation(String(res.id), String(trip.id))!; + current = (await svc.getReservation(String(res.id), String(trip.id)))!; ({ reservation } = await svc.update(String(res.id), String(trip.id), { reservation_end_time: '2030-05-02T18:00:00' }, current)); // no explicit end day + an end time -> derived expect(reservation.end_day_id).toBe(days[1].id); @@ -877,49 +877,49 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c const res = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare("UPDATE reservations SET accommodation_id = ?, metadata = ?, confirmation_number = 'KEEP' WHERE id = ?") .run(String(acc.id), JSON.stringify({ check_in_time: '16:00' }), res.id); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; await svc.update(String(res.id), String(trip.id), { notes: 'touch' }, current); const row = testDb.prepare('SELECT check_in, confirmation FROM day_accommodations WHERE id = ?').get(acc.id); expect(row).toEqual({ check_in: '16:00', confirmation: 'KEEP' }); }); - it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', () => { + it('RESV-SVC-026: resync derives the end day too, keeping the stored one when the end time is out of range', async () => { const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-03' }); const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[]; const res = createReservation(testDb, trip.id, { title: 'T', type: 'tour', day_id: days[0].id }); testDb.prepare('UPDATE reservations SET reservation_time = ?, reservation_end_time = ?, end_day_id = ? WHERE id = ?') .run('2030-05-02T09:00:00', '2031-01-01T09:00:00', days[0].id, res.id); - svc.resyncReservationDays(String(trip.id)); + await svc.resyncReservationDays(String(trip.id)); // start re-anchored to day 2; out-of-range end time keeps the stored end day expect(testDb.prepare('SELECT day_id, end_day_id FROM reservations WHERE id = ?').get(res.id)) .toEqual({ day_id: days[1].id, end_day_id: days[0].id }); // A booking already on the right day is left untouched (no-change skip). - svc.resyncReservationDays(String(trip.id)); + await svc.resyncReservationDays(String(trip.id)); expect(testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(res.id)).toEqual({ day_id: days[1].id }); }); - it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', () => { + it('RESV-SVC-027: listUpcoming includes a timeless reservation whose day is in the future', async () => { const { user, trip } = ownerTrip(); const day = createDay(testDb, trip.id, { date: '2999-06-01' }); createReservation(testDb, trip.id, { title: 'Timeless', day_id: day.id }); - const rows = svc.listUpcoming(user.id) as { title: string }[]; + const rows = (await svc.listUpcoming(user.id)) as { title: string }[]; expect(rows.map(r => r.title)).toContain('Timeless'); }); - it('RESV-SVC-028: list merges multiple per-day positions for one reservation', () => { + it('RESV-SVC-028: list merges multiple per-day positions for one reservation', async () => { const { trip } = ownerTrip(); const d1 = createDay(testDb, trip.id, { date: '2030-05-01' }); const d2 = createDay(testDb, trip.id, { date: '2030-05-02' }); const res = createReservation(testDb, trip.id); testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d1.id, 0); testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)').run(res.id, d2.id, 3); - const [row] = svc.list(String(trip.id)); + const [row] = (await svc.list(String(trip.id))); expect(row.day_positions).toEqual({ [d1.id]: 0, [d2.id]: 3 }); }); it('RESV-SVC-029: notifyBookingChange falls back to "Untitled" and type "booking"', async () => { const { user } = createUser(testDb); - svc.notifyBookingChange('424242', user.id, 'B', ''); + await svc.notifyBookingChange('424242', user.id, 'B', ''); await vi.waitFor(() => expect(notif.send).toHaveBeenCalled()); expect(notif.send).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ trip: 'Untitled', type: 'booking' }), @@ -942,7 +942,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c expect(budget.deleteBudgetItem).not.toHaveBeenCalled(); }); - it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', () => { + it('RESV-SVC-031: list returns staged bookings, the staging inbox has to see its own rows', async () => { const { trip } = ownerTrip(); createReservation(testDb, trip.id, { title: 'Live One' }); const staged = createReservation(testDb, trip.id, { title: 'Parked One' }); @@ -951,7 +951,7 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c // The visibility predicate belongs to the anonymous exports (ICS feed, // shared trip) and must never be pulled into the authenticated list, or // nobody can confirm what the ingest parked. - expect(svc.list(String(trip.id)).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']); + expect((await svc.list(String(trip.id))).map((r: any) => r.title).sort()).toEqual(['Live One', 'Parked One']); }); }); @@ -974,7 +974,7 @@ describe('ReservationsService — quirk fixes (post-fold)', () => { it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', async () => { const { trip } = ownerTrip(); const res = createReservation(testDb, trip.id, { title: 'Old' }); - const current = svc.getReservation(String(res.id), String(trip.id))!; + const current = (await svc.getReservation(String(res.id), String(trip.id)))!; const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }]; await expect(svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).rejects.toThrow(); expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' }); @@ -991,13 +991,13 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { return { mine, theirs }; } - it('RESV-SCOPE-001: names every foreign id in the body', () => { + it('RESV-SCOPE-001: names every foreign id in the body', async () => { const { mine, theirs } = twoTrips(); const foreignPlace = createPlace(testDb, theirs.id); const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id); - expect(svc.referencesOutsideTrip(String(mine.id), { + expect(await svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: foreignDay.id, place_id: foreignPlace.id, accommodation_id: foreignAcc.id, })).toEqual(['day_id', 'place_id', 'accommodation_id']); }); @@ -1013,21 +1013,21 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { // takes the accommodation with it, and the reservation keeps the id. testDb.prepare('DELETE FROM day_accommodations WHERE id = ?').run(acc.id); - expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id })).toEqual([]); + expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x', accommodation_id: acc.id }))).toEqual([]); - const current = svc.getReservation(String(res.id), String(mine.id))!; + const current = (await svc.getReservation(String(res.id), String(mine.id)))!; await svc.update(String(res.id), String(mine.id), { accommodation_id: acc.id, title: 'Hotel renamed' } as never, current); expect(testDb.prepare('SELECT title, accommodation_id FROM reservations WHERE id = ?').get(res.id)) .toEqual({ title: 'Hotel renamed', accommodation_id: null }); }); - it('RESV-SCOPE-002: passes a body whose ids all live in the trip', () => { + it('RESV-SCOPE-002: passes a body whose ids all live in the trip', async () => { const { mine } = twoTrips(); const place = createPlace(testDb, mine.id); const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(mine.id) as { id: number }; - expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id })).toEqual([]); - expect(svc.referencesOutsideTrip(String(mine.id), { title: 'x' })).toEqual([]); + expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x', day_id: day.id, place_id: place.id }))).toEqual([]); + expect((await svc.referencesOutsideTrip(String(mine.id), { title: 'x' }))).toEqual([]); }); it('RESV-SCOPE-003: a stored foreign accommodation_id is not deleted with the reservation', async () => { @@ -1051,7 +1051,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const foreignDay = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(theirs.id) as { id: number }; const foreignAcc = createDayAccommodation(testDb, theirs.id, foreignPlace.id, foreignDay.id, foreignDay.id, { check_in: '14:00' }); const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); - const current = svc.getReservation(String(res.id), String(mine.id))!; + const current = (await svc.getReservation(String(res.id), String(mine.id)))!; await svc.update(String(res.id), String(mine.id), { accommodation_id: foreignAcc.id, metadata: { check_in_time: '23:00' }, @@ -1111,7 +1111,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const foreignPlace = createPlace(testDb, theirs.id); const body = { title: 'x', place_id: foreignPlace.id }; - expect(svc.referencesOutsideTrip(String(mine.id), body)).toEqual(['place_id']); + expect((await svc.referencesOutsideTrip(String(mine.id), body))).toEqual(['place_id']); expect(await svc.unresolvedReferences(String(mine.id), body)).toEqual(['place_id']); }); @@ -1122,7 +1122,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { testDb.prepare('UPDATE reservations SET day_id = ?, end_day_id = ?, place_id = ?, assignment_id = ? WHERE id = ?') .run(999999, 999998, 999997, 999996, res.id); }); - const current = svc.getReservation(String(res.id), String(mine.id))!; + const current = (await svc.getReservation(String(res.id), String(mine.id)))!; // The body names none of them, so the statement rebinds what the row holds. await svc.update(String(res.id), String(mine.id), { title: 'Dinner, later' }, current); @@ -1139,7 +1139,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { expect(testDb.prepare('SELECT day_id, end_day_id, place_id, assignment_id FROM reservations WHERE id = ?').get(reservation.id)) .toEqual({ day_id: null, end_day_id: null, place_id: null, assignment_id: null }); - const current = svc.getReservation(String(reservation.id), String(mine.id))!; + const current = (await svc.getReservation(String(reservation.id), String(mine.id)))!; await svc.update(String(reservation.id), String(mine.id), { place_id: 999997 }, current); expect(testDb.prepare('SELECT place_id FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ place_id: null }); }); @@ -1161,7 +1161,7 @@ describe('ReservationsService — referenced ids stay inside the trip', () => { const acc = createDayAccommodation(testDb, mine.id, place.id, days[0].id, days[1].id); const res = createReservation(testDb, mine.id, { title: 'Hotel', type: 'hotel' }); testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(acc.id, res.id); - const current = svc.getReservation(String(res.id), String(mine.id))!; + const current = (await svc.getReservation(String(res.id), String(mine.id)))!; await expect(svc.update(String(res.id), String(mine.id), { type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: 999999 }, @@ -1251,7 +1251,7 @@ describe('the day stop a hotel booking implies', () => { title: 'Hotel', type: 'hotel', create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[0].id }, }); - const current = svc.getReservation(String(reservation.id), String(trip.id))!; + const current = (await svc.getReservation(String(reservation.id), String(trip.id)))!; await svc.update(String(reservation.id), String(trip.id), { type: 'hotel', @@ -1270,7 +1270,7 @@ describe('the day stop a hotel booking implies', () => { const { trip, days } = tripWithDays(); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { reservation } = await svc.create(String(trip.id), { title: 'Hotel', type: 'hotel' }); - const current = svc.getReservation(String(reservation.id), String(trip.id))!; + const current = (await svc.getReservation(String(reservation.id), String(trip.id)))!; await svc.update(String(reservation.id), String(trip.id), { type: 'hotel', diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 26e79408b..8aef0e5bb 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -8,10 +8,17 @@ import { RoadtripPreferencesMcp } from '../../../src/nest/roadtrip/roadtrip-pref import { RoadtripPreferencesService } from '../../../src/nest/roadtrip/roadtrip-preferences.service'; import { RoadtripMcp } from '../../../src/nest/roadtrip/roadtrip.mcp'; import { roadtripPreferencesUpdateSchema } from '@trek/shared'; +import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import { describe, it, expect, vi } from 'vitest'; const ctx = { userId: 5 } as McpContext; +/** + * There is no database behind this suite — every statement is served by the + * fakes below — so the UnitOfWork is a pass-through: it runs the callback as + * MikroORM would, with no transaction to open on. + */ +const uowStub = { transactional: (fn: () => Promise) => fn() } as unknown as UnitOfWork; function setup() { const settings: Record = { roadtrip_day_start: '08:00', @@ -35,7 +42,7 @@ function setup() { ), transaction: (fn: () => unknown) => fn(), }; - const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never); + const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime as never, uowStub); const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }]; const visits = [1, 2, 3].map((id) => ({ id, @@ -92,22 +99,22 @@ function setup() { } describe('roadtrip preferences', () => { - it('reads only public driving preferences and never endpoint URLs or credentials', () => { + it('reads only public driving preferences and never endpoint URLs or credentials', async () => { const s = setup(); - expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 }); - expect(JSON.stringify(s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/); + expect(await s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 }); + expect(JSON.stringify(await s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/); }); - it('validates the complete window before an atomic write and broadcasts to every member of its trip', () => { + it('validates the complete window before an atomic write and broadcasts to every member of its trip', async () => { const s = setup(); - expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow(); + await expect(s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).rejects.toThrow(); expect(s.preferenceDb.run).not.toHaveBeenCalled(); - s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); + await s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); // The fourth argument is the socket that saved, so the tab that made the // change is left out of its own echo. Undefined here: no header was sent. expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', { - preferences: s.preferences.read(10), + preferences: await s.preferences.read(10), }, undefined); - expect(s.preferences.read(10).roadtrip_range_km).toBe(100); + expect((await s.preferences.read(10)).roadtrip_range_km).toBe(100); }); it.each([ { llm_api_key: 'x' }, @@ -121,8 +128,8 @@ describe('roadtrip preferences', () => { }); it('allows clearing daily times and limits and refuses demo writes', async () => { const s = setup(); - s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 }); - expect(s.preferences.read(10).roadtrip_day_start).toBe(''); + await s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 }); + expect((await s.preferences.read(10)).roadtrip_day_start).toBe(''); const mcp = new RoadtripPreferencesMcp( s.preferences, { isDemoUser: () => true } as never, @@ -147,7 +154,7 @@ describe('roadtrip preferences', () => { ); const res = await mcp.update({ tripId: 10, settings: { roadtrip_day_end: '06:00' } }, ctx); expect([res.isError, res.content[0].text]).toEqual([true, 'Day end must be later than day start.']); - expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00'); + expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00'); }); }); @@ -166,7 +173,7 @@ describe('browser-independent roadtrip calculation', () => { it('previews settings without saving them', async () => { const s = setup(); await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' }); - expect(s.preferences.read(10).roadtrip_day_end).toBe('10:00'); + expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00'); expect(s.preferenceDb.run).not.toHaveBeenCalled(); }); it('checks trip access before reading or routing', async () => { diff --git a/server/tests/unit/nest/roadtrip.controller.test.ts b/server/tests/unit/nest/roadtrip.controller.test.ts index b360b9266..9ce4131ef 100644 --- a/server/tests/unit/nest/roadtrip.controller.test.ts +++ b/server/tests/unit/nest/roadtrip.controller.test.ts @@ -43,8 +43,8 @@ function svc(o: Partial = {}): RoadtripService { } as unknown as RoadtripService; } -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -68,25 +68,25 @@ describe('RoadtripController — the guard chain', () => { }); describe('RoadtripController — reads', () => { - it('ROADTRIP-CTL-002: the trip read hands back vias and tracks in one answer', () => { + it('ROADTRIP-CTL-002: the trip read hands back vias and tracks in one answer', async () => { const s = svc(); - expect(new RoadtripController(s).listAll('7')).toEqual({ vias: [VIA], tracks: [] }); + expect(await new RoadtripController(s).listAll('7')).toEqual({ vias: [VIA], tracks: [] }); expect(s.listForTrip).toHaveBeenCalledWith('7'); expect(s.tracksForTrip).toHaveBeenCalledWith('7'); }); - it('ROADTRIP-CTL-003: the day read checks the day belongs to this trip', () => { + it('ROADTRIP-CTL-003: the day read checks the day belongs to this trip', async () => { const s = svc(); - expect(new RoadtripController(s).list('7', '4')).toEqual({ vias: [VIA] }); + expect(await new RoadtripController(s).list('7', '4')).toEqual({ vias: [VIA] }); expect(s.dayExists).toHaveBeenCalledWith('4', '7'); }); - it('ROADTRIP-CTL-004: a day from another trip is not readable through this one', () => { + it('ROADTRIP-CTL-004: a day from another trip is not readable through this one', async () => { // The guard proves the caller may reach the trip; this proves the day is // part of it. Without the check a valid day id from somebody else's trip is // reachable by way of a trip the caller does have access to. const s = svc({ dayExists: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new RoadtripController(s).list('7', '999'))) + expect(await thrown(() => new RoadtripController(s).list('7', '999'))) .toEqual({ status: 404, body: { error: 'Day not found' } }); }); }); @@ -94,23 +94,23 @@ describe('RoadtripController — reads', () => { describe('RoadtripController — writes', () => { const body = { after_order_index: 0, lat: 53, lng: 10 }; - it('ROADTRIP-CTL-005: creating one checks the day first', () => { + it('ROADTRIP-CTL-005: creating one checks the day first', async () => { const s = svc(); - expect(new RoadtripController(s).create('7', '4', body)).toEqual({ via: VIA }); + expect(await new RoadtripController(s).create('7', '4', body)).toEqual({ via: VIA }); expect(s.dayExists).toHaveBeenCalledWith('4', '7'); expect(s.create).toHaveBeenCalledWith('4', body); }); - it('ROADTRIP-CTL-006: every write refuses a day from another trip', () => { + it('ROADTRIP-CTL-006: every write refuses a day from another trip', async () => { const s = svc({ dayExists: vi.fn().mockReturnValue(false) }); const c = new RoadtripController(s); const notFound = { status: 404, body: { error: 'Day not found' } }; - expect(thrown(() => c.create('7', '999', body))).toEqual(notFound); - expect(thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound); - expect(thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound); - expect(thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound); - expect(thrown(() => c.remove('7', '999', '5'))).toEqual(notFound); + expect(await thrown(() => c.create('7', '999', body))).toEqual(notFound); + expect(await thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound); + expect(await thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound); + expect(await thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound); + expect(await thrown(() => c.remove('7', '999', '5'))).toEqual(notFound); // None of them reached the service. expect(s.create).not.toHaveBeenCalled(); @@ -120,74 +120,74 @@ describe('RoadtripController — writes', () => { expect(s.remove).not.toHaveBeenCalled(); }); - it('ROADTRIP-CTL-007: a batch lays the whole chain in one call', () => { + it('ROADTRIP-CTL-007: a batch lays the whole chain in one call', async () => { const s = svc(); const batch = { vias: [body, body], replace_legs: [0] }; - expect(new RoadtripController(s).createMany('7', '4', batch)).toEqual({ vias: [VIA] }); + expect(await new RoadtripController(s).createMany('7', '4', batch)).toEqual({ vias: [VIA] }); expect(s.createMany).toHaveBeenCalledWith('4', batch); }); - it('ROADTRIP-CTL-008: a track from another trip cannot become this day label', () => { + it('ROADTRIP-CTL-008: a track from another trip cannot become this day label', async () => { // Permission is not enough on its own: a place id from somebody else's trip // would otherwise become this day's label, and a place that is not a track // would become a label that can never be drawn. const s = svc({ trackExists: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } }))) + expect(await thrown(() => new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } }))) .toEqual({ status: 404, body: { error: 'Track not found' } }); expect(s.createMany).not.toHaveBeenCalled(); }); - it('ROADTRIP-CTL-009: the track check runs against the trip, not the day', () => { + it('ROADTRIP-CTL-009: the track check runs against the trip, not the day', async () => { const s = svc(); - new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } }); + await new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } }); expect(s.trackExists).toHaveBeenCalledWith(3, '7'); }); - it('ROADTRIP-CTL-010: clearing a day track is not a track to look up', () => { + it('ROADTRIP-CTL-010: clearing a day track is not a track to look up', async () => { // `null` says the day follows nothing any more, so there is no place id to // check the existence of. Treating it as one would refuse the only way to // detach a track. const s = svc({ trackExists: vi.fn().mockReturnValue(false) }); - expect(new RoadtripController(s).createMany('7', '4', { vias: [body], track: null })).toEqual({ vias: [VIA] }); + expect(await new RoadtripController(s).createMany('7', '4', { vias: [body], track: null })).toEqual({ vias: [VIA] }); expect(s.trackExists).not.toHaveBeenCalled(); }); - it('ROADTRIP-CTL-011: re-anchoring goes through as one batch', () => { + it('ROADTRIP-CTL-011: re-anchoring goes through as one batch', async () => { const s = svc(); const plan = { vias: [{ id: 5, after_order_index: 1 }], remove: [6] }; - expect(new RoadtripController(s).reanchor('7', '4', plan)).toEqual({ vias: [VIA] }); + expect(await new RoadtripController(s).reanchor('7', '4', plan)).toEqual({ vias: [VIA] }); expect(s.reanchor).toHaveBeenCalledWith('4', plan); }); - it('ROADTRIP-CTL-012: moving a via reports where it went', () => { + it('ROADTRIP-CTL-012: moving a via reports where it went', async () => { const s = svc(); - expect(new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA }); + expect(await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA }); // Undefined anchor, explicitly: a drag that stayed between the same two stops sends // no new one, and the service must leave the existing pin alone rather than clear it. expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, undefined); }); - it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', () => { + it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', async () => { const s = svc(); - new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 }); + await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 }); expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, 2); }); - it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', () => { + it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', async () => { const s = svc({ move: vi.fn().mockReturnValue(null) }); - expect(thrown(() => new RoadtripController(s).update('7', '4', '999', { lat: 1, lng: 2 }))) + expect(await thrown(() => new RoadtripController(s).update('7', '4', '999', { lat: 1, lng: 2 }))) .toEqual({ status: 404, body: { error: 'Via not found' } }); }); - it('ROADTRIP-CTL-014: removing one reports success', () => { + it('ROADTRIP-CTL-014: removing one reports success', async () => { const s = svc(); - expect(new RoadtripController(s).remove('7', '4', '5')).toEqual({ success: true }); + expect(await new RoadtripController(s).remove('7', '4', '5')).toEqual({ success: true }); expect(s.remove).toHaveBeenCalledWith('5', '4'); }); - it('ROADTRIP-CTL-015: removing a via that is not there is a 404', () => { + it('ROADTRIP-CTL-015: removing a via that is not there is a 404', async () => { const s = svc({ remove: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new RoadtripController(s).remove('7', '4', '999'))) + expect(await thrown(() => new RoadtripController(s).remove('7', '4', '999'))) .toEqual({ status: 404, body: { error: 'Via not found' } }); }); @@ -197,7 +197,7 @@ describe('RoadtripController — writes', () => { * silence, which left the other side reading a route nobody could see change. */ describe('telling the trip', () => { - it('SRV-ROADTRIP-020: every write announces the new shape of the day', () => { + it('SRV-ROADTRIP-020: every write announces the new shape of the day', async () => { const cases: [string, (c: RoadtripController) => unknown][] = [ ['create', c => c.create('7', '4', { after_order_index: 0, lat: 1, lng: 2 }, 'sock')], ['createMany', c => c.createMany('7', '4', { vias: [{ after_order_index: 0, lat: 1, lng: 2 }] }, 'sock')], @@ -207,7 +207,7 @@ describe('RoadtripController — writes', () => { ]; for (const [name, run] of cases) { const s = svc(); - run(new RoadtripController(s)); + await run(new RoadtripController(s)); expect(s.broadcast, name).toHaveBeenCalledWith( '7', 'roadtripVia:changed', @@ -217,24 +217,24 @@ describe('RoadtripController — writes', () => { } }); - it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', () => { + it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', async () => { // A reanchor rewrites the whole set and a batch may clear legs before filling them, // so what the write returned is not what the day now holds. const s = svc({ reanchor: vi.fn().mockReturnValue([]) } as Partial); - new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock'); + await new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock'); expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock'); }); - it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', () => { + it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', async () => { const track = { day_id: 4, place_id: 3, name: 'B96' }; const s = svc({ tracksForTrip: vi.fn().mockReturnValue([track]) } as Partial); - new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock'); + await new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock'); expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripTrack:changed', { dayId: '4', track }, 'sock'); }); - it('SRV-ROADTRIP-023: a refused write announces nothing', () => { + it('SRV-ROADTRIP-023: a refused write announces nothing', async () => { const s = svc({ remove: vi.fn().mockReturnValue(false) } as Partial); - expect(() => new RoadtripController(s).remove('7', '4', '9', 'sock')).toThrow(); + await expect(new RoadtripController(s).remove('7', '4', '9', 'sock')).rejects.toThrow(); expect(s.broadcast).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts index 833cd644a..3b86ddfa4 100644 --- a/server/tests/unit/nest/roadtrip.service.test.ts +++ b/server/tests/unit/nest/roadtrip.service.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; /** * Via points, against a real SQLite. The table is tiny and the interesting parts are the * ordering and the day scoping, both of which are SQL — mocking the DB would test the mock. */ -function makeService() { +async function makeService() { const raw = new Database(':memory:'); raw.exec(` CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL); @@ -45,91 +46,91 @@ function makeService() { }; // The realtime side is exercised through the controller and the MCP layer; here the // service is under test for what it writes, so a broadcast that goes nowhere is right. - return new RoadtripService(db as never, { broadcast: () => {} } as never); + return new RoadtripService(db as never, { broadcast: () => {} } as never, await createTestUnitOfWork(raw)); } describe('RoadtripService', () => { let service: RoadtripService; - beforeEach(() => { service = makeService(); }); + beforeEach(async () => { service = await makeService(); }); - it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', () => { + it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => { // The trip guard proves access to the trip; this is what stops a valid day id from // someone else’s trip being edited through one the caller can reach. - expect(service.dayExists(1, 7)).toBe(true); - expect(service.dayExists(3, 7)).toBe(false); + expect((await service.dayExists(1, 7))).toBe(true); + expect((await service.dayExists(3, 7))).toBe(false); }); - it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', () => { - const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - const second = service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 }); + it('ROADTRIP-SVC-002: vias appended after the same stop keep the order they were added in', async () => { + const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + const second = (await service.create(1, { after_order_index: 0, lat: 53.5, lng: 10.5 })); expect(first.sequence).toBe(0); expect(second.sequence).toBe(1); - expect(service.listForDay(1).map(v => v.id)).toEqual([first.id, second.id]); + expect((await service.listForDay(1)).map(v => v.id)).toEqual([first.id, second.id]); }); - it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', () => { - const late = service.create(1, { after_order_index: 2, lat: 51, lng: 12 }); - const early = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + it('ROADTRIP-SVC-003: the list is ordered by stop first, then by sequence', async () => { + const late = (await service.create(1, { after_order_index: 2, lat: 51, lng: 12 })); + const early = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - expect(service.listForDay(1).map(v => v.id)).toEqual([early.id, late.id]); + expect((await service.listForDay(1)).map(v => v.id)).toEqual([early.id, late.id]); }); - it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', () => { - service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - service.create(2, { after_order_index: 0, lat: 52, lng: 11 }); - service.create(3, { after_order_index: 0, lat: 50, lng: 14 }); + it('ROADTRIP-SVC-004: the trip listing spans its days and stops at the trip boundary', async () => { + (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + (await service.create(2, { after_order_index: 0, lat: 52, lng: 11 })); + (await service.create(3, { after_order_index: 0, lat: 50, lng: 14 })); // Day 3 belongs to trip 99 and must not appear in trip 7. - expect(service.listForTrip(7).map(v => v.day_id)).toEqual([1, 2]); + expect((await service.listForTrip(7)).map(v => v.day_id)).toEqual([1, 2]); }); - it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', () => { + it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', async () => { // The drag stayed between the same two stops, so there is nothing to re-pin. The // sequence is never touched by a move either way: it orders the vias that share one // anchor, and dragging one does not reorder its neighbours. - const via = service.create(1, { after_order_index: 1, lat: 53, lng: 10 }); + const via = (await service.create(1, { after_order_index: 1, lat: 53, lng: 10 })); - const moved = service.move(via.id, 1, 52.5, 11.5); + const moved = (await service.move(via.id, 1, 52.5, 11.5)); expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence }); }); - it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', () => { + it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', async () => { // The bug this exists for: a drag used to carry only the coordinates, so a via pulled // beyond the stop it used to precede kept claiming the earlier leg. The route then ran // out to the point and back before carrying on, which reads as the drag doing nothing. - const via = service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 }); + const via = (await service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 })); - const moved = service.move(via.id, 1, 53.87, 11.53, 1); + const moved = (await service.move(via.id, 1, 53.87, 11.53, 1)); expect(moved).toMatchObject({ lat: 53.87, lng: 11.53, after_order_index: 1 }); }); - it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', () => { + it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', async () => { // The day check has to guard the anchor as well as the coordinates, or an id from // another day could be renumbered through a day the caller does happen to reach. - const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - expect(service.move(via.id, 2, 51, 12, 3)).toBeNull(); - expect(service.listForDay(1)[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 }); + expect((await service.move(via.id, 2, 51, 12, 3))).toBeNull(); + expect((await service.listForDay(1))[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 }); }); - it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', () => { - const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', async () => { + const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - expect(service.move(via.id, 2, 0, 0)).toBeNull(); - expect(service.remove(via.id, 2)).toBe(false); + expect((await service.move(via.id, 2, 0, 0))).toBeNull(); + expect((await service.remove(via.id, 2))).toBe(false); // Still where it was. - expect(service.listForDay(1)).toHaveLength(1); + expect((await service.listForDay(1))).toHaveLength(1); }); - it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', () => { - const a = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - const b = service.create(1, { after_order_index: 1, lat: 54, lng: 11 }); - const c = service.create(1, { after_order_index: 2, lat: 55, lng: 12 }); + it('ROADTRIP-SVC-008: re-anchoring moves the vias it names and leaves the rest alone', async () => { + const a = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + const b = (await service.create(1, { after_order_index: 1, lat: 54, lng: 11 })); + const c = (await service.create(1, { after_order_index: 2, lat: 55, lng: 12 })); - const after = service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] }); + const after = (await service.reanchor(1, { vias: [{ id: b.id, after_order_index: 2 }, { id: c.id, after_order_index: 3 }] })); const byId = new Map(after.map(v => [v.id, v.after_order_index])); expect(byId.get(a.id)).toBe(0); @@ -139,61 +140,61 @@ describe('RoadtripService', () => { expect(after.find(v => v.id === b.id)?.lat).toBe(54); }); - it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', () => { - const gone = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - const kept = service.create(1, { after_order_index: 2, lat: 54, lng: 11 }); + it('ROADTRIP-SVC-009: re-anchoring deletes the vias whose leg stopped existing', async () => { + const gone = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + const kept = (await service.create(1, { after_order_index: 2, lat: 54, lng: 11 })); - const after = service.reanchor(1, { + const after = (await service.reanchor(1, { vias: [{ id: kept.id, after_order_index: 1 }], remove: [gone.id], - }); + })); expect(after.map(v => v.id)).toEqual([kept.id]); expect(after[0].after_order_index).toBe(1); }); - it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', () => { + it('ROADTRIP-SVC-010: a via of another day cannot be renumbered through this one', async () => { // The batch is trusted to name ids, so `AND day_id = ?` is the only thing standing // between a day the caller can reach and every via of the trip. - const other = service.create(2, { after_order_index: 0, lat: 53, lng: 10 }); + const other = (await service.create(2, { after_order_index: 0, lat: 53, lng: 10 })); - service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] }); + (await service.reanchor(1, { vias: [{ id: other.id, after_order_index: 9 }], remove: [other.id] })); - const untouched = service.listForDay(2); + const untouched = (await service.listForDay(2)); expect(untouched.map(v => v.id)).toEqual([other.id]); expect(untouched[0].after_order_index).toBe(0); }); - it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', () => { + it('ROADTRIP-SVC-011: an id that is gone does not fail the rest of the batch', async () => { // The caller computed the batch from a snapshot; a via somebody else deleted in the // meantime must not leave the remaining ones mis-pinned. - const kept = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + const kept = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - const after = service.reanchor(1, { + const after = (await service.reanchor(1, { vias: [{ id: 9999, after_order_index: 4 }, { id: kept.id, after_order_index: 1 }], - }); + })); expect(after.map(v => v.after_order_index)).toEqual([1]); }); - it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', () => { + it('ROADTRIP-SVC-016: merging two populated legs keeps the drive going one way', async () => { // The case none of the four above reach: they all re-anchor onto legs that // are empty. `sequence` is allocated per leg and starts at 0 in each, so // merging two populated legs put leg A's 0,1 beside leg B's 0,1 — and // everything that draws the route orders by sequence, so the drive ran A0, // B0, A1, B1: forward, back, and forward again. Persisted, surviving a // reload, and unrepairable except by deleting the vias. - const a0 = service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 }); - const a1 = service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 }); - const b0 = service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 }); - const b1 = service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 }); + const a0 = (await service.create(1, { after_order_index: 1, lat: 50.0, lng: 10 })); + const a1 = (await service.create(1, { after_order_index: 1, lat: 50.1, lng: 10 })); + const b0 = (await service.create(1, { after_order_index: 2, lat: 50.2, lng: 10 })); + const b1 = (await service.create(1, { after_order_index: 2, lat: 50.3, lng: 10 })); expect([a0.sequence, a1.sequence, b0.sequence, b1.sequence]).toEqual([0, 1, 0, 1]); // The stop between the two legs is removed, so leg 2 merges into leg 1 — // exactly what reanchorAfterRemove computes on the client. - const after = service.reanchor(1, { + const after = (await service.reanchor(1, { vias: [{ id: b0.id, after_order_index: 1 }, { id: b1.id, after_order_index: 1 }], - }); + })); const onLeg = after.filter(v => v.after_order_index === 1).sort((x, y) => x.sequence - y.sequence); expect(onLeg.map(v => v.id)).toEqual([a0.id, a1.id, b0.id, b1.id]); @@ -203,12 +204,12 @@ describe('RoadtripService', () => { expect(onLeg.map(v => v.lat)).toEqual([50.0, 50.1, 50.2, 50.3]); }); - it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', () => { - const first = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - const second = service.create(1, { after_order_index: 0, lat: 54, lng: 11 }); - const elsewhere = service.create(1, { after_order_index: 3, lat: 55, lng: 12 }); + it('ROADTRIP-SVC-017: a leg nothing merged into keeps the order it had', async () => { + const first = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + const second = (await service.create(1, { after_order_index: 0, lat: 54, lng: 11 })); + const elsewhere = (await service.create(1, { after_order_index: 3, lat: 55, lng: 12 })); - const after = service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] }); + const after = (await service.reanchor(1, { vias: [{ id: elsewhere.id, after_order_index: 2 }] })); const leg0 = after.filter(v => v.after_order_index === 0).sort((x, y) => x.sequence - y.sequence); expect(leg0.map(v => v.id)).toEqual([first.id, second.id]); @@ -217,104 +218,104 @@ describe('RoadtripService', () => { expect(after.find(v => v.id === elsewhere.id)?.sequence).toBe(0); }); - it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', () => { - const vias = service.createMany(1, { vias: [ + it('ROADTRIP-SVC-012: a chain lands in the order it was sent, per leg', async () => { + const vias = (await service.createMany(1, { vias: [ { after_order_index: 0, lat: 53.0, lng: 10.0 }, { after_order_index: 0, lat: 53.1, lng: 10.1 }, { after_order_index: 1, lat: 53.2, lng: 10.2 }, - ] }); + ] })); expect(vias.map(v => [v.after_order_index, v.sequence])).toEqual([[0, 0], [0, 1], [1, 0]]); expect(vias.map(v => v.lat)).toEqual([53.0, 53.1, 53.2]); }); - it('ROADTRIP-SVC-013: a chain appends to what a leg already has', () => { - service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); - const vias = service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] }); + it('ROADTRIP-SVC-013: a chain appends to what a leg already has', async () => { + (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); + const vias = (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }] })); expect(vias.map(v => v.sequence)).toEqual([0, 1]); }); - it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', () => { + it('ROADTRIP-SVC-014: replace_legs clears only the legs it names', async () => { // The point of naming legs rather than taking a flag for the day: a hand-placed // detour on another leg is not something adopting a track on this one asked to lose. - const keep = service.create(1, { after_order_index: 5, lat: 50, lng: 8 }); - service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + const keep = (await service.create(1, { after_order_index: 5, lat: 50, lng: 8 })); + (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - const vias = service.createMany(1, { + const vias = (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 54, lng: 11 }], replace_legs: [0], - }); + })); expect(vias.map(v => v.id)).toEqual([vias[0].id, keep.id]); expect(vias.find(v => v.after_order_index === 0)?.lat).toBe(54); }); - it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', () => { - service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + it('ROADTRIP-SVC-015: clearing a leg without adding anything is a legal batch', async () => { + (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - expect(service.createMany(1, { vias: [], replace_legs: [0] })).toEqual([]); + expect((await service.createMany(1, { vias: [], replace_legs: [0] }))).toEqual([]); }); - it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', () => { - service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }); + it('ROADTRIP-SVC-016: a chain cannot be laid on a day it was not addressed to', async () => { + (await service.createMany(2, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] })); - expect(service.listForDay(1)).toEqual([]); - expect(service.listForDay(2)).toHaveLength(1); + expect((await service.listForDay(1))).toEqual([]); + expect((await service.listForDay(2))).toHaveLength(1); }); - it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', () => { + it('ROADTRIP-SVC-017: the chain and the track it came from land in one write', async () => { // Same transaction on purpose: a day saying it follows a road whose vias never // arrived is worse than a day saying nothing at all. - service.createMany(1, { + (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: 10, stray_km: 0.8 }, - }); + })); - expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]); + expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 10, stray_km: 0.8 }]); }); - it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', () => { - service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } }); - service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } }); + it('ROADTRIP-SVC-018: laying a second track on a day replaces the first, never doubles it', async () => { + (await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 2 } })); + (await service.createMany(1, { vias: [], track: { place_id: 12, stray_km: null } })); - expect(service.tracksForTrip(7)).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]); + expect((await service.tracksForTrip(7))).toEqual([{ day_id: 1, place_id: 12, stray_km: null }]); }); - it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', () => { - service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } }); + it('ROADTRIP-SVC-019: three states, not two \u2014 absent keeps, null clears', async () => { + (await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: 1 } })); // Absent: a hand-dragged via on the same day must not wipe the road it follows. - service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }); - expect(service.tracksForTrip(7)).toHaveLength(1); + (await service.createMany(1, { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] })); + expect((await service.tracksForTrip(7))).toHaveLength(1); - service.createMany(1, { vias: [], replace_legs: [0], track: null }); - expect(service.tracksForTrip(7)).toEqual([]); + (await service.createMany(1, { vias: [], replace_legs: [0], track: null })); + expect((await service.tracksForTrip(7))).toEqual([]); }); - it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', () => { - service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } }); - service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } }); + it('ROADTRIP-SVC-020: the tracks of one trip are not the tracks of another', async () => { + (await service.createMany(1, { vias: [], track: { place_id: 10, stray_km: null } })); + (await service.createMany(3, { vias: [], track: { place_id: 12, stray_km: null } })); - expect(service.tracksForTrip(7).map(t => t.day_id)).toEqual([1]); - expect(service.tracksForTrip(99).map(t => t.day_id)).toEqual([3]); + expect((await service.tracksForTrip(7)).map(t => t.day_id)).toEqual([1]); + expect((await service.tracksForTrip(99)).map(t => t.day_id)).toEqual([3]); }); - it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', () => { + it('ROADTRIP-SVC-021: only a track of this trip can become a day\u2019s label', async () => { // Permission is not enough on its own: without this a place id from another trip // would become this day\u2019s label, and an ordinary place would become a label that // can never be drawn. - expect(service.trackExists(10, 7)).toBe(true); - expect(service.trackExists(11, 7)).toBe(false); - expect(service.trackExists(12, 7)).toBe(false); - expect(service.trackExists(999, 7)).toBe(false); + expect((await service.trackExists(10, 7))).toBe(true); + expect((await service.trackExists(11, 7))).toBe(false); + expect((await service.trackExists(12, 7))).toBe(false); + expect((await service.trackExists(999, 7))).toBe(false); }); - it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', () => { - const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + it('ROADTRIP-SVC-007: removing one reports whether there was anything to remove', async () => { + const via = (await service.create(1, { after_order_index: 0, lat: 53, lng: 10 })); - expect(service.remove(via.id, 1)).toBe(true); - expect(service.remove(via.id, 1)).toBe(false); - expect(service.listForDay(1)).toEqual([]); + expect((await service.remove(via.id, 1))).toBe(true); + expect((await service.remove(via.id, 1))).toBe(false); + expect((await service.listForDay(1))).toEqual([]); }); }); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 634582d79..835008954 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -394,31 +394,31 @@ describe('TripsController (parity with the legacy /api/trips route)', async () = describe('GET /:id/export.ics', () => { function makeRes() { return { setHeader: vi.fn(), send: vi.fn() } as never; } - it('404 without access, else sends the calendar with headers', () => { - expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + it('404 without access, else sends the calendar with headers', async () => { + expect(await thrownAsync(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const res = { setHeader: vi.fn(), send: vi.fn() }; const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) }; - tc(svc(), cal).exportIcs(user, '9', res as never); + await tc(svc(), cal).exportIcs(user, '9', res as never); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/calendar; charset=utf-8'); expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"'); expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR'); }); - it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', () => { + it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', async () => { const res = { setHeader: vi.fn(), send: vi.fn() }; const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: '沖縄.ics' }) }; - tc(svc(), cal).exportIcs(user, '9', res as never); + await tc(svc(), cal).exportIcs(user, '9', res as never); expect(res.setHeader).toHaveBeenCalledWith( 'Content-Disposition', 'attachment; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84.ics', ); }); - it('maps a NotFoundError from the export to 404 and re-throws others', () => { + it('maps a NotFoundError from the export to 404 and re-throws others', async () => { const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) }; - expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); + expect(await thrownAsync(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } }); const other = { exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) }; - expect(() => tc(svc(), other).exportIcs(user, '9', makeRes())).toThrow('boom'); + await expect(tc(svc(), other).exportIcs(user, '9', makeRes())).rejects.toThrow('boom'); }); }); diff --git a/server/tests/unit/services/airtrailWriteback.test.ts b/server/tests/unit/services/airtrailWriteback.test.ts index 41e8f754b..2c26adf74 100644 --- a/server/tests/unit/services/airtrailWriteback.test.ts +++ b/server/tests/unit/services/airtrailWriteback.test.ts @@ -20,29 +20,29 @@ const svc = new AirtrailService( new AuditService(new DatabaseService(db)), new AirtrailClient(), ); -const getConnectionSettings = svc.getConnectionSettings.bind(svc); -const isAirtrailWriteEnabled = svc.isAirtrailWriteEnabled.bind(svc); -const saveSettings = svc.saveSettings.bind(svc); +const getConnectionSettings = (...args: Parameters) => svc.getConnectionSettings(...args); +const isAirtrailWriteEnabled = (...args: Parameters) => svc.isAirtrailWriteEnabled(...args); +const saveSettings = (...args: Parameters) => svc.saveSettings(...args); describe('airtrail writeback opt-in persistence (#1240)', () => { - it('defaults the writeback opt-in to off for a new user', () => { + it('defaults the writeback opt-in to off for a new user', async () => { const { user } = createUser(db); - expect(isAirtrailWriteEnabled(user.id)).toBe(false); - expect(getConnectionSettings(user.id).writeEnabled).toBe(false); + expect(await isAirtrailWriteEnabled(user.id)).toBe(false); + expect((await getConnectionSettings(user.id)).writeEnabled).toBe(false); }); it('persists the opt-in and lets it be toggled back off without dropping the key', async () => { const { user } = createUser(db); await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null); - expect(isAirtrailWriteEnabled(user.id)).toBe(true); - const on = getConnectionSettings(user.id); + expect(await isAirtrailWriteEnabled(user.id)).toBe(true); + const on = await getConnectionSettings(user.id); expect(on.writeEnabled).toBe(true); expect(on.connected).toBe(true); // key stored // No key supplied keeps the stored key; only the opt-in flips back off. await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null); - expect(isAirtrailWriteEnabled(user.id)).toBe(false); - expect(getConnectionSettings(user.id).connected).toBe(true); + expect(await isAirtrailWriteEnabled(user.id)).toBe(false); + expect((await getConnectionSettings(user.id)).connected).toBe(true); }); });