diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index b8f72842b..260a614c5 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -84,7 +84,11 @@ export class AssignmentsService { * the journey stays in sync. Non-fatal, like the route's try/catch. */ reconcile(tripId: string | number, socketId?: string): void { - try { this.journey.reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ } + // R1.5: reconcile() is called fire-and-forget from a dozen sync call sites across + // this domain; reconcileTripSkeletons is now async, so the rejection is caught here + // instead of escaping as an unhandled promise rejection — same "non-fatal" contract + // the try/catch gave it before the sweep. + void this.journey.reconcileTripSkeletons(Number(tripId), socketId).catch(() => { /* non-fatal */ }); } /** diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index 665f17b5e..f2a8c93cc 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -291,18 +291,18 @@ export class DawarichSuggestionsService { * arrival time fills the entry's time — the two fields a person would * otherwise copy off the suggestion by hand. */ - private acceptAsJournalEntry( + private async acceptAsJournalEntry( userId: number, row: SuggestionRow, body: DawarichAccept, sid?: string, - ): DawarichAcceptResult { + ): Promise { if (body.journalId === undefined) { throw new AcceptError('journal_required', 'A journey is required to create an entry', 400); } // canEdit is the journey domain's own answer, and createEntry asks it again. // Asking here too is what turns "silently did nothing" into a 403. - if (!this.journey.canEdit(body.journalId, userId)) { + if (!(await this.journey.canEdit(body.journalId, userId))) { throw new AcceptError('journal_forbidden', 'Journey not found', 404); } @@ -314,8 +314,8 @@ export class DawarichSuggestionsService { // that one notification can still outrun a rollback. Moving it out means // changing that domain's contract, which this integration has no business // doing; the write itself is what had to stop being two. - const entry = this.db.transaction(() => { - const created = this.journey.createEntry( + const entry = await this.uow.transactional(async () => { + const created = await this.journey.createEntry( body.journalId, userId, { diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts index 04cc2b2bd..a36fca81a 100644 --- a/server/src/nest/journey/journal.rpc.ts +++ b/server/src/nest/journey/journal.rpc.ts @@ -63,7 +63,7 @@ export class JournalRpc { await this.requireJourneyAddon(); // listEntries self-gates via canAccessJourney and returns null when the user // cannot see it. - const entries = this.journey.listEntries(journeyId, userId); + const entries = await this.journey.listEntries(journeyId, userId); if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`); return entries; } @@ -75,7 +75,7 @@ export class JournalRpc { if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required'); const journeyId = num(params.journeyId, 'journeyId'); await this.requireJourneyAddon(); - const entry = this.journey.createEntry(journeyId, userId, input as never); + const entry = await this.journey.createEntry(journeyId, userId, input as never); if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`); return entry; } @@ -85,7 +85,7 @@ export class JournalRpc { const userId = this.requireJournalUser(ctx, 'writes'); const entryId = num(params.entryId, 'entryId'); await this.requireJourneyAddon(); - const entry = this.journey.updateEntry(entryId, userId, asPayload(params.input) as never); + const entry = await this.journey.updateEntry(entryId, userId, asPayload(params.input) as never); if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); return entry; } @@ -95,7 +95,7 @@ export class JournalRpc { const userId = this.requireJournalUser(ctx, 'writes'); const entryId = num(params.entryId, 'entryId'); await this.requireJourneyAddon(); - if (!this.journey.deleteEntry(entryId, userId)) { + if (!(await this.journey.deleteEntry(entryId, userId))) { throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`); } return { deleted: true }; @@ -192,7 +192,7 @@ export class JournalRpc { const userId = this.requireJournalUser(ctx, 'writes'); const journeyId = num(params.journeyId, 'journeyId'); await this.requireJourneyAddon(); - if (!this.journey.deleteJourney(journeyId, userId)) { + if (!(await this.journey.deleteJourney(journeyId, userId))) { throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`); } return { deleted: true }; diff --git a/server/src/nest/journey/journey-book.service.ts b/server/src/nest/journey/journey-book.service.ts index 9df274c64..c21dc0fd6 100644 --- a/server/src/nest/journey/journey-book.service.ts +++ b/server/src/nest/journey/journey-book.service.ts @@ -34,8 +34,8 @@ export class JourneyBookService { ) {} /** Null when the journey does not exist or the user cannot reach it. */ - private canAccess(journeyId: number, userId: number): boolean { - return !!this.journey.canAccessJourney(journeyId, userId); + private async canAccess(journeyId: number, userId: number): Promise { + return !!(await this.journey.canAccessJourney(journeyId, userId)); } /** @@ -43,7 +43,7 @@ export class JourneyBookService { * same owner-or-editor check the entry and photo writes take. canAccess also * covers role 'viewer', who may read the book but must not overwrite it. */ - private canWrite(journeyId: number, userId: number): boolean { + private async canWrite(journeyId: number, userId: number): Promise { return this.journey.canEdit(journeyId, userId); } @@ -53,7 +53,7 @@ export class JourneyBookService { * Public so the controller can tell "no book yet" from "no journey" without * running a query built for something else. */ - canOpen(journeyId: number, userId: number): boolean { + async canOpen(journeyId: number, userId: number): Promise { return this.canAccess(journeyId, userId); } @@ -72,8 +72,8 @@ export class JourneyBookService { }; } - listBooks(journeyId: number, userId: number): BookSummary[] | null { - if (!this.canAccess(journeyId, userId)) return null; + async listBooks(journeyId: number, userId: number): Promise { + if (!(await this.canAccess(journeyId, userId))) return null; const rows = this.db .prepare(` SELECT id, journey_id, title, version, updated_at, updated_by @@ -99,8 +99,8 @@ export class JourneyBookService { * the same trip is an obvious thing to want and adding a column later is * harder than not needing to. */ - getBook(journeyId: number, userId: number): BookRecord | null { - if (!this.canAccess(journeyId, userId)) return null; + async getBook(journeyId: number, userId: number): Promise { + if (!(await this.canAccess(journeyId, userId))) return null; const row = this.db .prepare(` SELECT id, journey_id, title, document, version, updated_at, updated_by @@ -120,12 +120,12 @@ export class JourneyBookService { * moved. Throwing would be the obvious shape and the wrong one: a conflict is * an ordinary outcome of two people working, not an exception. */ - saveBook( + async saveBook( journeyId: number, userId: number, input: { title: string; document: unknown; baseVersion?: number }, - ): { record: BookRecord } | { conflict: BookRecord } | null { - if (!this.canWrite(journeyId, userId)) return null; + ): Promise<{ record: BookRecord } | { conflict: BookRecord } | null> { + if (!(await this.canWrite(journeyId, userId))) return null; const document = JSON.stringify(normalizeBookDocument(input.document)); const existing = this.db @@ -139,7 +139,7 @@ export class JourneyBookService { VALUES (?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP) `) .run(journeyId, input.title, document, userId, userId); - return { record: this.byId(Number(result.lastInsertRowid))! }; + return { record: (await this.byId(Number(result.lastInsertRowid)))! }; } /* @@ -164,13 +164,13 @@ export class JourneyBookService { .run(input.title, document, userId, existing.id, base); if (result.changes === 0) { - return { conflict: this.byId(existing.id)! }; + return { conflict: (await this.byId(existing.id))! }; } - return { record: this.byId(existing.id)! }; + return { record: (await this.byId(existing.id))! }; } - deleteBook(journeyId: number, userId: number): boolean | null { - if (!this.canWrite(journeyId, userId)) return null; + async deleteBook(journeyId: number, userId: number): Promise { + if (!(await this.canWrite(journeyId, userId))) return null; const result = this.db .prepare('DELETE FROM journey_books WHERE journey_id = ?') .run(journeyId); @@ -188,8 +188,8 @@ export class JourneyBookService { * The saver is excluded by socket id, the same way every other TREK mutation * does it, so the client that just saved does not process its own change. */ - broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) { - this.journey.broadcastJourneyEvent( + async broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) { + await this.journey.broadcastJourneyEvent( journeyId, 'journey:book:saved', { version: record.version, savedBy: userId }, @@ -197,7 +197,7 @@ export class JourneyBookService { ); } - private byId(id: number): BookRecord | null { + private async byId(id: number): Promise { const row = this.db .prepare(` SELECT id, journey_id, title, document, version, updated_at, updated_by diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index be1c7eb85..123341a2b 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -4,6 +4,7 @@ import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '.. import { decodeEntryRow, type JourneyEntryWire } from './journey-entry-row'; import { GALLERY_CHRONOLOGICAL_ORDER } from './journey-gallery-order'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { RealtimeService } from '../realtime/realtime.service'; import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; @@ -94,6 +95,7 @@ export class JourneyDomainService { private readonly db: DatabaseService, private readonly realtime: RealtimeService, private readonly photos: TrekPhotosRepository, + private readonly uow: UnitOfWork, ) {} private ts(): number { @@ -109,7 +111,7 @@ export class JourneyDomainService { * journey is one question with one answer, and a second copy of this walk * over contributors and owner would be a second answer waiting to disagree. */ - broadcastJourneyEvent( + async broadcastJourneyEvent( journeyId: number, // Typed against the shared WS registry rather than left as `string`: the // legacy broadcastToUser took anything, so a typo'd event name shipped as a @@ -117,7 +119,7 @@ export class JourneyDomainService { event: TrekWsUserEventName, data: Record, excludeSocketId?: string | number, - ) { + ): Promise { const contributors = this.db.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ?').all(journeyId) as { user_id: number; }[]; @@ -135,7 +137,7 @@ export class JourneyDomainService { // ── Access control ─────────────────────────────────────────────────────── - canAccessJourney(journeyId: number, userId: number): Journey | null { + async canAccessJourney(journeyId: number, userId: number): Promise { const own = this.db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as | Journey | undefined; @@ -147,12 +149,12 @@ export class JourneyDomainService { return null; } - isOwner(journeyId: number, userId: number): boolean { + async isOwner(journeyId: number, userId: number): Promise { return !!this.db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId); } - canEdit(journeyId: number, userId: number): boolean { - if (this.isOwner(journeyId, userId)) return true; + async canEdit(journeyId: number, userId: number): Promise { + if (await this.isOwner(journeyId, userId)) return true; const c = this.db .prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?') .get(journeyId, userId) as { role: string } | undefined; @@ -161,7 +163,7 @@ export class JourneyDomainService { // ── Journey CRUD ───────────────────────────────────────────────────────── - listJourneys(userId: number) { + async listJourneys(userId: number) { return this.db .prepare( ` @@ -186,14 +188,14 @@ export class JourneyDomainService { })[]; } - createJourney( + async createJourney( userId: number, data: { title: string; subtitle?: string; trip_ids?: number[]; }, - ): Journey { + ): Promise { const now = this.ts(); const res = this.db .prepare( @@ -221,7 +223,7 @@ export class JourneyDomainService { // trip_ids[0] would otherwise leak an arbitrary trip's cover image cross-tenant. let coverTripId: number | undefined; for (const tripId of data.trip_ids) { - if (this.addTripToJourney(journeyId, tripId, userId) && coverTripId === undefined) coverTripId = tripId; + if ((await this.addTripToJourney(journeyId, tripId, userId)) && coverTripId === undefined) coverTripId = tripId; } if (coverTripId !== undefined) { @@ -239,8 +241,8 @@ export class JourneyDomainService { return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; } - getJourneyFull(journeyId: number, userId: number) { - const journey = this.canAccessJourney(journeyId, userId); + async getJourneyFull(journeyId: number, userId: number) { + const journey = await this.canAccessJourney(journeyId, userId); if (!journey) return null; const entries = this.db @@ -342,7 +344,7 @@ export class JourneyDomainService { }; } - updateJourney( + async updateJourney( journeyId: number, userId: number, data: Partial<{ @@ -356,10 +358,10 @@ export class JourneyDomainService { show_mood: boolean | number; show_weather: boolean | number; }>, - ): Journey | null { + ): Promise { // Journey-level settings (title, cover, status) are owner-only — editors // may only edit entries and photos, not reshape the journey itself. - if (!this.isOwner(journeyId, userId)) return null; + if (!(await this.isOwner(journeyId, userId))) return null; const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived']; const allowed = [ @@ -394,8 +396,8 @@ export class JourneyDomainService { return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; } - updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { - if (!this.canAccessJourney(journeyId, userId)) return null; + async updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { + if (!(await this.canAccessJourney(journeyId, userId))) return null; if (data.hide_skeletons !== undefined) { this.db.prepare('UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?').run( data.hide_skeletons ? 1 : 0, @@ -417,24 +419,24 @@ export class JourneyDomainService { * things you have already said you did not want is not a screen worth * building. The count comes back so the caller can say what happened. */ - restoreDismissedSuggestions(journeyId: number, userId: number): { restored: number } | null { - if (!this.canEdit(journeyId, userId)) return null; + async restoreDismissedSuggestions(journeyId: number, userId: number): Promise<{ restored: number } | null> { + if (!(await this.canEdit(journeyId, userId))) return null; const res = this.db .prepare('UPDATE journey_entries SET dismissed = 0 WHERE journey_id = ? AND dismissed = 1') .run(journeyId); - if (res.changes > 0) this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes }); + if (res.changes > 0) await this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes }); return { restored: res.changes }; } - deleteJourney(journeyId: number, userId: number): boolean { - if (!this.isOwner(journeyId, userId)) return false; + async deleteJourney(journeyId: number, userId: number): Promise { + if (!(await this.isOwner(journeyId, userId))) return false; this.db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); return true; } // ── Trip management ────────────────────────────────────────────────────── - addTripToJourney(journeyId: number, tripId: number, userId: number): boolean { + async addTripToJourney(journeyId: number, tripId: number, userId: number): Promise { // Only attach a trip the caller can actually access — otherwise a journey // owner could pull an arbitrary trip's places + photos into their journey // (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped @@ -443,7 +445,7 @@ export class JourneyDomainService { // And a journey the caller can actually reach. Without this, any logged-in user // could link a trip of theirs into a stranger's journey and seed entries and // photos there — the MCP tool has always checked this, the REST route never did. - if (!this.canAccessJourney(journeyId, userId)) return false; + if (!(await this.canAccessJourney(journeyId, userId))) return false; const now = this.ts(); try { this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run( @@ -456,19 +458,19 @@ export class JourneyDomainService { } // sync skeleton entries for all places in this trip - this.syncTripPlaces(journeyId, tripId, userId); + await this.syncTripPlaces(journeyId, tripId, userId); // Trip photos are deliberately NOT pulled in any more (#1614). Photos live in // journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to // it on an install newer than that, and copying rows between the two was what // let a photo one member had chosen not to share reach a journey at all. The // table and its (unreferenced) routes stay for one more release rather than // being dropped in an append-only migration. - this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }); + await this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }); return true; } - removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean { - if (!this.isOwner(journeyId, userId)) return false; + async removeTripFromJourney(journeyId: number, tripId: number, userId: number): Promise { + if (!(await this.isOwner(journeyId, userId))) return false; // remove skeleton entries that haven't been filled in this.db.prepare( @@ -492,7 +494,7 @@ export class JourneyDomainService { // ── Sync engine ────────────────────────────────────────────────────────── - syncTripPlaces(journeyId: number, tripId: number, authorId: number) { + async syncTripPlaces(journeyId: number, tripId: number, authorId: number) { const places = this.db .prepare( ` @@ -531,7 +533,7 @@ export class JourneyDomainService { const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; dateMaxOrder.set(entryDate, nextOrder); - this.insertSkeletonEntry({ + await this.insertSkeletonEntry({ journeyId, tripId, placeId: place.id, @@ -550,7 +552,7 @@ export class JourneyDomainService { } // called when a trip place is created - onPlaceCreated(tripId: number, placeId: number) { + async onPlaceCreated(tripId: number, placeId: number) { const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number; }[]; @@ -589,7 +591,7 @@ export class JourneyDomainService { .get(link.journey_id, entryDate) as { m: number | null }; const nextOrder = (maxOrder?.m ?? -1) + 1; - this.insertSkeletonEntry({ + await this.insertSkeletonEntry({ journeyId: link.journey_id, tripId, placeId, @@ -609,7 +611,7 @@ export class JourneyDomainService { } // called when a trip place is updated - onPlaceUpdated(placeId: number) { + async onPlaceUpdated(placeId: number) { const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; if (!entries.length) return; @@ -673,7 +675,7 @@ export class JourneyDomainService { } // called when a trip place is deleted - onPlaceDeleted(placeId: number) { + async onPlaceDeleted(placeId: number) { const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[]; for (const entry of entries) { @@ -711,7 +713,7 @@ export class JourneyDomainService { } } - private insertSkeletonEntry(p: { + private async insertSkeletonEntry(p: { journeyId: number; tripId: number; placeId: number; @@ -757,7 +759,7 @@ export class JourneyDomainService { // only detached + annotated, mirroring onPlaceDeleted. Idempotent: a second call with // no underlying change is a no-op (no writes, no broadcast). Called from every // assignment mutation path. - reconcileTripSkeletons(tripId: number, sid?: string | number) { + async reconcileTripSkeletons(tripId: number, sid?: string | number) { const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number; }[]; @@ -857,7 +859,7 @@ export class JourneyDomainService { if (!found) { const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1; dateMaxOrder.set(entryDate, nextOrder); - this.insertSkeletonEntry({ + await this.insertSkeletonEntry({ journeyId: journey_id, tripId, placeId: place.id, @@ -926,7 +928,7 @@ export class JourneyDomainService { changed = true; } - if (changed) this.broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid); + if (changed) await this.broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid); } } @@ -941,8 +943,8 @@ export class JourneyDomainService { * Only places that actually carry geometry are returned, so a journey whose trips * have no tracks answers with an empty list rather than a wall of null points. */ - journeyTracks(journeyId: number, userId: number): JourneyTrack[] | null { - if (!this.canAccessJourney(journeyId, userId)) return null; + async journeyTracks(journeyId: number, userId: number): Promise { + if (!(await this.canAccessJourney(journeyId, userId))) return null; const rows = this.db.prepare(` SELECT DISTINCT p.id AS place_id, p.trip_id, p.name, p.route_color, p.route_geometry @@ -1025,8 +1027,8 @@ export class JourneyDomainService { * (discussion #2064). It is still named, under `excluded`, because the only * way to switch a stop back on is to be able to see it. */ - journeyStats(journeyId: number, userId: number): JourneyStats | null { - if (!this.canAccessJourney(journeyId, userId)) return null; + async journeyStats(journeyId: number, userId: number): Promise { + if (!(await this.canAccessJourney(journeyId, userId))) return null; const entryRows = this.db.prepare(` SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id, @@ -1245,8 +1247,8 @@ export class JourneyDomainService { }); } - listEntries(journeyId: number, userId: number) { - if (!this.canAccessJourney(journeyId, userId)) return null; + async listEntries(journeyId: number, userId: number) { + if (!(await this.canAccessJourney(journeyId, userId))) return null; const entries = this.db .prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC') @@ -1273,7 +1275,7 @@ export class JourneyDomainService { })); } - createEntry( + async createEntry( journeyId: number, userId: number, data: { @@ -1293,8 +1295,8 @@ export class JourneyDomainService { sort_order?: number; }, sid?: string, - ): JourneyEntryWire | null { - if (!this.canEdit(journeyId, userId)) return null; + ): Promise { + if (!(await this.canEdit(journeyId, userId))) return null; const now = this.ts(); const maxOrder = this.db @@ -1340,11 +1342,11 @@ export class JourneyDomainService { .prepare('SELECT * FROM journey_entries WHERE id = ?') .get(Number(res.lastInsertRowid)) as JourneyEntry, ); - this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid); + await this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid); return created; } - updateEntry( + async updateEntry( entryId: number, userId: number, data: Partial<{ @@ -1366,10 +1368,10 @@ export class JourneyDomainService { dismissed: boolean; }>, sid?: string, - ): JourneyEntryWire | null { + ): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; - if (!this.canEdit(entry.journey_id, userId)) return null; + if (!(await this.canEdit(entry.journey_id, userId))) return null; const fields: string[] = []; const values: unknown[] = []; @@ -1445,15 +1447,15 @@ export class JourneyDomainService { const updated = decodeEntryRow( this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry, ); - this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); + await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); return updated; } // Reorder entries (typically within a single day). Caller passes the new // desired order of ids; each entry's sort_order is set to its index in the // array. Only entries owned by this journey are accepted. - reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean { - if (!this.canEdit(journeyId, userId)) return false; + async reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): Promise { + if (!(await this.canEdit(journeyId, userId))) return false; if (!orderedIds.length) return true; const placeholders = orderedIds.map(() => '?').join(','); @@ -1464,20 +1466,19 @@ export class JourneyDomainService { const now = this.ts(); const update = this.db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?'); - const tx = this.db.connection.transaction(() => { + await this.uow.transactional(async () => { orderedIds.forEach((id, index) => update.run(index, now, id)); this.db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId); }); - tx(); - this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); + await this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); return true; } - deleteEntry(entryId: number, userId: number, sid?: string): boolean { + async deleteEntry(entryId: number, userId: number, sid?: string): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return false; - if (!this.canEdit(entry.journey_id, userId)) return false; + if (!(await this.canEdit(entry.journey_id, userId))) return false; if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { // Revert filled entry back to skeleton instead of deleting @@ -1489,10 +1490,10 @@ export class JourneyDomainService { WHERE id = ? `, ).run(this.ts(), entryId); - this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); + await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); } else { this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); - this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); + await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); } return true; @@ -1503,13 +1504,13 @@ export class JourneyDomainService { // Promote a skeleton suggestion to a concrete entry. Called whenever the user // adds content (photo upload, provider photo, gallery link) — a suggestion // with photos is no longer just a suggestion. - private promoteSkeletonIfNeeded(entry: JourneyEntry): void { + private async promoteSkeletonIfNeeded(entry: JourneyEntry): Promise { if (entry.type !== 'skeleton') return; this.db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', this.ts(), entry.id); } // Ensure a trek_photo_id is in the journey gallery; return its gallery row id. - private ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number { + private async ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): Promise { const now = this.ts(); const maxOrderRow = this.db .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') @@ -1527,7 +1528,7 @@ export class JourneyDomainService { } // Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row. - private linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null { + private async linkGalleryPhotoToEntry(galleryId: number, entryId: number): Promise { const now = this.ts(); const maxOrderRow = this.db .prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?') @@ -1562,7 +1563,7 @@ export class JourneyDomainService { ): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; - if (!this.canEdit(entry.journey_id, userId)) return null; + if (!(await this.canEdit(entry.journey_id, userId))) return null; const trekPhotoId = await this.photos.getOrCreateLocal( filePath, @@ -1572,9 +1573,9 @@ export class JourneyDomainService { media?.mediaType || 'image', media?.durationMs ?? null, ); - const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))(); - const result = this.linkGalleryPhotoToEntry(galleryId, entryId); - this.promoteSkeletonIfNeeded(entry); + const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(entry.journey_id, trekPhotoId, caption)); + const result = await this.linkGalleryPhotoToEntry(galleryId, entryId); + await this.promoteSkeletonIfNeeded(entry); return result; } @@ -1589,7 +1590,7 @@ export class JourneyDomainService { ): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; - if (!this.canEdit(entry.journey_id, userId)) return null; + if (!(await this.canEdit(entry.journey_id, userId))) return null; const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); @@ -1605,17 +1606,17 @@ export class JourneyDomainService { .get(entryId, trekPhotoId); if (alreadyLinked) return null; - const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))(); - const result = this.linkGalleryPhotoToEntry(galleryId, entryId); - this.promoteSkeletonIfNeeded(entry); + const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(entry.journey_id, trekPhotoId, caption)); + const result = await this.linkGalleryPhotoToEntry(galleryId, entryId); + await this.promoteSkeletonIfNeeded(entry); return result; } // Link a gallery photo (by its journey_photos.id) to an entry — idempotent. - linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null { + async linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; - if (!this.canEdit(entry.journey_id, userId)) return null; + if (!(await this.canEdit(entry.journey_id, userId))) return null; // Verify the gallery photo belongs to this journey const galleryRow = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as @@ -1623,8 +1624,8 @@ export class JourneyDomainService { | undefined; if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null; - const result = this.linkGalleryPhotoToEntry(galleryRow.id, entryId); - this.promoteSkeletonIfNeeded(entry); + const result = await this.linkGalleryPhotoToEntry(galleryRow.id, entryId); + await this.promoteSkeletonIfNeeded(entry); return result; } @@ -1634,7 +1635,7 @@ export class JourneyDomainService { userId: number, filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], ): Promise { - if (!this.canEdit(journeyId, userId)) return []; + if (!(await this.canEdit(journeyId, userId))) return []; const results: any[] = []; const now = this.ts(); const maxOrderRow = this.db @@ -1668,17 +1669,17 @@ export class JourneyDomainService { passphrase?: string, mediaType: string = 'image', ): Promise { - if (!this.canEdit(journeyId, userId)) return null; + if (!(await this.canEdit(journeyId, userId))) return null; const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); - const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))(); + const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(journeyId, trekPhotoId, caption)); return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null; } // Unlink a photo from a specific entry; gallery row is preserved. - unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean { + async unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): Promise { const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return false; - if (!this.canEdit(entry.journey_id, userId)) return false; + if (!(await this.canEdit(entry.journey_id, userId))) return false; const result = this.db .prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?') @@ -1695,7 +1696,7 @@ export class JourneyDomainService { | { id: number; journey_id: number; photo_id: number } | undefined; if (!row) return null; - if (!this.canEdit(row.journey_id, userId)) return null; + if (!(await this.canEdit(row.journey_id, userId))) return null; const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as | { file_path?: string; thumbnail_path?: string; provider?: string } @@ -1724,17 +1725,17 @@ export class JourneyDomainService { ); } - updatePhoto( + async updatePhoto( photoId: number, userId: number, data: { caption?: string; sort_order?: number }, - ): JourneyPhoto | null { + ): Promise { // photoId = journey_photos.id (gallery row) const row = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as | { id: number; journey_id: number } | undefined; if (!row) return null; - if (!this.canEdit(row.journey_id, userId)) return null; + if (!(await this.canEdit(row.journey_id, userId))) return null; // caption lives on the gallery row; sort_order lives on the junction table // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order @@ -1760,7 +1761,7 @@ export class JourneyDomainService { | { id: number; journey_id: number; photo_id: number } | undefined; if (!row) return null; - if (!this.canEdit(row.journey_id, userId)) return null; + if (!(await this.canEdit(row.journey_id, userId))) return null; const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as | { file_path?: string; thumbnail_path?: string; provider?: string } @@ -1774,43 +1775,43 @@ export class JourneyDomainService { // ── Contributors ───────────────────────────────────────────────────────── - addContributor( + async addContributor( journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer', - ): boolean { - if (!this.isOwner(journeyId, userId)) return false; + ): Promise { + if (!(await this.isOwner(journeyId, userId))) return false; if (targetUserId === userId) return false; try { this.db.prepare( 'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', ).run(journeyId, targetUserId, role, this.ts()); - this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); + await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); return true; } catch { return false; } } - updateContributorRole( + async updateContributorRole( journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer', - ): boolean { - if (!this.isOwner(journeyId, userId)) return false; + ): Promise { + if (!(await this.isOwner(journeyId, userId))) return false; this.db.prepare('UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?').run( role, journeyId, targetUserId, ); - this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); + await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); return true; } - removeContributor(journeyId: number, userId: number, targetUserId: number): boolean { - if (!this.isOwner(journeyId, userId)) return false; + async removeContributor(journeyId: number, userId: number, targetUserId: number): Promise { + if (!(await this.isOwner(journeyId, userId))) return false; this.db.prepare("DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'").run( journeyId, targetUserId, @@ -1820,7 +1821,7 @@ export class JourneyDomainService { // ── Suggestions ────────────────────────────────────────────────────────── - getSuggestions(userId: number) { + async getSuggestions(userId: number) { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; return this.db .prepare( @@ -1842,7 +1843,7 @@ export class JourneyDomainService { // ── User trips (for trip picker) ───────────────────────────────────────── - listUserTrips(userId: number) { + async listUserTrips(userId: number) { return this.db .prepare( ` diff --git a/server/src/nest/journey/journey-public.controller.ts b/server/src/nest/journey/journey-public.controller.ts index 16b1fc199..a3a9b0795 100644 --- a/server/src/nest/journey/journey-public.controller.ts +++ b/server/src/nest/journey/journey-public.controller.ts @@ -43,7 +43,7 @@ export class JourneyPublicController { @Get(':token/photos/:photoId/:kind') async photo(@Param('token') token: string, @Param('photoId') photoId: string, @Param('kind') kind: string, @Res() res: Response): Promise { - const valid = this.journey.validateShareTokenForPhoto(token, Number(photoId)); + const valid = await this.journey.validateShareTokenForPhoto(token, Number(photoId)); if (!valid) { throw new HttpException({ error: 'Not found' }, 404); } @@ -58,7 +58,7 @@ export class JourneyPublicController { @Param('kind') kind: string, @Res() res: Response, ): Promise { - const valid = this.journey.validateShareTokenForAsset(token, assetId); + const valid = await this.journey.validateShareTokenForAsset(token, assetId); if (!valid) { throw new HttpException({ error: 'Not found' }, 404); } diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index 48d59c82d..f02c1bbcf 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -39,14 +39,14 @@ export class JourneyShareService { private readonly settings: SettingsService, ) {} - createOrUpdateJourneyShareLink( + async createOrUpdateJourneyShareLink( journeyId: number, createdBy: number, permissions: JourneySharePermissions - ): { token: string; created: boolean } | null { + ): Promise<{ token: string; created: boolean } | null> { // Public sharing is an owner-only action — editors/viewers must not be // able to publish the journey or change which screens are shared. - if (!this.journey.isOwner(journeyId, createdBy)) return null; + if (!(await this.journey.isOwner(journeyId, createdBy))) return null; const existing = this.db.prepare('SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?') .get(journeyId) as { token: string; share_timeline: number; share_gallery: number; share_map: number; newest_first: number } | undefined; @@ -87,15 +87,15 @@ export class JourneyShareService { * unpublished puts a "create link" button in front of somebody it will then * refuse. Both the REST route and the MCP tool read the link through here. */ - readJourneyShareLink( + async readJourneyShareLink( journeyId: number, userId: number, - ): { allowed: false } | { allowed: true; link: JourneyShareTokenInfo | null } { - if (!this.journey.isOwner(journeyId, userId)) return { allowed: false }; - return { allowed: true, link: this.getJourneyShareLink(journeyId) }; + ): Promise<{ allowed: false } | { allowed: true; link: JourneyShareTokenInfo | null }> { + if (!(await this.journey.isOwner(journeyId, userId))) return { allowed: false }; + return { allowed: true, link: await this.getJourneyShareLink(journeyId) }; } - getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null { + async getJourneyShareLink(journeyId: number): Promise { const row = this.db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any; if (!row) return null; return { @@ -108,13 +108,13 @@ export class JourneyShareService { }; } - deleteJourneyShareLink(journeyId: number, userId: number): boolean { - if (!this.journey.isOwner(journeyId, userId)) return false; + async deleteJourneyShareLink(journeyId: number, userId: number): Promise { + if (!(await this.journey.isOwner(journeyId, userId))) return false; this.db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId); return true; } - validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null { + async validateShareTokenForPhoto(token: string, photoId: number): Promise<{ journeyId: number; ownerId: number } | null> { const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any; if (!row) return null; // Photos only ever surface (inline or in the gallery) when share_gallery is on, @@ -133,7 +133,7 @@ export class JourneyShareService { return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null; } - validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null { + async validateShareTokenForAsset(token: string, assetId: string): Promise<{ ownerId: number } | null> { const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any; if (!row) return null; // Same as the unified photo proxy: no asset bytes leave the host unless the diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index c39fba457..7563fc2d6 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -192,8 +192,8 @@ export class JourneyController { // ── Static prefix routes (before /:id) ────────────────────────────────── @Get() - list(@CurrentUser() user: User) { - return { journeys: this.journey.listJourneys(user.id) }; + async list(@CurrentUser() user: User) { + return { journeys: await this.journey.listJourneys(user.id) }; } @Post() @@ -211,19 +211,19 @@ export class JourneyController { } @Get('suggestions') - suggestions(@CurrentUser() user: User) { - return { trips: this.journey.getSuggestions(user.id) }; + async suggestions(@CurrentUser() user: User) { + return { trips: await this.journey.getSuggestions(user.id) }; } @Get('available-trips') - availableTrips(@CurrentUser() user: User) { - return { trips: this.journey.listUserTrips(user.id) }; + async availableTrips(@CurrentUser() user: User) { + return { trips: await this.journey.listUserTrips(user.id) }; } // ── Entries (prefix /entries — before /:id) ───────────────────────────── @Patch('entries/:entryId') - updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyEntryUpdateDto, @Headers('x-socket-id') socketId?: string) { - const result = this.journey.updateEntry(Number(entryId), user.id, body, socketId); + async updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyEntryUpdateDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.journey.updateEntry(Number(entryId), user.id, body, socketId); if (!result) { throw new HttpException({ error: 'Entry not found' }, 404); } @@ -231,8 +231,8 @@ export class JourneyController { } @Delete('entries/:entryId') - deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) { - if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) { + async deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.journey.deleteEntry(Number(entryId), user.id, socketId))) { throw new HttpException({ error: 'Entry not found' }, 404); } return { success: true }; @@ -258,7 +258,7 @@ export class JourneyController { continue; } // Mirror to Immich only when the user explicitly opted in (#730). - if (this.journey.immichAutoUploadEnabled(user.id)) { + if (await this.journey.immichAutoUploadEnabled(user.id)) { try { const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname); if (immichId) { @@ -357,12 +357,12 @@ export class JourneyController { } @Post('entries/:entryId/link-photo') - linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyLinkPhotoDto) { + async linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyLinkPhotoDto) { const journeyPhotoId = body.journey_photo_id ?? body.photo_id; if (!journeyPhotoId) { throw new HttpException({ error: 'journey_photo_id required' }, 400); } - const result = this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id); + const result = await this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -371,15 +371,15 @@ export class JourneyController { @Delete('entries/:entryId/photos/:journeyPhotoId') @HttpCode(204) - unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): void { - if (!this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id)) { + async unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): Promise { + if (!(await this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id))) { throw new HttpException({ error: 'Not found or not allowed' }, 404); } } @Patch('photos/:photoId') - updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: JourneyPhotoUpdateDto) { - const result = this.journey.updatePhoto(Number(photoId), user.id, body); + async updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: JourneyPhotoUpdateDto) { + const result = await this.journey.updatePhoto(Number(photoId), user.id, body); if (!result) { throw new HttpException({ error: 'Photo not found' }, 404); } @@ -527,8 +527,8 @@ export class JourneyController { // ── Journeys /:id ─────────────────────────────────────────────────────── @Get(':id') - get(@CurrentUser() user: User, @Param('id') id: string) { - const data = this.journey.getJourneyFull(Number(id), user.id); + async get(@CurrentUser() user: User, @Param('id') id: string) { + const data = await this.journey.getJourneyFull(Number(id), user.id); if (!data) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -536,8 +536,8 @@ export class JourneyController { } @Patch(':id') - update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto) { - const result = this.journey.updateJourney(Number(id), user.id, body); + async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto) { + const result = await this.journey.updateJourney(Number(id), user.id, body); if (!result) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -552,7 +552,7 @@ export class JourneyController { throw new HttpException({ error: 'No file uploaded' }, 400); } await this.commitJourneyUploads([file]); - const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }); + const result = await this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }); if (!result) { await this.discardJourneyUploads([file]); throw new HttpException({ error: 'Journey not found' }, 404); @@ -561,8 +561,8 @@ export class JourneyController { } @Delete(':id') - remove(@CurrentUser() user: User, @Param('id') id: string) { - if (!this.journey.deleteJourney(Number(id), user.id)) { + async remove(@CurrentUser() user: User, @Param('id') id: string) { + if (!(await this.journey.deleteJourney(Number(id), user.id))) { throw new HttpException({ error: 'Journey not found' }, 404); } return { success: true }; @@ -571,19 +571,19 @@ export class JourneyController { // ── Journey trips ─────────────────────────────────────────────────────── @Post(':id/trips') @HttpCode(200) // Express answers with res.json (200). - addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyAddTripDto) { + async addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyAddTripDto) { if (!body.trip_id) { throw new HttpException({ error: 'trip_id required' }, 400); } - if (!this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id)) { + if (!(await this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; } @Delete(':id/trips/:tripId') - removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { - if (!this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id)) { + async removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { + if (!(await this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; @@ -591,8 +591,8 @@ export class JourneyController { // ── Entries under journey ─────────────────────────────────────────────── @Get(':id/entries') - listEntries(@CurrentUser() user: User, @Param('id') id: string) { - const entries = this.journey.listEntries(Number(id), user.id); + async listEntries(@CurrentUser() user: User, @Param('id') id: string) { + const entries = await this.journey.listEntries(Number(id), user.id); if (!entries) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -605,8 +605,8 @@ export class JourneyController { * planner is all it takes for it to show up here. */ @Get(':id/tracks') - listTracks(@CurrentUser() user: User, @Param('id') id: string) { - const tracks = this.journey.journeyTracks(Number(id), user.id); + async listTracks(@CurrentUser() user: User, @Param('id') id: string) { + const tracks = await this.journey.journeyTracks(Number(id), user.id); if (!tracks) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -623,8 +623,8 @@ export class JourneyController { * designing and never while printing. */ @Get(':id/stats') - stats(@CurrentUser() user: User, @Param('id') id: string) { - const stats = this.journey.journeyStats(Number(id), user.id); + async stats(@CurrentUser() user: User, @Param('id') id: string) { + const stats = await this.journey.journeyStats(Number(id), user.id); if (!stats) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -640,9 +640,9 @@ export class JourneyController { */ @Get(':id/book') - getBook(@CurrentUser() user: User, @Param('id') id: string) { - const book = this.books.getBook(Number(id), user.id); - if (book === null && !this.books.canOpen(Number(id), user.id)) { + async getBook(@CurrentUser() user: User, @Param('id') id: string) { + const book = await this.books.getBook(Number(id), user.id); + if (book === null && !(await this.books.canOpen(Number(id), user.id))) { throw new HttpException({ error: 'Journey not found' }, 404); } // A journey with no book yet is not an error: Studio opens, lays one out @@ -653,19 +653,19 @@ export class JourneyController { @Put(':id/book') @HttpCode(200) - saveBook( + async saveBook( @CurrentUser() user: User, @Param('id') id: string, @Body() body: BookSaveDto, @Headers('x-socket-id') socketId?: string, ) { - const result = this.books.saveBook(Number(id), user.id, { + const result = await this.books.saveBook(Number(id), user.id, { title: body.title ?? '', document: body.document, baseVersion: body.baseVersion, }); if (result === null) { - throw this.bookRefusal(Number(id), user.id); + throw await this.bookRefusal(Number(id), user.id); } if ('conflict' in result) { /* @@ -681,16 +681,16 @@ export class JourneyController { 409, ); } - this.books.broadcastSaved(Number(id), user.id, result.record, socketId); + await this.books.broadcastSaved(Number(id), user.id, result.record, socketId); return result.record; } @Delete(':id/book') @HttpCode(204) - deleteBook(@CurrentUser() user: User, @Param('id') id: string) { - const removed = this.books.deleteBook(Number(id), user.id); + async deleteBook(@CurrentUser() user: User, @Param('id') id: string) { + const removed = await this.books.deleteBook(Number(id), user.id); if (removed === null) { - throw this.bookRefusal(Number(id), user.id); + throw await this.bookRefusal(Number(id), user.id); } } @@ -702,18 +702,18 @@ export class JourneyController { * not exist. Someone who cannot see the journey at all still gets the 404 — * the status is not an existence oracle for them. */ - private bookRefusal(journeyId: number, userId: number): HttpException { - return this.books.canOpen(journeyId, userId) + private async bookRefusal(journeyId: number, userId: number): Promise { + return (await this.books.canOpen(journeyId, userId)) ? new HttpException({ error: 'Not allowed' }, 403) : new HttpException({ error: 'Journey not found' }, 404); } @Post(':id/entries') - createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) { + async createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) { if (!body.entry_date) { throw new HttpException({ error: 'entry_date is required' }, 400); } - const entry = this.journey.createEntry(Number(id), user.id, body, socketId); + const entry = await this.journey.createEntry(Number(id), user.id, body, socketId); if (!entry) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -721,12 +721,12 @@ export class JourneyController { } @Put(':id/entries/reorder') - reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyReorderEntriesDto, @Headers('x-socket-id') socketId?: string) { + async reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyReorderEntriesDto, @Headers('x-socket-id') socketId?: string) { const orderedIds = body.orderedIds; if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) { throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400); } - if (!this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId)) { + if (!(await this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; @@ -734,27 +734,27 @@ export class JourneyController { // ── Contributors ──────────────────────────────────────────────────────── @Post(':id/contributors') - addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyContributorAddDto) { + async addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyContributorAddDto) { if (!body.user_id) { throw new HttpException({ error: 'user_id required' }, 400); } - if (!this.journey.addContributor(Number(id), user.id, Number(body.user_id), (body.role as 'editor' | 'viewer') || 'viewer')) { + if (!(await this.journey.addContributor(Number(id), user.id, Number(body.user_id), (body.role as 'editor' | 'viewer') || 'viewer'))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; } @Patch(':id/contributors/:userId') - updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: JourneyContributorUpdateDto) { - if (!this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer')) { + async updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: JourneyContributorUpdateDto) { + if (!(await this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer'))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; } @Delete(':id/contributors/:userId') - removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { - if (!this.journey.removeContributor(Number(id), user.id, Number(userId))) { + async removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { + if (!(await this.journey.removeContributor(Number(id), user.id, Number(userId)))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; @@ -763,8 +763,8 @@ export class JourneyController { // ── Suggestions ───────────────────────────────────────────────────────── @Post(':id/suggestions/restore') @HttpCode(200) - restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) { - const result = this.journey.restoreDismissedSuggestions(Number(id), user.id); + async restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) { + const result = await this.journey.restoreDismissedSuggestions(Number(id), user.id); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -773,8 +773,8 @@ export class JourneyController { // ── User Preferences ──────────────────────────────────────────────────── @Patch(':id/preferences') - preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) { - const result = this.journey.updateJourneyPreferences(Number(id), user.id, body); + async preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) { + const result = await this.journey.updateJourneyPreferences(Number(id), user.id, body); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -783,12 +783,12 @@ export class JourneyController { // ── Share Link ────────────────────────────────────────────────────────── @Get(':id/share-link') - getShareLink(@CurrentUser() user: User, @Param('id') id: string) { + async getShareLink(@CurrentUser() user: User, @Param('id') id: string) { // Reading the token is owner-only. It refuses like its siblings rather than // answering with a null link: "not published" and "not yours to see" are // different answers, and a client told the first one offers a create button // that the POST below then refuses. - const result = this.journey.getJourneyShareLink(Number(id), user.id); + const result = await this.journey.getJourneyShareLink(Number(id), user.id); if (!result.allowed) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -797,8 +797,8 @@ export class JourneyController { @Post(':id/share-link') @HttpCode(200) // Express answers with res.json (200). - setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyShareLinkDto) { - const result = this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, { + async setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyShareLinkDto) { + const result = await this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, { share_timeline: body.share_timeline as boolean | undefined, share_gallery: body.share_gallery as boolean | undefined, share_map: body.share_map as boolean | undefined, @@ -811,8 +811,8 @@ export class JourneyController { } @Delete(':id/share-link') - deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) { - if (!this.journey.deleteJourneyShareLink(Number(id), user.id)) { + async deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) { + if (!(await this.journey.deleteJourneyShareLink(Number(id), user.id))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 4aec07bc4..8d00a99cb 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -123,8 +123,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - listJourneys(_args: unknown, ctx: McpContext) { - return ok({ journeys: this.journey.listJourneys(ctx.userId) }); + async listJourneys(_args: unknown, ctx: McpContext) { + return ok({ journeys: await this.journey.listJourneys(ctx.userId) }); } @Tool({ @@ -135,8 +135,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - getJourney({ journeyId }: { journeyId: number }, ctx: McpContext) { - const journey = this.journey.getJourneyFull(journeyId, ctx.userId); + async getJourney({ journeyId }: { journeyId: number }, ctx: McpContext) { + const journey = await this.journey.getJourneyFull(journeyId, ctx.userId); if (!journey) return notFound('Journey not found or access denied.'); return ok({ journey }); } @@ -152,8 +152,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - getJourneyStats({ journeyId, include_route }: { journeyId: number; include_route?: boolean }, ctx: McpContext) { - const stats = this.journey.journeyStats(journeyId, ctx.userId); + async getJourneyStats({ journeyId, include_route }: { journeyId: number; include_route?: boolean }, ctx: McpContext) { + const stats = await this.journey.journeyStats(journeyId, ctx.userId); if (!stats) return notFound('Journey not found or access denied.'); // Same payload as GET /api/journeys/:id/stats. The route is left out unless // it was asked for: 400 coordinate pairs answer a different question than @@ -171,9 +171,9 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) { - if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); - return ok({ entries: this.journey.listEntries(journeyId, ctx.userId) }); + async listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) { + if (!(await this.journey.canAccessJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.'); + return ok({ entries: await this.journey.listEntries(journeyId, ctx.userId) }); } @Tool({ @@ -184,8 +184,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - listJourneyContributors({ journeyId }: { journeyId: number }, ctx: McpContext) { - const journey = this.journey.getJourneyFull(journeyId, ctx.userId); + async listJourneyContributors({ journeyId }: { journeyId: number }, ctx: McpContext) { + const journey = await this.journey.getJourneyFull(journeyId, ctx.userId); if (!journey) return notFound('Journey not found or access denied.'); return ok({ contributors: (journey as { contributors?: JourneyContributor[] }).contributors ?? [] }); } @@ -198,8 +198,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - getJourneySuggestions(_args: unknown, ctx: McpContext) { - return ok({ trips: this.journey.getSuggestions(ctx.userId) }); + async getJourneySuggestions(_args: unknown, ctx: McpContext) { + return ok({ trips: await this.journey.getSuggestions(ctx.userId) }); } @Tool({ @@ -210,8 +210,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, }) - listJourneyAvailableTrips(_args: unknown, ctx: McpContext) { - return ok({ trips: this.journey.listUserTrips(ctx.userId) }); + async listJourneyAvailableTrips(_args: unknown, ctx: McpContext) { + return ok({ trips: await this.journey.listUserTrips(ctx.userId) }); } // ── Write ─────────────────────────────────────────────────────────────── @@ -233,10 +233,10 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const journey = this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids }); + const journey = await this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids }); // Return the fully-hydrated journey (entries/contributors/trips/stats/my_role), // matching get_journey, rather than the bare row. - return ok({ journey: this.journey.getJourneyFull(journey.id, ctx.userId) ?? journey }); + return ok({ journey: (await this.journey.getJourneyFull(journey.id, ctx.userId)) ?? journey }); } @Tool({ @@ -263,7 +263,7 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const journey = this.journey.updateJourney(journeyId, ctx.userId, data); + const journey = await this.journey.updateJourney(journeyId, ctx.userId, data); if (!journey) return notFound('Journey not found or access denied.'); return ok({ journey }); } @@ -280,7 +280,7 @@ export class JourneyMcp { }) async restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId); + const result = await this.journey.restoreDismissedSuggestions(journeyId, ctx.userId); if (!result) return notFound('Journey not found or access denied.'); return ok(result); } @@ -295,7 +295,7 @@ export class JourneyMcp { }) async deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.deleteJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); + if (!(await this.journey.deleteJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -309,8 +309,8 @@ export class JourneyMcp { }) async addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); - return ok({ success: this.journey.addTripToJourney(journeyId, tripId, ctx.userId) }); + if (!(await this.journey.canAccessJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.'); + return ok({ success: await this.journey.addTripToJourney(journeyId, tripId, ctx.userId) }); } @Tool({ @@ -323,7 +323,7 @@ export class JourneyMcp { }) async removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const success = this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId); + const success = await this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId); if (!success) return notFound('Journey not found or access denied.'); return ok({ success }); } @@ -362,10 +362,10 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const entry = this.journey.createEntry(journeyId, ctx.userId, data); + const entry = await this.journey.createEntry(journeyId, ctx.userId, data); if (!entry) return notFound('Journey not found or access denied.'); // Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name). - const enriched = this.journey.listEntries(journeyId, ctx.userId)?.find(e => e.id === entry.id) ?? entry; + const enriched = (await this.journey.listEntries(journeyId, ctx.userId))?.find(e => e.id === entry.id) ?? entry; return ok({ entry: enriched }); } @@ -407,10 +407,10 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const entry = this.journey.updateEntry(entryId, ctx.userId, data, undefined); + const entry = await this.journey.updateEntry(entryId, ctx.userId, data, undefined); if (!entry) return notFound('Entry not found or access denied.'); // Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry. - const enriched = this.journey.listEntries(entry.journey_id, ctx.userId)?.find(e => e.id === entry.id) ?? entry; + const enriched = (await this.journey.listEntries(entry.journey_id, ctx.userId))?.find(e => e.id === entry.id) ?? entry; return ok({ entry: enriched }); } @@ -424,7 +424,7 @@ export class JourneyMcp { }) async deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.deleteEntry(entryId, ctx.userId, undefined)) return notFound('Entry not found or access denied.'); + if (!(await this.journey.deleteEntry(entryId, ctx.userId, undefined))) return notFound('Entry not found or access denied.'); return ok({ success: true }); } @@ -441,7 +441,7 @@ export class JourneyMcp { }) async reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const success = this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined); + const success = await this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined); if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.'); return ok({ success: true }); } @@ -463,7 +463,7 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.addContributor(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.'); + if (!(await this.journey.addContributor(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -484,7 +484,7 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.'); + if (!(await this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -501,7 +501,7 @@ export class JourneyMcp { }) async removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.journey.removeContributor(journeyId, ctx.userId, targetUserId)) return notFound('Journey not found or access denied.'); + if (!(await this.journey.removeContributor(journeyId, ctx.userId, targetUserId))) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -521,7 +521,7 @@ export class JourneyMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const result = this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons }); + const result = await this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons }); if (!result) return notFound('Journey not found or access denied.'); // Return the service result ({ hide_skeletons }), matching the REST route. return ok(result); @@ -556,8 +556,8 @@ export class JourneyMcp { // access gets an empty 200. Checking up front instead turns that silence // into a sentence, and is what makes journeyId worth asking for: the entry // has to be shown to belong to it before anything is written. - if (!this.journey.canEdit(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); - if (entryId !== undefined && !this.journey.listEntries(journeyId, ctx.userId)?.some(e => e.id === entryId)) { + if (!(await this.journey.canEdit(journeyId, ctx.userId))) return notFound('Journey not found or access denied.'); + if (entryId !== undefined && !(await this.journey.listEntries(journeyId, ctx.userId))?.some(e => e.id === entryId)) { return notFound('Entry not found in this journey.'); } @@ -593,10 +593,10 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, }) - getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { + async getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { // Same read the REST route uses, so the owner check cannot drift apart // between the two surfaces: handing out the token is handing out the journey. - const result = this.share.readJourneyShareLink(journeyId, ctx.userId); + const result = await this.share.readJourneyShareLink(journeyId, ctx.userId); if (!result.allowed) return notFound('Journey not found or access denied.'); return ok({ shareLink: result.link }); } @@ -617,7 +617,7 @@ export class JourneyMcp { }) async createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions); + const shareLink = await this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions); if (!shareLink) return notFound('Journey not found or access denied.'); return ok({ shareLink }); } @@ -632,7 +632,7 @@ export class JourneyMcp { }) async deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.share.deleteJourneyShareLink(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); + if (!(await this.share.deleteJourneyShareLink(journeyId, ctx.userId))) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -651,7 +651,7 @@ export class JourneyMcp { access: { group: 'journey', mode: 'read' }, }) async journeysResource(uri: URL, ctx: McpContext) { - return jsonContent(uri.href, this.journey.listJourneys(ctx.userId)); + return jsonContent(uri.href, await this.journey.listJourneys(ctx.userId)); } @ResourceTemplate({ @@ -665,7 +665,7 @@ export class JourneyMcp { async journeyDetailResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) { const id = parseId(journeyId); if (id === null) return accessDenied(uri.href); - const journey = this.journey.getJourneyFull(id, ctx.userId); + const journey = await this.journey.getJourneyFull(id, ctx.userId); if (!journey) return accessDenied(uri.href); return jsonContent(uri.href, journey); } @@ -681,8 +681,8 @@ export class JourneyMcp { async journeyEntriesResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) { const id = parseId(journeyId); if (id === null) return accessDenied(uri.href); - if (!this.journey.canAccessJourney(id, ctx.userId)) return accessDenied(uri.href); - return jsonContent(uri.href, this.journey.listEntries(id, ctx.userId)); + if (!(await this.journey.canAccessJourney(id, ctx.userId))) return accessDenied(uri.href); + return jsonContent(uri.href, await this.journey.listEntries(id, ctx.userId)); } @ResourceTemplate({ @@ -696,7 +696,7 @@ export class JourneyMcp { async journeyContributorsResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) { const id = parseId(journeyId); if (id === null) return accessDenied(uri.href); - const journey = this.journey.getJourneyFull(id, ctx.userId); + const journey = await this.journey.getJourneyFull(id, ctx.userId); if (!journey) return accessDenied(uri.href); return jsonContent(uri.href, (journey as { contributors?: JourneyContributor[] }).contributors ?? []); } diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 6ffe0d81e..63f94449e 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -87,7 +87,7 @@ export class JourneyService { deleteJourneyShareLink(id: number, userId: number) { return this.share.deleteJourneyShareLink(id, userId); } // Immich mirror (only when the user opted in via integration settings) - immichAutoUploadEnabled(userId: number): boolean { + async immichAutoUploadEnabled(userId: number): Promise { const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; return !!prefs?.immich_auto_upload; } diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index 5c58a3da2..1de398456 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -45,7 +45,7 @@ export class OidcController { res.status(403).json({ error: 'SSO login is disabled.' }); return; } - const config = this.oidc.getOidcConfig(); + const config = await this.oidc.getOidcConfig(); if (!config) { res.status(400).json({ error: 'OIDC not configured' }); return; @@ -120,7 +120,7 @@ export class OidcController { const pending = this.oidc.consumeState(state); if (!pending) return f('/login?oidc_error=invalid_state'); - const config = this.oidc.getOidcConfig(); + const config = await this.oidc.getOidcConfig(); if (!config) return f('/login?oidc_error=not_configured'); if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) { return f('/login?oidc_error=issuer_not_https'); @@ -181,11 +181,11 @@ export class OidcController { }); } - this.oidc.touchLastLogin(result.user.id); + await this.oidc.touchLastLogin(result.user.id); // Pass the flag through untouched: `undefined` must reach the token as // "absent", not `false`, or the sliding renewal would later downgrade the // default persistent cookie to a browser-session one (remember-me, #1927). - const jwtToken = this.oidc.generateToken(result.user, pending.remember); + const jwtToken = await this.oidc.generateToken(result.user, pending.remember); const { code: authCode, binding } = this.oidc.createAuthCode(jwtToken, pending.remember); // Bind the code to THIS browser, the way the state cookie binds the callback. // The code rides home in a URL, so it is readable from history, from a diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index e3e647f5f..7f6b03e03 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -306,7 +306,7 @@ export class OidcService implements OnModuleDestroy { // OIDC configuration (env + DB) // ------------------------------------------------------------------------- - getOidcConfig(): OidcConfig | null { + async getOidcConfig(): Promise { const get = (key: string) => (this.db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; @@ -445,7 +445,7 @@ export class OidcService implements OnModuleDestroy { return base + path; } - generateToken(user: { id: number }, remember?: boolean): string { + async generateToken(user: { id: number }, remember?: boolean): Promise { // Embed the current password_version so an OIDC-issued session is invalidated // by a password change/reset exactly like a password-login session (the auth // middleware compares this `pv` against users.password_version). @@ -768,7 +768,7 @@ export class OidcService implements OnModuleDestroy { // Update last_login timestamp // ------------------------------------------------------------------------- - touchLastLogin(userId: number): void { + async touchLastLogin(userId: number): Promise { this.db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId); } @@ -778,7 +778,7 @@ export class OidcService implements OnModuleDestroy { // settings write: removing the SSO config while password login is off would lock // every user out of the instance. - getOidcSettings() { + async getOidcSettings() { const get = (key: string) => this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || ''; const secret = decrypt_api_key(get('oidc_client_secret')); @@ -811,7 +811,7 @@ export class OidcService implements OnModuleDestroy { this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', key, val || ''); // All five writes are one SSO config — a partial apply would leave the // instance with an issuer but no client id (or vice versa). - this.db.transaction(() => { + await this.uow.transactional(async () => { set('oidc_issuer', data.issuer ?? ''); set('oidc_client_id', data.client_id ?? ''); if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? ''); diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index 2e306020c..7172e2aa2 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -318,13 +318,13 @@ export class PlaceEnrichmentService { * would mean backfilling a row for every existing install just to keep them * working, and there is nothing here that warrants a migration. */ - enrichDisabled(): boolean { + async enrichDisabled(): Promise { const row = this.database.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'places_enrich_enabled'); return row?.value === 'false'; } async enrich(userId: number, req: MapsPlaceEnrichmentRequest): Promise { - if (this.enrichDisabled()) return { photos: [], description: null, facts: [], disabled: true }; + if (await this.enrichDisabled()) return { photos: [], description: null, facts: [], disabled: true }; const placeId = req.placeId?.trim() || `coords:${req.lat}:${req.lng}`; const lang = req.lang; @@ -378,7 +378,7 @@ export class PlaceEnrichmentService { // A summary or a link the lookup here fetched itself is the map's and keeps. const fromCaller = req.details != null && (description?.source === 'osm' || ownFacts.some((fact) => fact.url != null)); - if (!fromCaller) this.writeCache(placeId, lang, result); + if (!fromCaller) await this.writeCache(placeId, lang, result); return result; } @@ -842,7 +842,7 @@ export class PlaceEnrichmentService { } } - private writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): void { + private async writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): Promise { try { this.database.run( 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)', diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 76e87164d..e6f9b9168 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -153,7 +153,7 @@ export class PlacesMcp { }); this.guards.safeBroadcast(tripId, 'place:created', { place: result.place }); this.guards.safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment }); - try { this.journey.reconcileTripSkeletons(tripId); } catch { /* non-fatal */ } + try { await this.journey.reconcileTripSkeletons(tripId); } catch { /* non-fatal */ } return ok(result); } catch { return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true }; @@ -257,7 +257,7 @@ export class PlacesMcp { if (!this.places.get(String(tripId), String(placeId))) { return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; } - try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone + try { await this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone // The link is gone once the place is, so read it first (#1298). const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]); const { deleted, cancelled } = await this.places.remove(String(tripId), String(placeId)); @@ -433,7 +433,7 @@ export class PlacesMcp { // detach and left the entries as orphans. const scoped = this.places.scopedIds(String(tripId), placeIds); for (const id of scoped) { - try { this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ } + try { await this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ } } // The link is gone once the places are, so read it first (#1298). const expenseIds = this.places.linkedExpenseIds(tripId, scoped); diff --git a/server/src/nest/places/places.rpc.ts b/server/src/nest/places/places.rpc.ts index 5c24e2c46..d6ef6ff36 100644 --- a/server/src/nest/places/places.rpc.ts +++ b/server/src/nest/places/places.rpc.ts @@ -119,10 +119,16 @@ export class PlacesRpc { return { deleted: true }; } - /** Journey mirroring never fails a write that already succeeded. */ - private mirrorJourneys(run: () => void): void { + /** + * Journey mirroring never fails a write that already succeeded. + * + * R1.5: `run` wraps a now-async `this.journey.onPlace*` call; its rejection is + * swallowed here (void + catch) rather than left unhandled — same "non-fatal" + * contract the synchronous try/catch gave it before the sweep. + */ + private mirrorJourneys(run: () => Promise): void { try { - run(); + void run().catch(() => { /* non-fatal */ }); } catch { /* non-fatal */ } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 52aacf507..0f69676ad 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -1594,7 +1594,10 @@ export class PlacesService { } // Journey hooks — non-fatal, mirroring the route's try/catch wrappers. - onCreated(tripId: string, placeId: number): void { try { this.journey.onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } } - onUpdated(placeId: number): void { try { this.journey.onPlaceUpdated(placeId); } catch { /* non-fatal */ } } - onDeleted(placeId: number): void { try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } } + // R1.5: called fire-and-forget from several sync call sites; onPlace* are now async, + // so the rejection is swallowed here (void + catch) rather than left unhandled — + // same "non-fatal" contract the try/catch gave it before the sweep. + onCreated(tripId: string, placeId: number): void { void this.journey.onPlaceCreated(Number(tripId), placeId).catch(() => { /* non-fatal */ }); } + onUpdated(placeId: number): void { void this.journey.onPlaceUpdated(placeId).catch(() => { /* non-fatal */ }); } + onDeleted(placeId: number): void { void this.journey.onPlaceDeleted(placeId).catch(() => { /* non-fatal */ }); } } diff --git a/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts index 0ff138ed9..d682b8840 100644 --- a/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts @@ -85,7 +85,7 @@ export class JournalEntryRowsController { // The entry's journey must be one the caller can access — same gate as a read. const row = this.dbs.connection.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number } | undefined; - if (!row || !this.journey.canAccessJourney(row.journey_id, userId)) return { providers: [] }; + if (!row || !(await this.journey.canAccessJourney(row.journey_id, userId))) return { providers: [] }; const ids = this.hooks.providersOf('journalEntryProvider'); const results = await Promise.all( diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index 4c54ecb3f..a7defb6de 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -192,7 +192,7 @@ export class RealtimeGateway if (!user || !message?.journeyId) return undefined; const journeyId = Number(message.journeyId); - if (!Number.isFinite(journeyId) || !this.journeys.canAccessJourney(journeyId, user.id)) { + if (!Number.isFinite(journeyId) || !(await this.journeys.canAccessJourney(journeyId, user.id))) { return { type: 'error', message: 'Access denied' }; } diff --git a/server/src/nest/trip-members/trip-members.controller.ts b/server/src/nest/trip-members/trip-members.controller.ts index 5298d5f84..281c1bb81 100644 --- a/server/src/nest/trip-members/trip-members.controller.ts +++ b/server/src/nest/trip-members/trip-members.controller.ts @@ -47,12 +47,12 @@ export class TripMembersController { constructor(private readonly roster: TripMembersService, private readonly audit: AuditService) {} @Get(':id/members') - members(@CurrentUser() user: User, @Param('id') id: string) { - const access = this.roster.canAccessTrip(id, user.id); + async members(@CurrentUser() user: User, @Param('id') id: string) { + const access = await this.roster.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } - const { owner, members } = this.roster.listMembers(id, access.user_id); + const { owner, members } = await this.roster.listMembers(id, access.user_id); return { owner, members, current_user_id: user.id }; } @@ -60,7 +60,7 @@ export class TripMembersController { @HttpCode(201) async addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) { const { identifier } = body; - const access = this.roster.canAccessTrip(id, user.id); + const access = await this.roster.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } @@ -68,7 +68,7 @@ export class TripMembersController { throw new HttpException({ error: 'No permission to manage members' }, 403); } try { - const result = this.roster.addMember(id, identifier, access.user_id, user.id); + const result = await this.roster.addMember(id, identifier, access.user_id, user.id); this.roster.notifyInvite(id, user, result.targetUserId, result.tripTitle, result.member.email); return { member: result.member }; } catch (e: unknown) { @@ -80,7 +80,7 @@ export class TripMembersController { @Delete(':id/members/:userId') async removeMember(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { - const access = this.roster.canAccessTrip(id, user.id); + const access = await this.roster.canAccessTrip(id, user.id); if (!access) { throw new HttpException({ error: 'Trip not found' }, 404); } @@ -88,7 +88,7 @@ export class TripMembersController { if (targetId !== user.id && !(await this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id))) { throw new HttpException({ error: 'No permission to remove members' }, 403); } - this.roster.removeMember(id, targetId); + await this.roster.removeMember(id, targetId); return { success: true }; } @@ -104,11 +104,11 @@ export class TripMembersController { ) { const { newOwnerId } = body; try { - const result = this.roster.transferOwnership(id, newOwnerId, user.id); + const result = await this.roster.transferOwnership(id, newOwnerId, user.id); await this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } }); // Nudge everyone viewing the trip to re-read it so the new ownership and the // recomputed permissions take effect live. - const updatedTrip = this.roster.getTripForViewer(id, user.id); + const updatedTrip = await this.roster.getTripForViewer(id, user.id); this.roster.broadcast(id, 'trip:updated', { trip: updatedTrip }, socketId); return { success: true }; } catch (e: unknown) { @@ -122,12 +122,12 @@ export class TripMembersController { @HttpCode(201) @UseGuards(TripOwnerGuard) @RequireTripOwner('Only the owner can manage guests', { param: 'id' }) - createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) { + async createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) { // Whitespace-only names still 400 with the legacy body — the service throws // ValidationError('Guest name is required') after trimming. try { // No notifyInvite: a guest has no inbox. - return this.roster.createGuest(id, body.name, user.id); + return await this.roster.createGuest(id, body.name, user.id); } catch (e: unknown) { if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); throw e; @@ -137,9 +137,9 @@ export class TripMembersController { @Put(':id/guests/:userId') @UseGuards(TripOwnerGuard) @RequireTripOwner('Only the owner can manage guests', { param: 'id' }) - renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) { + async renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) { try { - if (!this.roster.renameGuest(id, Number.parseInt(userId), body.name)) { + if (!(await this.roster.renameGuest(id, Number.parseInt(userId), body.name))) { throw new HttpException({ error: 'Guest not found' }, 404); } return { success: true }; diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index 17d3555a9..f22c968b5 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -74,7 +74,7 @@ export class TripMembersService { return this.dbs.connection; } - canAccessTrip(tripId: string | number, userId: number) { + async canAccessTrip(tripId: string | number, userId: number) { return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; } @@ -88,7 +88,7 @@ export class TripMembersService { /** The trip in list shape, for the re-read a handover broadcasts. Same query the * trip routes use, imported rather than copied so the two cannot drift. */ - getTripForViewer(tripId: string | number, userId: number) { + async getTripForViewer(tripId: string | number, userId: number) { return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); } @@ -109,7 +109,7 @@ export class TripMembersService { // ── Members ─────────────────────────────────────────────────────────────── - listMembers(tripId: string | number, tripOwnerId: number) { + async listMembers(tripId: string | number, tripOwnerId: number) { // u.is_guest rides along (#1362) so guests stay assignable everywhere a member is, // while the UI can badge them and suppress owner-only actions. The owner is never a guest. const members = this.db.prepare(` @@ -134,7 +134,7 @@ export class TripMembersService { }; } - addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult { + async addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): Promise { if (!identifier) throw new ValidationError('Email or username required'); // Guests (#1362) are not invitable accounts — exclude them so a trip-scoped guest @@ -162,7 +162,7 @@ export class TripMembersService { }; } - removeMember(tripId: string | number, targetUserId: number): void { + async removeMember(tripId: string | number, targetUserId: number): Promise { this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId); } @@ -172,11 +172,11 @@ export class TripMembersService { * becomes a regular member, so nobody loses access. Runs in a transaction so the * owner pointer and the membership rows never diverge. */ - transferOwnership( + async transferOwnership( tripId: string | number, newOwnerId: number, currentOwnerId: number, - ): TransferOwnershipResult { + ): Promise { const trip = this.db.prepare('SELECT id, title, user_id FROM trips WHERE id = ?').get(tripId) as { id: number; title: string; user_id: number } | undefined; if (!trip) throw new NotFoundError('Trip not found'); if (trip.user_id !== currentOwnerId) throw new ValidationError('Only the owner can transfer ownership'); @@ -192,14 +192,13 @@ export class TripMembersService { const fromEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(currentOwnerId) as { email: string } | undefined)?.email || ''; - const run = this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.prepare('UPDATE trips SET user_id = ? WHERE id = ?').run(newOwnerId, tripId); // The new owner is no longer a plain member… this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, newOwnerId); // …and the former owner keeps access as a member. this.db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, currentOwnerId, newOwnerId); }); - run(); return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email }; } @@ -208,7 +207,7 @@ export class TripMembersService { /** username is UNIQUE across all users — keep the typed name but disambiguate guests * that happen to share it (e.g. two "Anna"s) with a numeric suffix. */ - createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } { + async createGuest(tripId: string | number, name: string, invitedByUserId: number): Promise<{ member: GuestMember }> { const display = (name || '').trim(); if (!display) throw new ValidationError('Guest name is required'); if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); @@ -219,31 +218,30 @@ export class TripMembersService { const email = `guest-${randomUUID()}@guests.invalid`; const username = `guest-${randomUUID()}`; - const create = this.db.transaction(() => { + const guestId = await this.uow.transactional(async () => { const res = this.db.prepare( "INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)" ).run(username, email, display); - const guestId = Number(res.lastInsertRowid); - this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId); - return guestId; + const newGuestId = Number(res.lastInsertRowid); + this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, newGuestId, invitedByUserId); + return newGuestId; }); - const guestId = create(); return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } }; } /** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */ - private guestOfTrip(tripId: string | number, guestUserId: number): boolean { + private async guestOfTrip(tripId: string | number, guestUserId: number): Promise { return !!this.db.prepare( 'SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1' ).get(guestUserId, tripId); } - renameGuest(tripId: string | number, guestUserId: number, name: string): boolean { + async renameGuest(tripId: string | number, guestUserId: number, name: string): Promise { const display = (name || '').trim(); if (!display) throw new ValidationError('Guest name is required'); if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); - if (!this.guestOfTrip(tripId, guestUserId)) return false; + if (!(await this.guestOfTrip(tripId, guestUserId))) return false; // Rename only the display name — no global-uniqueness dedup, so a rename to a name // another trip's guest already uses no longer produces "Name 2" (#1446). @@ -252,7 +250,7 @@ export class TripMembersService { } async deleteGuest(tripId: string | number, guestUserId: number): Promise { - if (!this.guestOfTrip(tripId, guestUserId)) return false; + if (!(await this.guestOfTrip(tripId, guestUserId))) return false; // A guest is still a user id a plugin may hold data for, so erase that too — the // host-side per-user tables + a durable own-db erasure per granted plugin — exactly // like a full account deletion (otherwise a deleted guest's plugin data lingers). 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 fd5cba41b..98c03f165 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 @@ -55,7 +55,7 @@ export class TripReadModelService { const ownerRow = this.getOwner(tripId); if (!ownerRow) return null; - const { owner, members } = this.members.listMembers(tripId, ownerRow.user_id); + const { owner, members } = await this.members.listMembers(tripId, ownerRow.user_id); const { days: rawDays } = await this.days.list(tripId); const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items })); @@ -99,7 +99,7 @@ export class TripReadModelService { /** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */ async bundle(tripId: string, trip: { user_id: number }, viewerId: number) { const { days } = await this.days.list(tripId); - const { owner, members } = this.members.listMembers(tripId, trip.user_id); + const { owner, members } = await this.members.listMembers(tripId, trip.user_id); return { trip, days, diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 63c7e1eca..1ae87b189 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -335,7 +335,7 @@ export class TripsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow) return noAccess(); - const { owner, members } = this.members.listMembers(tripId, ownerRow.user_id); + const { owner, members } = await this.members.listMembers(tripId, ownerRow.user_id); return ok({ owner, members }); } @@ -359,7 +359,7 @@ export class TripsMcp { // that setting (and the admin bypass inside checkPermission) where this did not. if (!(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied(); try { - const result = this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId); + const result = await this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId); this.guards.safeBroadcast(tripId, 'member:added', { member: result.member }); return ok({ member: result.member }); } catch (err) { @@ -385,7 +385,7 @@ export class TripsMcp { // requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has. if (memberId !== ctx.userId && !(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied(); - this.members.removeMember(tripId, memberId); + await this.members.removeMember(tripId, memberId); this.guards.safeBroadcast(tripId, 'member:removed', { userId: memberId }); return ok({ success: true }); } @@ -408,7 +408,7 @@ export class TripsMcp { // owner calling this would get a success that changed nothing. Say so instead. if (ownerRow.user_id === ctx.userId) return errorResult('You own this trip, so you cannot leave it. Hand it to another member first, or delete it.'); - this.members.removeMember(tripId, ctx.userId); + await this.members.removeMember(tripId, ctx.userId); this.guards.safeBroadcast(tripId, 'member:removed', { userId: ctx.userId }); return ok({ success: true }); } @@ -441,7 +441,7 @@ export class TripsMcp { return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; try { // No notifyInvite: a guest has no inbox. - const { member } = this.members.createGuest(tripId, name, ctx.userId); + const { member } = await this.members.createGuest(tripId, name, ctx.userId); this.guards.safeBroadcast(tripId, 'member:added', { member }); return ok({ member }); } catch (err) { @@ -468,7 +468,7 @@ export class TripsMcp { if (!ownerRow || ownerRow.user_id !== ctx.userId) return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; try { - if (!this.members.renameGuest(tripId, guestId, name)) + if (!(await this.members.renameGuest(tripId, guestId, name))) return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true }; } catch (err) { const msg = err instanceof ValidationError ? err.message : 'Failed to rename guest.'; @@ -580,7 +580,7 @@ export class TripsMcp { if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href); const ownerRow = this.trips.getOwner(id); if (!ownerRow) return accessDenied(uri.href); - const { owner, members } = this.members.listMembers(id, ownerRow.user_id); + const { owner, members } = await this.members.listMembers(id, ownerRow.user_id); return jsonContent(uri.href, { owner, members }); } diff --git a/server/src/nest/trips/trips.rpc.ts b/server/src/nest/trips/trips.rpc.ts index b53b20431..3c1d5e8f8 100644 --- a/server/src/nest/trips/trips.rpc.ts +++ b/server/src/nest/trips/trips.rpc.ts @@ -187,7 +187,7 @@ export class TripsRpc { // Ownership transfer is a separate, deliberate action. const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner'); - this.roster.removeMember(tripId, targetUserId); + await this.roster.removeMember(tripId, targetUserId); return { removed: true }; } } diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index fdda2c713..8fac42ff9 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -63,7 +63,7 @@ vi.mock('../../src/db/database', () => ({ const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../src/websocket', () => ({ broadcast })); -const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() })); +const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined) })); import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; diff --git a/server/tests/e2e/oidc.e2e.test.ts b/server/tests/e2e/oidc.e2e.test.ts index ef1d66af7..c74942951 100644 --- a/server/tests/e2e/oidc.e2e.test.ts +++ b/server/tests/e2e/oidc.e2e.test.ts @@ -74,7 +74,7 @@ describe('OIDC e2e (real cookie service)', () => { server = app.getHttpServer(); vi.spyOn(app.get(AuthService), 'resolveAuthToggles').mockImplementation(() => toggles as never); const oidc = app.get(OidcService); - vi.spyOn(oidc, 'getOidcConfig').mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', displayName: 'SSO', discoveryUrl: null }); + vi.spyOn(oidc, 'getOidcConfig').mockResolvedValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', displayName: 'SSO', discoveryUrl: null }); vi.spyOn(oidc, 'discover').mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' } as never); vi.spyOn(oidc, 'createState').mockReturnValue({ state: 'st', codeChallenge: 'cc' }); consumeAuthCode = vi.spyOn(oidc, 'consumeAuthCode').mockReturnValue({ token: 'jwt.value' }); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 07631fdbb..f6ea954d1 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -96,7 +96,11 @@ describe('Places e2e (real auth guard + temp SQLite)', () => { async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, PlacesModule] }) .overrideProvider(JourneyDomainService) - .useValue({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }) + .useValue({ + onPlaceCreated: vi.fn().mockResolvedValue(undefined), + onPlaceUpdated: vi.fn().mockResolvedValue(undefined), + onPlaceDeleted: vi.fn().mockResolvedValue(undefined), + }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index 04e7e7f92..1b15d96a1 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -35,7 +35,7 @@ export async function accommodationsOver(dbs: DatabaseService): Promise { // and its on-disk set only work if all three readers see the same maps. const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage); const mapsService = new MapsService(dbService, placePhotoCache); - const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService)); + const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService), await createTestUnitOfWork(dbService.connection)); // The last three were previously omitted, which left them `undefined` at // runtime — silently fine while nothing called them, a TypeError the moment // the journey skeleton hooks landed on the place write paths. tsconfig.tests.json diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index eea3626ab..fc88b3646 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -101,7 +101,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection)); const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage); const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage); - const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)); + const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)); const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage, await createTestUnitOfWork(dbs.connection)); const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection)); const dayNotes = new DayNotesService(dbs, permissions, realtime); diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index be5199014..2017118ff 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -69,7 +69,7 @@ beforeAll(async () => { new QueryHelpersService(dbs), // Real collaborator rather than a stub: reconcile() runs after every mutation // and needs the same connection to see the rows these cases write. - new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)), ); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 2ac3bb2a3..21cc8db72 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -141,7 +141,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => { it('BUDGET-SVC-DB-010: re-derives the persons divisor when a guest in the split is deleted', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member); + const guests = await Promise.all(['G1', 'G2', 'G3'].map(async n => (await createGuest(trip.id, n, owner.id)).member)); const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 400, member_ids: [owner.id, ...guests.map(g => g.id)], @@ -160,7 +160,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => { it('BUDGET-SVC-DB-011: leaves a manually entered persons count alone', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const guest = createGuest(trip.id, 'G1', owner.id).member; + const guest = (await createGuest(trip.id, 'G1', owner.id)).member; // No member rows — `persons` is just a number someone typed. const item = await budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 }); @@ -172,7 +172,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => { it('BUDGET-SVC-DB-012: drops the last member to a null divisor rather than zero', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const guest = createGuest(trip.id, 'G1', owner.id).member; + const guest = (await createGuest(trip.id, 'G1', owner.id)).member; const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] }); await deleteGuest(trip.id, guest.id); @@ -184,7 +184,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => { it('BUDGET-SVC-DB-013: saves a split from a stale client instead of failing on the users FK', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const guest = createGuest(trip.id, 'G1', owner.id).member; + const guest = (await createGuest(trip.id, 'G1', owner.id)).member; const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] }); await deleteGuest(trip.id, guest.id); @@ -199,7 +199,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => { it('BUDGET-SVC-DB-014: ignores a deleted member arriving through updateMembers', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const guest = createGuest(trip.id, 'G1', owner.id).member; + const guest = (await createGuest(trip.id, 'G1', owner.id)).member; const item = await budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] }); await deleteGuest(trip.id, guest.id); diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index 9fe725dec..d30980fdc 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -45,10 +45,11 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { JourneyBookService } from '../../../src/nest/journey/journey-book.service'; import { db as dbConn } from '../../../src/db/database'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; const dbs = new DatabaseService(dbConn); -const domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)); -const books = new JourneyBookService(dbs, domain); +let domain: JourneyDomainService; +let books: JourneyBookService; /** A minimal document that survives normalizeBookDocument unchanged. */ function doc(title = 'one') { @@ -60,9 +61,12 @@ function doc(title = 'one') { }; } -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + const uow = await createTestUnitOfWork(testDb); + domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow); + books = new JourneyBookService(dbs, domain); }); beforeEach(() => { @@ -76,15 +80,15 @@ afterAll(() => { // -- Access ------------------------------------------------------------------- describe('access', () => { - it('refuses a journey the user cannot see', () => { + it('refuses a journey the user cannot see', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(books.getBook(journey.id, stranger.id)).toBeNull(); - expect(books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull(); - expect(books.deleteBook(journey.id, stranger.id)).toBeNull(); - expect(books.canOpen(journey.id, stranger.id)).toBe(false); + expect(await books.getBook(journey.id, stranger.id)).toBeNull(); + expect(await books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull(); + expect(await books.deleteBook(journey.id, stranger.id)).toBeNull(); + expect(await books.canOpen(journey.id, stranger.id)).toBe(false); }); /* @@ -92,80 +96,80 @@ describe('access', () => { * over the same object is how two rules end up disagreeing about who may do * what. */ - it('lets a contributor edit, not only the owner', () => { + it('lets a contributor edit, not only the owner', async () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, helper.id, 'editor'); - const saved = books.saveBook(journey.id, helper.id, { title: 'B', document: doc() }); + const saved = await books.saveBook(journey.id, helper.id, { title: 'B', document: doc() }); expect(saved && 'record' in saved).toBe(true); - expect(books.getBook(journey.id, helper.id)).not.toBeNull(); + expect(await books.getBook(journey.id, helper.id)).not.toBeNull(); }); - it('lets a viewer read the book but not overwrite or delete it', () => { + it('lets a viewer read the book but not overwrite or delete it', async () => { const { user: owner } = createUser(testDb); const { user: guest } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, guest.id, 'viewer'); - books.saveBook(journey.id, owner.id, { title: 'Iceland', document: doc() }); + await books.saveBook(journey.id, owner.id, { title: 'Iceland', document: doc() }); - expect(books.canOpen(journey.id, guest.id)).toBe(true); - expect(books.getBook(journey.id, guest.id)).not.toBeNull(); - expect(books.saveBook(journey.id, guest.id, { title: 'mine now', document: doc('two') })).toBeNull(); - expect(books.deleteBook(journey.id, guest.id)).toBeNull(); - expect(books.getBook(journey.id, owner.id)!.title).toBe('Iceland'); + expect(await books.canOpen(journey.id, guest.id)).toBe(true); + expect(await books.getBook(journey.id, guest.id)).not.toBeNull(); + expect(await books.saveBook(journey.id, guest.id, { title: 'mine now', document: doc('two') })).toBeNull(); + expect(await books.deleteBook(journey.id, guest.id)).toBeNull(); + expect((await books.getBook(journey.id, owner.id))!.title).toBe('Iceland'); }); - it('says nothing about a journey that does not exist', () => { + it('says nothing about a journey that does not exist', async () => { const { user } = createUser(testDb); - expect(books.canOpen(999_999, user.id)).toBe(false); - expect(books.getBook(999_999, user.id)).toBeNull(); + expect(await books.canOpen(999_999, user.id)).toBe(false); + expect(await books.getBook(999_999, user.id)).toBeNull(); }); }); // -- Creating and reading ----------------------------------------------------- describe('creating and reading', () => { - it('is null for a journey with no book yet', () => { + it('is null for a journey with no book yet', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(books.getBook(journey.id, user.id)).toBeNull(); - expect(books.canOpen(journey.id, user.id)).toBe(true); + expect(await books.getBook(journey.id, user.id)).toBeNull(); + expect(await books.canOpen(journey.id, user.id)).toBe(true); }); - it('creates on first save, at version 1', () => { + it('creates on first save, at version 1', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const saved = books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() }); + const saved = await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() }); expect(saved && 'record' in saved && saved.record.version).toBe(1); expect(saved && 'record' in saved && saved.record.title).toBe('Iceland'); }); - it('reads the document back as it went in', () => { + it('reads the document back as it went in', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); - const read = books.getBook(journey.id, user.id)!; + const read = (await books.getBook(journey.id, user.id))!; expect(read.document.spreads).toHaveLength(1); expect(read.document.page.pageWidth).toBe(210); }); - it('records who saved it', () => { + it('records who saved it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); - expect(books.getBook(journey.id, user.id)!.updatedBy).toBe(user.id); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); + expect((await books.getBook(journey.id, user.id))!.updatedBy).toBe(user.id); }); - it('lists books without their documents', () => { + it('lists books without their documents', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() }); + await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() }); - const list = books.listBooks(journey.id, user.id)!; + const list = (await books.listBooks(journey.id, user.id))!; expect(list).toHaveLength(1); expect(list[0].title).toBe('Iceland'); expect('document' in list[0]).toBe(false); @@ -176,7 +180,7 @@ describe('creating and reading', () => { * not lock somebody out of their own book — normalizeBookDocument drops what * it cannot read rather than throwing. */ - it('opens a book whose stored JSON is broken, rather than throwing', () => { + it('opens a book whose stored JSON is broken, rather than throwing', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); testDb @@ -185,7 +189,7 @@ describe('creating and reading', () => { ) .run(journey.id); - const read = books.getBook(journey.id, user.id); + const read = await books.getBook(journey.id, user.id); expect(read).not.toBeNull(); expect(read!.document.spreads).toEqual([]); }); @@ -194,16 +198,16 @@ describe('creating and reading', () => { // -- Concurrency -------------------------------------------------------------- describe('concurrency', () => { - function seed() { + async function seed() { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') }); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') }); return { user, journey }; } - it('bumps the version on every save', () => { - const { user, journey } = seed(); - const second = books.saveBook(journey.id, user.id, { + it('bumps the version on every save', async () => { + const { user, journey } = await seed(); + const second = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1, @@ -212,11 +216,11 @@ describe('concurrency', () => { }); /* The one this column exists for. */ - it('refuses a save made against a version that has moved', () => { - const { user, journey } = seed(); - books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); + it('refuses a save made against a version that has moved', async () => { + const { user, journey } = await seed(); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); - const stale = books.saveBook(journey.id, user.id, { + const stale = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('three'), baseVersion: 1, @@ -224,11 +228,11 @@ describe('concurrency', () => { expect(stale && 'conflict' in stale).toBe(true); }); - it('answers a conflict with the current record, not only a refusal', () => { - const { user, journey } = seed(); - books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 }); + it('answers a conflict with the current record, not only a refusal', async () => { + const { user, journey } = await seed(); + await books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 }); - const stale = books.saveBook(journey.id, user.id, { + const stale = await books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1, @@ -238,72 +242,72 @@ describe('concurrency', () => { expect(stale && 'conflict' in stale && stale.conflict.document.title).toBe('two'); }); - it('leaves the stored document untouched when it refuses', () => { - const { user, journey } = seed(); - books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 }); - books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 }); + it('leaves the stored document untouched when it refuses', async () => { + const { user, journey } = await seed(); + await books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 }); + await books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 }); - expect(books.getBook(journey.id, user.id)!.document.title).toBe('two'); - expect(books.getBook(journey.id, user.id)!.version).toBe(2); + expect((await books.getBook(journey.id, user.id))!.document.title).toBe('two'); + expect((await books.getBook(journey.id, user.id))!.version).toBe(2); }); /* * "Open Studio and start editing" has to work for the second person to * arrive, who has a document but no version yet. */ - it('takes a save with no base version as an ordinary write', () => { - const { user, journey } = seed(); - const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') }); + it('takes a save with no base version as an ordinary write', async () => { + const { user, journey } = await seed(); + const saved = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') }); expect(saved && 'record' in saved && saved.record.version).toBe(2); }); - it('lets the loser save again once it has the new version', () => { - const { user, journey } = seed(); - books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); - const conflict = books.saveBook(journey.id, user.id, { + it('lets the loser save again once it has the new version', async () => { + const { user, journey } = await seed(); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); + const conflict = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('mine'), baseVersion: 1, }); expect(conflict && 'conflict' in conflict).toBe(true); - const retry = books.saveBook(journey.id, user.id, { + const retry = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('mine'), baseVersion: 2, }); expect(retry && 'record' in retry && retry.record.version).toBe(3); - expect(books.getBook(journey.id, user.id)!.document.title).toBe('mine'); + expect((await books.getBook(journey.id, user.id))!.document.title).toBe('mine'); }); - it('does not let a version from another journey unlock this one', () => { - const { user, journey } = seed(); + it('does not let a version from another journey unlock this one', async () => { + const { user, journey } = await seed(); const other = createJourney(testDb, user.id); - books.saveBook(other.id, user.id, { title: 'O', document: doc('other') }); + await books.saveBook(other.id, user.id, { title: 'O', document: doc('other') }); // Version 1 is current over there and stale here. - books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); - const stale = books.saveBook(journey.id, user.id, { + await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 }); + const stale = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('three'), baseVersion: 1, }); expect(stale && 'conflict' in stale).toBe(true); - expect(books.getBook(other.id, user.id)!.version).toBe(1); + expect((await books.getBook(other.id, user.id))!.version).toBe(1); }); }); // -- Broadcasting ------------------------------------------------------------- describe('broadcastSaved', () => { - it('sends the version, not the document, and excludes the saver', () => { + it('sends the version, not the document, and excludes the saver', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc() })!; + const saved = (await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }))!; const record = 'record' in saved ? saved.record : null; - const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(() => {}); - books.broadcastSaved(journey.id, user.id, record!, 'socket-7'); + const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(async () => {}); + await books.broadcastSaved(journey.id, user.id, record!, 'socket-7'); expect(spy).toHaveBeenCalledWith( journey.id, @@ -318,25 +322,25 @@ describe('broadcastSaved', () => { // -- Deleting ----------------------------------------------------------------- describe('deleting', () => { - it('removes the book and reports it', () => { + it('removes the book and reports it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); - expect(books.deleteBook(journey.id, user.id)).toBe(true); - expect(books.getBook(journey.id, user.id)).toBeNull(); + expect(await books.deleteBook(journey.id, user.id)).toBe(true); + expect(await books.getBook(journey.id, user.id)).toBeNull(); }); - it('reports false when there was nothing to delete', () => { + it('reports false when there was nothing to delete', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(books.deleteBook(journey.id, user.id)).toBe(false); + expect(await books.deleteBook(journey.id, user.id)).toBe(false); }); - it('goes with the journey it belongs to', () => { + it('goes with the journey it belongs to', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); + await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }); testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id); diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 3f1e94159..37301bbf5 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -58,13 +58,15 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { db as dbConn } from '../../../src/db/database'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; const dbs = new DatabaseService(dbConn); -const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)); +let svc: JourneyDomainService; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(testDb)); }); beforeEach(() => { @@ -82,7 +84,7 @@ describe('canAccessJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'My Journey' }); - const result = svc.canAccessJourney(journey.id, user.id); + const result = await svc.canAccessJourney(journey.id, user.id); expect(result).not.toBeNull(); expect(result!.id).toBe(journey.id); @@ -95,7 +97,7 @@ describe('canAccessJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = svc.canAccessJourney(journey.id, contrib.id); + const result = await svc.canAccessJourney(journey.id, contrib.id); expect(result).not.toBeNull(); expect(result!.id).toBe(journey.id); @@ -106,7 +108,7 @@ describe('canAccessJourney', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = svc.canAccessJourney(journey.id, stranger.id); + const result = await svc.canAccessJourney(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -117,7 +119,7 @@ describe('isOwner', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(svc.isOwner(journey.id, user.id)).toBe(true); + expect(await svc.isOwner(journey.id, user.id)).toBe(true); }); it('JOURNEY-SVC-005: returns false for contributor', async () => { @@ -126,7 +128,7 @@ describe('isOwner', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - expect(svc.isOwner(journey.id, contrib.id)).toBe(false); + expect(await svc.isOwner(journey.id, contrib.id)).toBe(false); }); it('JOURNEY-SVC-006: returns false for stranger', async () => { @@ -134,7 +136,7 @@ describe('isOwner', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(svc.isOwner(journey.id, stranger.id)).toBe(false); + expect(await svc.isOwner(journey.id, stranger.id)).toBe(false); }); }); @@ -143,7 +145,7 @@ describe('canEdit', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(svc.canEdit(journey.id, user.id)).toBe(true); + expect(await svc.canEdit(journey.id, user.id)).toBe(true); }); it('JOURNEY-SVC-008: editor contributor can edit', async () => { @@ -152,7 +154,7 @@ describe('canEdit', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - expect(svc.canEdit(journey.id, editor.id)).toBe(true); + expect(await svc.canEdit(journey.id, editor.id)).toBe(true); }); it('JOURNEY-SVC-009: viewer contributor cannot edit', async () => { @@ -161,7 +163,7 @@ describe('canEdit', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - expect(svc.canEdit(journey.id, viewer.id)).toBe(false); + expect(await svc.canEdit(journey.id, viewer.id)).toBe(false); }); it('JOURNEY-SVC-010: stranger cannot edit', async () => { @@ -169,7 +171,7 @@ describe('canEdit', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(svc.canEdit(journey.id, stranger.id)).toBe(false); + expect(await svc.canEdit(journey.id, stranger.id)).toBe(false); }); }); @@ -182,7 +184,7 @@ describe('listJourneys', () => { createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', location_name: 'Paris' }); createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02', location_name: 'Lyon' }); - const result = svc.listJourneys(user.id); + const result = await svc.listJourneys(user.id); expect(result).toHaveLength(1); expect(result[0].title).toBe('Road Trip'); @@ -196,7 +198,7 @@ describe('listJourneys', () => { const journey = createJourney(testDb, owner.id, { title: 'Shared Trip' }); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = svc.listJourneys(contrib.id); + const result = await svc.listJourneys(contrib.id); expect(result).toHaveLength(1); expect(result[0].title).toBe('Shared Trip'); @@ -207,7 +209,7 @@ describe('listJourneys', () => { const { user: other } = createUser(testDb); createJourney(testDb, owner.id, { title: 'Private' }); - const result = svc.listJourneys(other.id); + const result = await svc.listJourneys(other.id); expect(result).toHaveLength(0); }); @@ -217,10 +219,10 @@ describe('listJourneys', () => { const journey = createJourney(testDb, user.id, { title: 'Multi Trip' }); const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' }); const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' }); - svc.addTripToJourney(journey.id, trip1.id, user.id); - svc.addTripToJourney(journey.id, trip2.id, user.id); + await svc.addTripToJourney(journey.id, trip1.id, user.id); + await svc.addTripToJourney(journey.id, trip2.id, user.id); - const result = svc.listJourneys(user.id); + const result = await svc.listJourneys(user.id); expect(result).toHaveLength(1); expect(result[0].trip_date_min).toBe('2025-06-01'); @@ -232,7 +234,7 @@ describe('createJourney (service)', () => { it('JOURNEY-SVC-014: creates journey with contributor record', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' }); + const journey = await svc.createJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' }); expect(journey.title).toBe('New Journey'); expect(journey.subtitle).toBe('Subtitle'); @@ -251,7 +253,7 @@ describe('createJourney (service)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris 2026' }); - const journey = svc.createJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] }); const link = testDb.prepare( 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' @@ -270,7 +272,7 @@ describe('getJourneyFull', () => { story: 'Arrived!', }); - const result = svc.getJourneyFull(journey.id, user.id); + const result = await svc.getJourneyFull(journey.id, user.id); expect(result).not.toBeNull(); expect(result!.title).toBe('Full Journey'); @@ -285,7 +287,7 @@ describe('getJourneyFull', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = svc.getJourneyFull(journey.id, stranger.id); + const result = await svc.getJourneyFull(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -296,7 +298,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Old Title' }); - const updated = svc.updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' }); + const updated = await svc.updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' }); expect(updated).not.toBeNull(); expect(updated!.title).toBe('New Title'); @@ -311,7 +313,7 @@ describe('updateJourney', () => { const journey = createJourney(testDb, owner.id, { title: 'Original' }); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const updated = svc.updateJourney(journey.id, editor.id, { title: 'Edited' }); + const updated = await svc.updateJourney(journey.id, editor.id, { title: 'Edited' }); expect(updated).toBeNull(); }); @@ -322,7 +324,7 @@ describe('updateJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - const result = svc.updateJourney(journey.id, viewer.id, { title: 'Hacked' }); + const result = await svc.updateJourney(journey.id, viewer.id, { title: 'Hacked' }); expect(result).toBeNull(); }); @@ -331,7 +333,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Same' }); - const result = svc.updateJourney(journey.id, user.id, {}); + const result = await svc.updateJourney(journey.id, user.id, {}); expect(result).not.toBeNull(); expect(result!.title).toBe('Same'); @@ -341,7 +343,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'To Archive' }); - const result = svc.updateJourney(journey.id, user.id, { status: 'archived' }); + const result = await svc.updateJourney(journey.id, user.id, { status: 'archived' }); expect(result).not.toBeNull(); expect(result!.status).toBe('archived'); @@ -351,7 +353,7 @@ describe('updateJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Stay Active' }); - const result = svc.updateJourney(journey.id, user.id, { status: 'bogus' }); + const result = await svc.updateJourney(journey.id, user.id, { status: 'bogus' }); expect(result).not.toBeNull(); expect(result!.status).toBe('active'); @@ -362,12 +364,12 @@ describe('updateJourney', () => { const journey = createJourney(testDb, user.id, { title: 'Norway' }); // #1260 drew the linked trips' tracks unconditionally; they are opt-in now. - expect(svc.updateJourney(journey.id, user.id, {})!.show_trip_tracks).toBe(0); + expect((await svc.updateJourney(journey.id, user.id, {}))!.show_trip_tracks).toBe(0); // A JS boolean has to survive the trip into an INTEGER column — // better-sqlite3 refuses to bind one, so this would throw uncoerced. - expect(svc.updateJourney(journey.id, user.id, { show_trip_tracks: true })!.show_trip_tracks).toBe(1); - expect(svc.updateJourney(journey.id, user.id, { show_trip_tracks: false })!.show_trip_tracks).toBe(0); + expect((await svc.updateJourney(journey.id, user.id, { show_trip_tracks: true }))!.show_trip_tracks).toBe(1); + expect((await svc.updateJourney(journey.id, user.id, { show_trip_tracks: false }))!.show_trip_tracks).toBe(0); }); it('JOURNEY-SVC-021e: an editor cannot switch the trip tracks on (#2194)', async () => { @@ -378,7 +380,7 @@ describe('updateJourney', () => { .run(journey.id, editor.id); // Journey-level settings stay owner-only, same as title and status. - expect(svc.updateJourney(journey.id, editor.id, { show_trip_tracks: true })).toBeNull(); + expect(await svc.updateJourney(journey.id, editor.id, { show_trip_tracks: true })).toBeNull(); }); }); @@ -387,7 +389,7 @@ describe('deleteJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = svc.deleteJourney(journey.id, user.id); + const result = await svc.deleteJourney(journey.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); @@ -400,7 +402,7 @@ describe('deleteJourney', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = svc.deleteJourney(journey.id, editor.id); + const result = await svc.deleteJourney(journey.id, editor.id); expect(result).toBe(false); const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id); @@ -416,7 +418,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Linked Trip' }); - const result = svc.addTripToJourney(journey.id, trip.id, user.id); + const result = await svc.addTripToJourney(journey.id, trip.id, user.id); expect(result).toBe(true); const link = testDb.prepare( @@ -432,7 +434,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { // A trip owned by someone else, that `user` is not a member of. const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" }); - const result = svc.addTripToJourney(journey.id, foreignTrip.id, user.id); + const result = await svc.addTripToJourney(journey.id, foreignTrip.id, user.id); expect(result).toBe(false); const link = testDb.prepare( @@ -453,7 +455,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day025.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -465,9 +467,9 @@ describe('addTripToJourney / removeTripFromJourney', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Remove Me' }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); - const result = svc.removeTripFromJourney(journey.id, trip.id, user.id); + const result = await svc.removeTripFromJourney(journey.id, trip.id, user.id); expect(result).toBe(true); const link = testDb.prepare( @@ -481,10 +483,10 @@ describe('addTripToJourney / removeTripFromJourney', () => { const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); const trip = createTrip(testDb, owner.id, { title: 'Stay Linked' }); - svc.addTripToJourney(journey.id, trip.id, owner.id); + await svc.addTripToJourney(journey.id, trip.id, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = svc.removeTripFromJourney(journey.id, trip.id, editor.id); + const result = await svc.removeTripFromJourney(journey.id, trip.id, editor.id); expect(result).toBe(false); }); @@ -501,7 +503,7 @@ describe('listEntries', () => { entry_date: '2026-03-01', }); - const result = svc.listEntries(journey.id, user.id); + const result = await svc.listEntries(journey.id, user.id); expect(result).not.toBeNull(); expect(result).toHaveLength(1); @@ -514,7 +516,7 @@ describe('listEntries', () => { const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = svc.listEntries(journey.id, stranger.id); + const result = await svc.listEntries(journey.id, stranger.id); expect(result).toBeNull(); }); @@ -530,11 +532,11 @@ describe('listEntries', () => { const off = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', stats_excluded: 1 }); const on = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' }); - const listed = svc.listEntries(journey.id, user.id)!; + const listed = (await svc.listEntries(journey.id, user.id))!; expect(listed.find(e => e.id === off.id)!.stats_excluded).toBe(true); expect(listed.find(e => e.id === on.id)!.stats_excluded).toBe(false); - const full = svc.getJourneyFull(journey.id, user.id)!; + const full = (await svc.getJourneyFull(journey.id, user.id))!; expect(full.entries.map(e => e.stats_excluded)).toEqual([true, false]); }); }); @@ -544,7 +546,7 @@ describe('createEntry', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { title: 'Beach Day', entry_date: '2026-03-10', story: 'Beautiful sunset', @@ -567,7 +569,7 @@ describe('createEntry', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - const entry = svc.createEntry(journey.id, viewer.id, { + const entry = await svc.createEntry(journey.id, viewer.id, { title: 'Should Fail', entry_date: '2026-03-10', }); @@ -585,7 +587,7 @@ describe('updateEntry', () => { entry_date: '2026-03-01', }); - const updated = svc.updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' }); + const updated = await svc.updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' }); expect(updated).not.toBeNull(); expect(updated!.title).toBe('Updated'); @@ -601,7 +603,7 @@ describe('updateEntry', () => { entry_date: '2026-03-01', }); - const updated = svc.updateEntry(entry.id, user.id, { story: 'Now I have a story!' }); + const updated = await svc.updateEntry(entry.id, user.id, { story: 'Now I have a story!' }); expect(updated).not.toBeNull(); expect(updated!.type).toBe('entry'); @@ -619,13 +621,13 @@ describe('updateEntry', () => { it('answers with tags decoded, not as the JSON string the column holds', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach', 'sunset'], }); expect(entry!.tags).toEqual(['beach', 'sunset']); - const updated = svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }); + const updated = await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }); expect(updated!.tags).toEqual(['beach', 'sunset']); }); @@ -636,7 +638,7 @@ describe('updateEntry', () => { // The update path writes JSON.stringify([]) with no length check, so this // is the ordinary way an entry ends up with a non-null tags column. - const updated = svc.updateEntry(entry.id, user.id, { tags: [] }); + const updated = await svc.updateEntry(entry.id, user.id, { tags: [] }); expect(updated!.tags).toEqual([]); }); @@ -644,23 +646,23 @@ describe('updateEntry', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const prosCons = { pros: ['warm'], cons: ['crowded'] }; - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { title: 'Verdict', entry_date: '2026-03-10', pros_cons: prosCons, }); expect(entry!.pros_cons).toEqual(prosCons); - expect(svc.updateEntry(entry!.id, user.id, { title: 'Renamed' })!.pros_cons).toEqual(prosCons); + expect((await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }))!.pros_cons).toEqual(prosCons); }); /* An update that changes nothing takes its own early return out of the method. */ it('decodes on the no-op update too', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach'], }); - expect(svc.updateEntry(entry!.id, user.id, {})!.tags).toEqual(['beach']); + expect((await svc.updateEntry(entry!.id, user.id, {}))!.tags).toEqual(['beach']); }); /* @@ -671,13 +673,13 @@ describe('updateEntry', () => { it('broadcasts the decoded entry, not the row', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { title: 'Beach Day', entry_date: '2026-03-10', tags: ['beach', 'sunset'], }); const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {}); try { - svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }); + await svc.updateEntry(entry!.id, user.id, { title: 'Renamed' }); const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { tags: unknown } }; expect(payload.type).toBe('journey:entry:updated'); expect(payload.entry.tags).toEqual(['beach', 'sunset']); @@ -701,13 +703,13 @@ describe('updateEntry', () => { const spy = vi.spyOn(RealtimeService.prototype, 'broadcastToUser').mockImplementation(() => {}); try { - const off = svc.updateEntry(entry.id, user.id, { stats_excluded: true }); + const off = await svc.updateEntry(entry.id, user.id, { stats_excluded: true }); expect(off!.stats_excluded).toBe(true); expect(flagOf(entry.id)).toBe(1); const payload = spy.mock.calls.at(-1)?.[1] as { type: string; entry: { stats_excluded: unknown } }; expect(payload.entry.stats_excluded).toBe(true); - const on = svc.updateEntry(entry.id, user.id, { stats_excluded: false }); + const on = await svc.updateEntry(entry.id, user.id, { stats_excluded: false }); expect(on!.stats_excluded).toBe(false); expect(flagOf(entry.id)).toBe(0); } finally { @@ -722,14 +724,14 @@ describe('updateEntry', () => { addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - expect(svc.updateEntry(entry.id, viewer.id, { stats_excluded: true })).toBeNull(); - expect(svc.listEntries(journey.id, owner.id)![0].stats_excluded).toBe(false); + expect(await svc.updateEntry(entry.id, viewer.id, { stats_excluded: true })).toBeNull(); + expect((await svc.listEntries(journey.id, owner.id))![0].stats_excluded).toBe(false); }); it('JOURNEY-SVC-034: returns null for non-existent entry', async () => { const { user } = createUser(testDb); - const result = svc.updateEntry(99999, user.id, { title: 'No Such Entry' }); + const result = await svc.updateEntry(99999, user.id, { title: 'No Such Entry' }); expect(result).toBeNull(); }); @@ -752,7 +754,7 @@ describe('updateEntry', () => { author_id: 999999, }; - const updated = svc.updateEntry(entry.id, user.id, malicious as Parameters[2]); + const updated = await svc.updateEntry(entry.id, user.id, malicious as Parameters[2]); expect(updated).not.toBeNull(); expect(updated!.title).toBe('Updated'); // legit field still applied @@ -767,7 +769,7 @@ describe('deleteEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = svc.deleteEntry(entry.id, user.id); + const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); @@ -777,7 +779,7 @@ describe('deleteEntry', () => { it('JOURNEY-SVC-036: returns false for non-existent entry', async () => { const { user } = createUser(testDb); - expect(svc.deleteEntry(99999, user.id)).toBe(false); + expect(await svc.deleteEntry(99999, user.id)).toBe(false); }); it('JOURNEY-SVC-037: viewer cannot delete entry', async () => { @@ -787,7 +789,7 @@ describe('deleteEntry', () => { addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - expect(svc.deleteEntry(entry.id, viewer.id)).toBe(false); + expect(await svc.deleteEntry(entry.id, viewer.id)).toBe(false); }); it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', async () => { @@ -804,7 +806,7 @@ describe('deleteEntry', () => { `).run(journey.id, trip.id, place.id, user.id, now, now); const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any; - const result = svc.deleteEntry(entry.id, user.id); + const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); // Entry should still exist but reverted to skeleton @@ -823,7 +825,7 @@ describe('deleteEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' }); - const result = svc.deleteEntry(entry.id, user.id); + const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); @@ -921,7 +923,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const { user: newContrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = svc.addContributor(journey.id, owner.id, newContrib.id, 'editor'); + const result = await svc.addContributor(journey.id, owner.id, newContrib.id, 'editor'); expect(result).toBe(true); const row = testDb.prepare( @@ -938,7 +940,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - const result = svc.addContributor(journey.id, editor.id, newUser.id, 'viewer'); + const result = await svc.addContributor(journey.id, editor.id, newUser.id, 'viewer'); expect(result).toBe(false); }); @@ -947,7 +949,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const { user: owner } = createUser(testDb); const journey = createJourney(testDb, owner.id); - const result = svc.addContributor(journey.id, owner.id, owner.id, 'editor'); + const result = await svc.addContributor(journey.id, owner.id, owner.id, 'editor'); expect(result).toBe(false); }); @@ -958,7 +960,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'viewer'); - const result = svc.updateContributorRole(journey.id, owner.id, contrib.id, 'editor'); + const result = await svc.updateContributorRole(journey.id, owner.id, contrib.id, 'editor'); expect(result).toBe(true); const row = testDb.prepare( @@ -975,7 +977,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { addJourneyContributor(testDb, journey.id, editor.id, 'editor'); addJourneyContributor(testDb, journey.id, target.id, 'viewer'); - const result = svc.updateContributorRole(journey.id, editor.id, target.id, 'editor'); + const result = await svc.updateContributorRole(journey.id, editor.id, target.id, 'editor'); expect(result).toBe(false); }); @@ -986,7 +988,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, contrib.id, 'editor'); - const result = svc.removeContributor(journey.id, owner.id, contrib.id); + const result = await svc.removeContributor(journey.id, owner.id, contrib.id); expect(result).toBe(true); const row = testDb.prepare( @@ -1001,7 +1003,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { // attempting to remove the owner's own contributor record should not work // (the SQL filters role != 'owner') - svc.removeContributor(journey.id, owner.id, owner.id); + await svc.removeContributor(journey.id, owner.id, owner.id); const row = testDb.prepare( 'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?' @@ -1024,7 +1026,7 @@ describe('getSuggestions', () => { end_date: fiveDaysAgo, }); - const suggestions = svc.getSuggestions(user.id); + const suggestions = await svc.getSuggestions(user.id); expect(suggestions.length).toBe(1); expect((suggestions[0] as any).title).toBe('Recent Trip'); @@ -1040,9 +1042,9 @@ describe('getSuggestions', () => { end_date: fiveDaysAgo, }); const journey = createJourney(testDb, user.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); - const suggestions = svc.getSuggestions(user.id); + const suggestions = await svc.getSuggestions(user.id); expect(suggestions.length).toBe(0); }); @@ -1056,7 +1058,7 @@ describe('getSuggestions', () => { end_date: tomorrow, }); - const suggestions = svc.getSuggestions(user.id); + const suggestions = await svc.getSuggestions(user.id); expect(suggestions.length).toBe(0); }); @@ -1079,7 +1081,7 @@ describe('syncTripPlaces', () => { createDayAssignment(testDb, days055[0].id, place1.id); createDayAssignment(testDb, days055[1].id, place2.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" @@ -1101,8 +1103,8 @@ describe('syncTripPlaces', () => { const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day056.id, place056.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); // second call + await svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); // second call const skeletons = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'" @@ -1125,7 +1127,7 @@ describe('syncTripPlaces', () => { const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); createDayAssignment(testDb, day.id, place.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); const skeleton = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" @@ -1146,13 +1148,13 @@ describe('onPlaceCreated', () => { start_date: '2026-07-01', end_date: '2026-07-03', }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Create a new place after trip is linked const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' }); const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day058.id, place.id); - svc.onPlaceCreated(trip.id, place.id); + await svc.onPlaceCreated(trip.id, place.id); const skeleton = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -1165,7 +1167,7 @@ describe('onPlaceCreated', () => { const trip = createTrip(testDb, user.id, { title: 'Unlinked Trip' }); const place = createPlace(testDb, trip.id, { name: 'Remote Place' }); - svc.onPlaceCreated(trip.id, place.id); + await svc.onPlaceCreated(trip.id, place.id); const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1181,13 +1183,13 @@ describe('onPlaceCreated', () => { start_date: '2026-07-01', end_date: '2026-07-02', }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' }); const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day060.id, place.id); - svc.onPlaceCreated(trip.id, place.id); - svc.onPlaceCreated(trip.id, place.id); // second call + await svc.onPlaceCreated(trip.id, place.id); + await svc.onPlaceCreated(trip.id, place.id); // second call const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" @@ -1208,11 +1210,11 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Old Name' }); const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day061.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Update the place name directly in DB testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('New Name', 'New Address', place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -1233,17 +1235,17 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Original Place' }); const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day062.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a full entry const skeleton = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" ).get(journey.id, place.id) as { id: number }; - svc.updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' }); + await svc.updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' }); // Now update the place testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Changed Place', 'Changed Addr', place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE id = ?" @@ -1258,7 +1260,7 @@ describe('onPlaceUpdated', () => { const place = createPlace(testDb, trip.id, { name: 'Orphan Place' }); // Should not throw - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const entries = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1277,9 +1279,9 @@ describe('onPlaceDeleted', () => { end_date: '2026-09-02', }); const place = createPlace(testDb, trip.id, { name: 'To Be Deleted' }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); - svc.onPlaceDeleted(place.id); + await svc.onPlaceDeleted(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE source_place_id = ?" @@ -1298,15 +1300,15 @@ describe('onPlaceDeleted', () => { const place = createPlace(testDb, trip.id, { name: 'Detach Place' }); const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day065.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote the skeleton to a filled entry const skeleton = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?" ).get(journey.id, place.id) as { id: number }; - svc.updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' }); + await svc.updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' }); - svc.onPlaceDeleted(place.id); + await svc.onPlaceDeleted(place.id); const entry = testDb.prepare( "SELECT * FROM journey_entries WHERE id = ?" @@ -1322,7 +1324,7 @@ describe('onPlaceDeleted', () => { const trip = createTrip(testDb, user.id, { title: 'Unlinked' }); const place = createPlace(testDb, trip.id, { name: 'Nowhere' }); - expect(() => svc.onPlaceDeleted(place.id)).not.toThrow(); + await expect(svc.onPlaceDeleted(place.id)).resolves.toBeUndefined(); const orphaned = testDb.prepare( "SELECT COUNT(*) AS n FROM journey_entries WHERE source_place_id = ?" @@ -1343,7 +1345,7 @@ describe('linkPhotoToEntry', () => { const photo = await svc.addPhoto(entry1.id, user.id, '/uploads/link-test.jpg'); expect(photo).not.toBeNull(); - const result = svc.linkPhotoToEntry(entry2.id, photo!.id, user.id); + const result = await svc.linkPhotoToEntry(entry2.id, photo!.id, user.id); expect(result).not.toBeNull(); expect(result!.entry_id).toBe(entry2.id); }); @@ -1354,7 +1356,7 @@ describe('linkPhotoToEntry', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, user.id, '/uploads/same-entry.jpg'); - const result = svc.linkPhotoToEntry(entry.id, photo!.id, user.id); + const result = await svc.linkPhotoToEntry(entry.id, photo!.id, user.id); expect(result).not.toBeNull(); expect(result!.id).toBe(photo!.id); expect(result!.entry_id).toBe(entry.id); @@ -1363,7 +1365,7 @@ describe('linkPhotoToEntry', () => { it('JOURNEY-SVC-069: returns null for non-existent entry', async () => { const { user } = createUser(testDb); - const result = svc.linkPhotoToEntry(99999, 1, user.id); + const result = await svc.linkPhotoToEntry(99999, 1, user.id); expect(result).toBeNull(); }); @@ -1372,7 +1374,7 @@ describe('linkPhotoToEntry', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = svc.linkPhotoToEntry(entry.id, 99999, user.id); + const result = await svc.linkPhotoToEntry(entry.id, 99999, user.id); expect(result).toBeNull(); }); @@ -1384,7 +1386,7 @@ describe('linkPhotoToEntry', () => { const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg'); - const result = svc.linkPhotoToEntry(entry.id, photo!.id, viewer.id); + const result = await svc.linkPhotoToEntry(entry.id, photo!.id, viewer.id); expect(result).toBeNull(); }); }); @@ -1420,7 +1422,7 @@ describe('updatePhoto', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption'); - const result = svc.updatePhoto(photo!.id, user.id, { caption: 'New caption' }); + const result = await svc.updatePhoto(photo!.id, user.id, { caption: 'New caption' }); expect(result).not.toBeNull(); expect(result!.caption).toBe('New caption'); @@ -1432,7 +1434,7 @@ describe('updatePhoto', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, user.id, '/uploads/sort-test.jpg'); - const result = svc.updatePhoto(photo!.id, user.id, { sort_order: 10 }); + const result = await svc.updatePhoto(photo!.id, user.id, { sort_order: 10 }); expect(result).not.toBeNull(); expect(result!.sort_order).toBe(10); @@ -1441,7 +1443,7 @@ describe('updatePhoto', () => { it('JOURNEY-SVC-075: returns null for non-existent photo', async () => { const { user } = createUser(testDb); - const result = svc.updatePhoto(99999, user.id, { caption: 'Nope' }); + const result = await svc.updatePhoto(99999, user.id, { caption: 'Nope' }); expect(result).toBeNull(); }); @@ -1451,7 +1453,7 @@ describe('updatePhoto', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay'); - const result = svc.updatePhoto(photo!.id, user.id, {}); + const result = await svc.updatePhoto(photo!.id, user.id, {}); expect(result).not.toBeNull(); expect(result!.caption).toBe('Stay'); @@ -1465,7 +1467,7 @@ describe('updatePhoto', () => { const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg'); - const result = svc.updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' }); + const result = await svc.updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' }); expect(result).toBeNull(); }); }); @@ -1478,7 +1480,7 @@ describe('listUserTrips', () => { createTrip(testDb, user.id, { title: 'Trip A', start_date: '2026-01-01', end_date: '2026-01-03' }); createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-02-01', end_date: '2026-02-03' }); - const trips = svc.listUserTrips(user.id); + const trips = await svc.listUserTrips(user.id); expect(trips.length).toBe(2); // ordered by start_date DESC @@ -1489,7 +1491,7 @@ describe('listUserTrips', () => { it('JOURNEY-SVC-079: returns empty for user with no trips', async () => { const { user } = createUser(testDb); - const trips = svc.listUserTrips(user.id); + const trips = await svc.listUserTrips(user.id); expect(trips.length).toBe(0); }); @@ -1499,7 +1501,7 @@ describe('listUserTrips', () => { const { user: user2 } = createUser(testDb); createTrip(testDb, user1.id, { title: 'User1 Trip' }); - const trips = svc.listUserTrips(user2.id); + const trips = await svc.listUserTrips(user2.id); expect(trips.length).toBe(0); }); @@ -1514,7 +1516,7 @@ describe('Edge cases', () => { const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const photo = await svc.addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg'); - const result = svc.deleteEntry(entry.id, user.id); + const result = await svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); // Junction row must be gone (ON DELETE CASCADE from journey_entries). @@ -1527,7 +1529,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = svc.updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' }); + const result = await svc.updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' }); expect(result).not.toBeNull(); expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)'); @@ -1537,7 +1539,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Original' }); - const result = svc.updateJourney(journey.id, user.id, { bogus: 'field' } as any); + const result = await svc.updateJourney(journey.id, user.id, { bogus: 'field' } as any); expect(result).not.toBeNull(); expect(result!.title).toBe('Original'); @@ -1547,7 +1549,7 @@ describe('Edge cases', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-03-10', tags: ['food', 'culture'], pros_cons: { pros: ['Great view'], cons: ['Expensive'] }, @@ -1565,7 +1567,7 @@ describe('Edge cases', () => { const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const result = svc.updateEntry(entry.id, user.id, { + const result = await svc.updateEntry(entry.id, user.id, { tags: ['beach', 'adventure'], pros_cons: { pros: ['Fun'], cons: [] }, }); @@ -1590,7 +1592,7 @@ describe('Edge cases', () => { }); addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true }); - expect(svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true); + expect(await svc.addTripToJourney(journey.id, trip.id, user.id)).toBe(true); const photos = testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id); expect(photos).toHaveLength(0); @@ -1609,15 +1611,15 @@ describe('Edge cases', () => { const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[]; createDayAssignment(testDb, days087[0].id, place1.id); createDayAssignment(testDb, days087[1].id, place2.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Promote one skeleton to a filled entry const filled = testDb.prepare( "SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" ).get(journey.id, place2.id) as { id: number }; - svc.updateEntry(filled.id, user.id, { story: 'Now filled!' }); + await svc.updateEntry(filled.id, user.id, { story: 'Now filled!' }); - svc.removeTripFromJourney(journey.id, trip.id, user.id); + await svc.removeTripFromJourney(journey.id, trip.id, user.id); // skeleton for place1 should be deleted const skeletonRow = testDb.prepare( @@ -1674,10 +1676,10 @@ describe('reorderEntries', () => { const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 }); - const ok = svc.reorderEntries(journey.id, user.id, [e2.id, e1.id]); + const ok = await svc.reorderEntries(journey.id, user.id, [e2.id, e1.id]); expect(ok).toBe(true); - const entries = svc.listEntries(journey.id, user.id)!; + const entries = (await svc.listEntries(journey.id, user.id))!; const dayEntries = entries.filter(e => e.entry_date === '2026-08-01'); expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]); }); @@ -1688,7 +1690,7 @@ describe('reorderEntries', () => { const j2 = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' }); - const ok = svc.reorderEntries(j1.id, user.id, [entry.id]); + const ok = await svc.reorderEntries(j1.id, user.id, [entry.id]); expect(ok).toBe(false); }); @@ -1699,9 +1701,9 @@ describe('reorderEntries', () => { const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 }); const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 }); - svc.reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); + await svc.reorderEntries(journey.id, user.id, [day1b.id, day1a.id]); - const entries = svc.listEntries(journey.id, user.id)!; + const entries = (await svc.listEntries(journey.id, user.id))!; const day2Entry = entries.find(e => e.id === day2.id)!; expect(day2Entry.sort_order).toBe(0); }); @@ -1724,7 +1726,7 @@ describe('syncTripPlaces sort_order', () => { createDayAssignment(testDb, day.id, p2.id); createDayAssignment(testDb, day.id, p3.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); const rows = testDb.prepare( 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC' @@ -1744,14 +1746,14 @@ describe('onPlaceCreated sort_order', () => { start_date: '2026-10-01', end_date: '2026-10-02', }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string }; insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 }); const place = createPlace(testDb, trip.id, { name: 'Late Addition' }); createDayAssignment(testDb, day.id, place.id); - svc.onPlaceCreated(trip.id, place.id); + await svc.onPlaceCreated(trip.id, place.id); const newEntry = testDb.prepare( 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?' @@ -1765,7 +1767,7 @@ describe('onPlaceCreated sort_order', () => { describe('reconcileTripSkeletons', () => { /** Link a fresh journey to a trip and return both. */ - function linkedJourneyTrip() { + async function linkedJourneyTrip() { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1773,7 +1775,7 @@ describe('reconcileTripSkeletons', () => { start_date: '2026-05-01', end_date: '2026-05-03', }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); return { user, journey, trip }; } @@ -1791,12 +1793,12 @@ describe('reconcileTripSkeletons', () => { } it('JOURNEY-SVC-094: adds a skeleton for a newly assigned place', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'New Museum' }); createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const skeleton = skeletonFor(journey.id, place.id); expect(skeleton).toBeDefined(); @@ -1806,25 +1808,25 @@ describe('reconcileTripSkeletons', () => { }); it('JOURNEY-SVC-095: removes a pure skeleton when its place is unassigned', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'To Remove' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id)).toBeDefined(); testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id)).toBeUndefined(); }); it('JOURNEY-SVC-096: preserves a filled entry on unassign (detaches + notes it)', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Filled Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const skeleton = skeletonFor(journey.id, place.id); // Promote to a filled entry with content. testDb @@ -1832,7 +1834,7 @@ describe('reconcileTripSkeletons', () => { .run(skeleton.id); testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignment.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; expect(kept).toBeDefined(); @@ -1844,30 +1846,30 @@ describe('reconcileTripSkeletons', () => { }); it('JOURNEY-SVC-097: refreshes skeleton entry_date when a place is moved to another day', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Moving Place' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[0].date); testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[1].id, assignment.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[1].date); }); it('JOURNEY-SVC-098: is idempotent — a second call makes no changes', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Stable Place' }); createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const before = testDb .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') .all(journey.id) as { id: number; updated_at: number }[]; - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const after = testDb .prepare('SELECT id, updated_at FROM journey_entries WHERE journey_id = ? ORDER BY id') .all(journey.id) as { id: number; updated_at: number }[]; @@ -1882,7 +1884,7 @@ describe('reconcileTripSkeletons', () => { const place = createPlace(testDb, trip.id, { name: 'Orphan' }); createDayAssignment(testDb, days[0].id, place.id); - expect(() => svc.reconcileTripSkeletons(trip.id)).not.toThrow(); + await expect(svc.reconcileTripSkeletons(trip.id)).resolves.toBeUndefined(); const anyEntry = testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries').get() as { n: number }; expect(anyEntry.n).toBe(0); }); @@ -1891,7 +1893,7 @@ describe('reconcileTripSkeletons', () => { // -- the same place on two days (#2329) --------------------------------------- describe('a place standing on more than one day', () => { - function linkedJourneyTrip() { + async function linkedJourneyTrip() { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1899,7 +1901,7 @@ describe('a place standing on more than one day', () => { start_date: '2026-05-01', end_date: '2026-05-03', }); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); return { user, journey, trip }; } @@ -1925,7 +1927,7 @@ describe('a place standing on more than one day', () => { createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); }); @@ -1939,35 +1941,35 @@ describe('a place standing on more than one day', () => { createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); expect(skeletonsFor(journey.id, place.id)).toHaveLength(2); }); it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Höfn' }); createDayAssignment(testDb, days[1].id, place.id); createDayAssignment(testDb, days[2].id, place.id); - svc.onPlaceCreated(trip.id, place.id); + await svc.onPlaceCreated(trip.id, place.id); expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[1].date, days[2].date]); }); it('JOURNEY-SVC-REPEAT-004: assigning the place to a second day adds a second entry and keeps the first', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Akureyri' }); createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const first = skeletonsFor(journey.id, place.id)[0]; testDb.prepare("UPDATE journey_entries SET type = 'entry', story = 'Sunset' WHERE id = ?").run(first.id); createDayAssignment(testDb, days[1].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const both = skeletonsFor(journey.id, place.id); expect(both.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); @@ -1977,31 +1979,31 @@ describe('a place standing on more than one day', () => { }); it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Selfoss' }); createDayAssignment(testDb, days[0].id, place.id); const second = createDayAssignment(testDb, days[1].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonsFor(journey.id, place.id)).toHaveLength(2); testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(second.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date]); }); it('JOURNEY-SVC-REPEAT-006: moving one of the two assignments moves its entry rather than replacing it', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Geysir' }); createDayAssignment(testDb, days[0].id, place.id); const second = createDayAssignment(testDb, days[1].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const movedId = skeletonsFor(journey.id, place.id)[1].id; testDb.prepare('UPDATE day_assignments SET day_id = ? WHERE id = ?').run(days[2].id, second.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const after = skeletonsFor(journey.id, place.id); expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[2].date]); @@ -2009,15 +2011,15 @@ describe('a place standing on more than one day', () => { }); it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Old Name' }); createDayAssignment(testDb, days[0].id, place.id); createDayAssignment(testDb, days[1].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('New Name', place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const after = skeletonsFor(journey.id, place.id); expect(after.map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); @@ -2025,18 +2027,18 @@ describe('a place standing on more than one day', () => { }); it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', async () => { - const { journey, trip } = linkedJourneyTrip(); + const { journey, trip } = await linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Legacy Stop' }); createDayAssignment(testDb, days[0].id, place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const legacy = skeletonsFor(journey.id, place.id)[0]; // What an install upgraded from before the column existed looks like. testDb .prepare("UPDATE journey_entries SET source_assignment_id = NULL, type = 'entry', story = 'Kept' WHERE id = ?") .run(legacy.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const after = skeletonsFor(journey.id, place.id); expect(after).toHaveLength(1); @@ -2071,7 +2073,7 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-001: linking a trip materialises one skeleton per assigned place', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const skeletons = skeletonsOf(journey.id); expect(skeletons).toHaveLength(1); @@ -2083,20 +2085,20 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-002: syncing the same trip twice does not duplicate skeletons', async () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - svc.syncTripPlaces(journey.id, trip.id, user.id); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + await svc.syncTripPlaces(journey.id, trip.id, user.id); expect(skeletonsOf(journey.id)).toHaveLength(1); }); it('JOURNEY-SVC-SKEL-003: onPlaceCreated adds a skeleton to every journey the trip is linked to', async () => { const { user } = createUser(testDb); const { trip, day } = tripWithPlace(user.id); - const a = svc.createJourney(user.id, { title: 'A', trip_ids: [trip.id] }); - const b = svc.createJourney(user.id, { title: 'B', trip_ids: [trip.id] }); + const a = await svc.createJourney(user.id, { title: 'A', trip_ids: [trip.id] }); + const b = await svc.createJourney(user.id, { title: 'B', trip_ids: [trip.id] }); const extra = createPlace(testDb, trip.id, { name: 'Nishiki Market' }); createDayAssignment(testDb, day.id, extra.id); - svc.onPlaceCreated(trip.id, extra.id); + await svc.onPlaceCreated(trip.id, extra.id); for (const j of [a, b]) { expect(skeletonsOf(j.id).map((s) => s.title)).toContain('Nishiki Market'); @@ -2106,16 +2108,16 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-004: onPlaceCreated is a no-op for a trip in no journey', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - expect(() => svc.onPlaceCreated(trip.id, place.id)).not.toThrow(); + await expect(svc.onPlaceCreated(trip.id, place.id)).resolves.toBeUndefined(); }); it('JOURNEY-SVC-SKEL-005: onPlaceUpdated carries the rename and the new address onto the skeleton', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Kinkaku-ji', '1 Kinkakujicho', place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const [skeleton] = skeletonsOf(journey.id); expect(skeleton.title).toBe('Kinkaku-ji'); @@ -2125,27 +2127,27 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-006: onPlaceUpdated is a no-op when no skeleton points at the place', async () => { const { user } = createUser(testDb); const { place } = tripWithPlace(user.id); - expect(() => svc.onPlaceUpdated(place.id)).not.toThrow(); + await expect(svc.onPlaceUpdated(place.id)).resolves.toBeUndefined(); }); it('JOURNEY-SVC-SKEL-007: onPlaceDeleted drops an empty skeleton outright', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); expect(skeletonsOf(journey.id)).toHaveLength(1); - svc.onPlaceDeleted(place.id); + await svc.onPlaceDeleted(place.id); expect(skeletonsOf(journey.id)).toHaveLength(0); }); it('JOURNEY-SVC-SKEL-008: onPlaceDeleted keeps a skeleton that has a story, detaches it and appends the note', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const [skeleton] = skeletonsOf(journey.id); testDb.prepare('UPDATE journey_entries SET story = ? WHERE id = ?').run('We queued for an hour.', skeleton.id); - svc.onPlaceDeleted(place.id); + await svc.onPlaceDeleted(place.id); const kept = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any; expect(kept).toBeDefined(); @@ -2159,14 +2161,14 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-009: reconcileTripSkeletons adds what is missing and removes what is gone', async () => { const { user } = createUser(testDb); const { trip, day, place } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); // A second place lands without firing the hook, and the first is unassigned. const second = createPlace(testDb, trip.id, { name: 'Gion' }); createDayAssignment(testDb, day.id, second.id); testDb.prepare('DELETE FROM day_assignments WHERE place_id = ?').run(place.id); - svc.reconcileTripSkeletons(trip.id); + await svc.reconcileTripSkeletons(trip.id); const titles = skeletonsOf(journey.id).map((s) => s.title); expect(titles).toContain('Gion'); @@ -2176,7 +2178,7 @@ describe('skeleton sync', () => { it('JOURNEY-SVC-SKEL-010: reconcileTripSkeletons is a no-op for a trip in no journey', async () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); - expect(() => svc.reconcileTripSkeletons(trip.id)).not.toThrow(); + await expect(svc.reconcileTripSkeletons(trip.id)).resolves.toBeUndefined(); }); it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', async () => { @@ -2184,31 +2186,31 @@ describe('skeleton sync', () => { const trip = createTrip(testDb, user.id); testDb.prepare('UPDATE trips SET cover_image = ? WHERE id = ?').run('/uploads/covers/kyoto.jpg', trip.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const row = testDb.prepare('SELECT cover_image FROM journeys WHERE id = ?').get(journey.id) as any; expect(row.cover_image).toBe('covers/kyoto.jpg'); }); }); describe('journey gallery', () => { - function ownedEntry() { + async function ownedEntry() { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' })!; + const journey = await svc.createJourney(user.id, { title: 'J' }); + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' }))!; return { user, journey, entry }; } it('JOURNEY-SVC-PHOTO-001: addPhoto puts the file in the gallery and links it to the entry', async () => { - const { user, journey, entry } = ownedEntry(); + const { user, journey, entry } = await ownedEntry(); const photo = await svc.addPhoto(entry.id, user.id, 'journey/a.jpg', 'journey/a-thumb.jpg', 'Torii'); expect(photo).toBeTruthy(); - const entries = svc.listEntries(journey.id, user.id)!; + const entries = (await svc.listEntries(journey.id, user.id))!; expect(entries.find((e) => e.id === entry.id)!.photos).toHaveLength(1); }); it('JOURNEY-SVC-PHOTO-002: addPhoto refuses an unknown entry and a non-editor', async () => { - const { entry } = ownedEntry(); + const { entry } = await ownedEntry(); const { user: stranger } = createUser(testDb); expect(await svc.addPhoto(999999, 1, 'journey/a.jpg')).toBeNull(); expect(await svc.addPhoto(entry.id, stranger.id, 'journey/a.jpg')).toBeNull(); @@ -2217,7 +2219,7 @@ describe('journey gallery', () => { it('JOURNEY-SVC-PHOTO-003: uploadGalleryPhotos appends in order and refuses a non-editor', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); + const journey = await svc.createJourney(user.id, { title: 'J' }); const first = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/1.jpg' }]); const second = await svc.uploadGalleryPhotos(journey.id, user.id, [ @@ -2236,32 +2238,32 @@ describe('journey gallery', () => { }); it('JOURNEY-SVC-PHOTO-004: linkPhotoToEntry attaches a gallery row, unlinkPhotoFromEntry detaches it', async () => { - const { user, journey, entry } = ownedEntry(); + const { user, journey, entry } = await ownedEntry(); const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); - expect(svc.linkPhotoToEntry(entry.id, gallery.id, user.id)).toBeTruthy(); - expect(svc.listEntries(journey.id, user.id)!.find((e) => e.id === entry.id)!.photos).toHaveLength(1); + expect(await svc.linkPhotoToEntry(entry.id, gallery.id, user.id)).toBeTruthy(); + expect((await svc.listEntries(journey.id, user.id))!.find((e) => e.id === entry.id)!.photos).toHaveLength(1); - expect(svc.unlinkPhotoFromEntry(entry.id, gallery.id, user.id)).toBe(true); - expect(svc.listEntries(journey.id, user.id)!.find((e) => e.id === entry.id)!.photos).toHaveLength(0); + expect(await svc.unlinkPhotoFromEntry(entry.id, gallery.id, user.id)).toBe(true); + expect((await svc.listEntries(journey.id, user.id))!.find((e) => e.id === entry.id)!.photos).toHaveLength(0); // The gallery row survives the unlink — that is the whole point of the split. expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeDefined(); }); it('JOURNEY-SVC-PHOTO-005: link/unlink refuse an unknown entry and a non-editor', async () => { - const { user, journey, entry } = ownedEntry(); + const { user, journey, entry } = await ownedEntry(); const { user: stranger } = createUser(testDb); const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); - expect(svc.linkPhotoToEntry(999999, gallery.id, user.id)).toBeNull(); - expect(svc.linkPhotoToEntry(entry.id, gallery.id, stranger.id)).toBeNull(); - expect(svc.unlinkPhotoFromEntry(999999, gallery.id, user.id)).toBe(false); - expect(svc.unlinkPhotoFromEntry(entry.id, gallery.id, stranger.id)).toBe(false); + expect(await svc.linkPhotoToEntry(999999, gallery.id, user.id)).toBeNull(); + expect(await svc.linkPhotoToEntry(entry.id, gallery.id, stranger.id)).toBeNull(); + expect(await svc.unlinkPhotoFromEntry(999999, gallery.id, user.id)).toBe(false); + expect(await svc.unlinkPhotoFromEntry(entry.id, gallery.id, stranger.id)).toBe(false); }); it('JOURNEY-SVC-PHOTO-006: deleteGalleryPhoto removes the row and refuses an unknown id', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); + const journey = await svc.createJourney(user.id, { title: 'J' }); const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); expect(await svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy(); @@ -2272,7 +2274,7 @@ describe('journey gallery', () => { it('JOURNEY-SVC-PHOTO-007: deleteGalleryPhoto refuses a non-editor', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); + const journey = await svc.createJourney(user.id, { title: 'J' }); const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); expect(await svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull(); }); @@ -2281,7 +2283,7 @@ describe('journey gallery', () => { // with after the trip landed behind the ones added while it was running. it('JOURNEY-SVC-PHOTO-008: the gallery reads in capture order, not upload order', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); + const journey = await svc.createJourney(user.id, { title: 'J' }); const shot = async (path: string, takenAt: string) => { const [row] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]); @@ -2292,7 +2294,7 @@ describe('journey gallery', () => { await shot('journey/day1.jpg', '2026-05-01T08:30:00.000Z'); await shot('journey/day2.jpg', '2026-05-02T12:00:00.000Z'); - const gallery = svc.getJourneyFull(journey.id, user.id)!.gallery as { file_path: string }[]; + const gallery = (await svc.getJourneyFull(journey.id, user.id))!.gallery as { file_path: string }[]; expect(gallery.map((p) => p.file_path)).toEqual([ 'journey/day1.jpg', 'journey/day2.jpg', @@ -2302,23 +2304,23 @@ describe('journey gallery', () => { it('JOURNEY-SVC-PHOTO-009: a photo with no capture time rides on the date of the stop it hangs on', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - const day1 = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' })!; - const day2 = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-02', title: 'Day 2' })!; + const journey = await svc.createJourney(user.id, { title: 'J' }); + const day1 = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Day 1' }))!; + const day2 = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-02', title: 'Day 2' }))!; // Day two was sorted out first and day one caught up afterwards, with the // EXIF gone (an iPhone upload is converted before it leaves the browser). const [dayTwoPhoto] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day2.jpg' }]); const [dayOnePhoto] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day1.jpg' }]); const [loose] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/loose.jpg' }]); - svc.linkPhotoToEntry(day2.id, dayTwoPhoto.id, user.id); - svc.linkPhotoToEntry(day1.id, dayOnePhoto.id, user.id); + await svc.linkPhotoToEntry(day2.id, dayTwoPhoto.id, user.id); + await svc.linkPhotoToEntry(day1.id, dayOnePhoto.id, user.id); // Pin the loose photo's upload time so the run does not depend on today's date. testDb .prepare('UPDATE journey_photos SET created_at = ? WHERE id = ?') .run(Date.parse('2026-06-01T00:00:00Z'), loose.id); - const gallery = svc.getJourneyFull(journey.id, user.id)!.gallery as { file_path: string }[]; + const gallery = (await svc.getJourneyFull(journey.id, user.id))!.gallery as { file_path: string }[]; expect(gallery.map((p) => p.file_path)).toEqual([ 'journey/day1.jpg', 'journey/day2.jpg', @@ -2330,23 +2332,23 @@ describe('journey gallery', () => { describe('per-user journey preferences', () => { it('JOURNEY-SVC-PREF-001: hide_skeletons round-trips for the owner', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - expect(svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true })).toEqual({ hide_skeletons: true }); - expect(svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: false })).toEqual({ hide_skeletons: false }); + const journey = await svc.createJourney(user.id, { title: 'J' }); + expect(await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true })).toEqual({ hide_skeletons: true }); + expect(await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: false })).toEqual({ hide_skeletons: false }); }); it('JOURNEY-SVC-PREF-002: an empty patch is accepted and changes nothing', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true }); - expect(svc.updateJourneyPreferences(journey.id, user.id, {})).toEqual({ hide_skeletons: true }); + const journey = await svc.createJourney(user.id, { title: 'J' }); + await svc.updateJourneyPreferences(journey.id, user.id, { hide_skeletons: true }); + expect(await svc.updateJourneyPreferences(journey.id, user.id, {})).toEqual({ hide_skeletons: true }); }); it('JOURNEY-SVC-PREF-003: a stranger gets null, not a row', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - expect(svc.updateJourneyPreferences(journey.id, stranger.id, { hide_skeletons: true })).toBeNull(); + const journey = await svc.createJourney(user.id, { title: 'J' }); + expect(await svc.updateJourneyPreferences(journey.id, stranger.id, { hide_skeletons: true })).toBeNull(); }); }); @@ -2355,14 +2357,14 @@ describe('entry enrichment', () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); testDb.prepare('UPDATE trips SET title = ? WHERE id = ?').run('Japan 2026', trip.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); const [skeleton] = skeletonsOf(journey.id); testDb .prepare('UPDATE journey_entries SET tags = ?, pros_cons = ? WHERE id = ?') .run(JSON.stringify(['shrine']), JSON.stringify({ pros: ['quiet'], cons: [] }), skeleton.id); - const entry = svc.listEntries(journey.id, user.id)!.find((e) => e.id === skeleton.id)!; + const entry = (await svc.listEntries(journey.id, user.id))!.find((e) => e.id === skeleton.id)!; expect(entry.tags).toEqual(['shrine']); expect((entry as any).pros_cons).toEqual({ pros: ['quiet'], cons: [] }); expect((entry as any).source_trip_name).toBe('Japan 2026'); @@ -2370,10 +2372,10 @@ describe('entry enrichment', () => { it('JOURNEY-SVC-ENRICH-002: an entry with no trip and no tags gets [] and null, not undefined', async () => { const { user } = createUser(testDb); - const journey = svc.createJourney(user.id, { title: 'J' }); - const created = svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Solo' })!; + const journey = await svc.createJourney(user.id, { title: 'J' }); + const created = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-05-01', title: 'Solo' }))!; - const entry = svc.listEntries(journey.id, user.id)!.find((e) => e.id === created.id)!; + const entry = (await svc.listEntries(journey.id, user.id))!.find((e) => e.id === created.id)!; expect(entry.tags).toEqual([]); expect((entry as any).pros_cons).toBeNull(); expect((entry as any).source_trip_name).toBeNull(); @@ -2392,9 +2394,9 @@ describe('journeyTracks', () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000'); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const tracks = svc.journeyTracks(journey.id, user.id)!; + const tracks = (await svc.journeyTracks(journey.id, user.id))!; expect(tracks).toHaveLength(1); expect(tracks[0]).toMatchObject({ place_id: place.id, trip_id: trip.id, color: '#ff0000' }); expect(tracks[0].points).toEqual([[35.1, 135.7], [35.2, 135.8]]); @@ -2405,17 +2407,17 @@ describe('journeyTracks', () => { const { trip, place } = tripWithPlace(user.id); // The importer keeps elevation as a third value where the file had it. withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - expect(svc.journeyTracks(journey.id, user.id)![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]); + expect((await svc.journeyTracks(journey.id, user.id))![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]); }); it('JOURNEY-SVC-TRACKS-003: a place without geometry contributes nothing', async () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - expect(svc.journeyTracks(journey.id, user.id)).toEqual([]); + expect(await svc.journeyTracks(journey.id, user.id)).toEqual([]); }); it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', async () => { @@ -2424,9 +2426,9 @@ describe('journeyTracks', () => { const second = createPlace(testDb, trip.id, { name: 'Good one' }); withGeometry(place.id, 'not json at all'); withGeometry(second.id, [[1, 2], [3, 4]]); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - const tracks = svc.journeyTracks(journey.id, user.id)!; + const tracks = (await svc.journeyTracks(journey.id, user.id))!; expect(tracks.map(t => t.place_id)).toEqual([second.id]); }); @@ -2434,9 +2436,9 @@ describe('journeyTracks', () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); withGeometry(place.id, [[35.1, 135.7]]); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - expect(svc.journeyTracks(journey.id, user.id)).toEqual([]); + expect(await svc.journeyTracks(journey.id, user.id)).toEqual([]); }); it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', async () => { @@ -2444,9 +2446,9 @@ describe('journeyTracks', () => { const { user: stranger } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); withGeometry(place.id, [[1, 2], [3, 4]]); - const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); + const journey = await svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] }); - expect(svc.journeyTracks(journey.id, stranger.id)).toBeNull(); + expect(await svc.journeyTracks(journey.id, stranger.id)).toBeNull(); }); }); @@ -2460,7 +2462,7 @@ describe('addTripToJourney guards', () => { const trip = createTrip(testDb, stranger.id, { title: 'Stranger trip' }); // The stranger owns the trip, so the trip gate passes — only the journey gate stops this. - expect(svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false); + expect(await svc.addTripToJourney(journey.id, trip.id, stranger.id)).toBe(false); const links = testDb.prepare('SELECT * FROM journey_trips WHERE journey_id = ?').all(journey.id); expect(links).toHaveLength(0); }); @@ -2473,7 +2475,7 @@ describe('addTripToJourney guards', () => { .run(journey.id, helper.id, 'editor', new Date().toISOString()); const trip = createTrip(testDb, helper.id, { title: 'Helper trip' }); - expect(svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true); + expect(await svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true); }); it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', async () => { @@ -2487,7 +2489,7 @@ describe('addTripToJourney guards', () => { testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)') .run(trip.id, user.id, Number(r.lastInsertRowid)); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE journey_id = ?').all(journey.id)).toHaveLength(0); }); @@ -2503,11 +2505,11 @@ describe('dismissed suggestions', () => { const keep = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Museum' }); const drop = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Aquarium' }); - svc.updateEntry(drop.id, user.id, { dismissed: true }); + await svc.updateEntry(drop.id, user.id, { dismissed: true }); - const full = svc.getJourneyFull(journey.id, user.id)!; + const full = (await svc.getJourneyFull(journey.id, user.id))!; expect(full.entries.map((e: { id: number }) => e.id)).toEqual([keep.id]); - expect(svc.listEntries(journey.id, user.id)!.map((e) => e.id)).toEqual([keep.id]); + expect((await svc.listEntries(journey.id, user.id))!.map((e) => e.id)).toEqual([keep.id]); // The row has to survive, or syncTripPlaces offers the same place again. expect(testDb.prepare('SELECT dismissed FROM journey_entries WHERE id = ?').get(drop.id)).toEqual({ dismissed: 1, @@ -2521,10 +2523,10 @@ describe('dismissed suggestions', () => { const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); - svc.updateEntry(a.id, user.id, { dismissed: true }); - svc.updateEntry(b.id, user.id, { dismissed: true }); + await svc.updateEntry(a.id, user.id, { dismissed: true }); + await svc.updateEntry(b.id, user.id, { dismissed: true }); - expect(svc.getJourneyFull(journey.id, user.id)!.dismissed_count).toBe(2); + expect((await svc.getJourneyFull(journey.id, user.id))!.dismissed_count).toBe(2); }); it('JOURNEY-SVC-105: a dismissed place is not offered again by the trip sync', async () => { @@ -2535,13 +2537,13 @@ describe('dismissed suggestions', () => { const place = createPlace(testDb, trip.id, { name: 'Aquarium' }); createDayAssignment(testDb, day.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); - const skeleton = svc.listEntries(journey.id, user.id)!.find((e) => e.type === 'skeleton')!; - svc.updateEntry(skeleton.id, user.id, { dismissed: true }); + await svc.addTripToJourney(journey.id, trip.id, user.id); + const skeleton = (await svc.listEntries(journey.id, user.id))!.find((e) => e.type === 'skeleton')!; + await svc.updateEntry(skeleton.id, user.id, { dismissed: true }); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); - expect(svc.listEntries(journey.id, user.id)!.filter((e) => e.type === 'skeleton')).toHaveLength(0); + expect((await svc.listEntries(journey.id, user.id))!.filter((e) => e.type === 'skeleton')).toHaveLength(0); }); it('JOURNEY-SVC-106: restoring brings them all back and says how many', async () => { @@ -2549,18 +2551,18 @@ describe('dismissed suggestions', () => { const journey = createJourney(testDb, user.id); const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); const b = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); - svc.updateEntry(a.id, user.id, { dismissed: true }); - svc.updateEntry(b.id, user.id, { dismissed: true }); + await svc.updateEntry(a.id, user.id, { dismissed: true }); + await svc.updateEntry(b.id, user.id, { dismissed: true }); - expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 2 }); - expect(svc.listEntries(journey.id, user.id)).toHaveLength(2); + expect(await svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 2 }); + expect(await svc.listEntries(journey.id, user.id)).toHaveLength(2); }); it('JOURNEY-SVC-107: restoring nothing is not an error', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 0 }); + expect(await svc.restoreDismissedSuggestions(journey.id, user.id)).toEqual({ restored: 0 }); }); it('JOURNEY-SVC-108: a viewer may not restore', async () => { @@ -2569,7 +2571,7 @@ describe('dismissed suggestions', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); - expect(svc.restoreDismissedSuggestions(journey.id, viewer.id)).toBeNull(); + expect(await svc.restoreDismissedSuggestions(journey.id, viewer.id)).toBeNull(); }); }); @@ -2580,11 +2582,11 @@ describe('country_code', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15', location_lat: 52.52, location_lng: 13.405, - })!; + }))!; expect(entry.country_code).toBe('DE'); }); @@ -2593,7 +2595,7 @@ describe('country_code', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15' })!; + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15' }))!; expect(entry.country_code).toBeNull(); }); @@ -2601,13 +2603,13 @@ describe('country_code', () => { it('JOURNEY-SVC-111: moving the pin moves the country with it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15', location_lat: 52.52, location_lng: 13.405, - })!; + }))!; - const moved = svc.updateEntry(entry.id, user.id, { location_lat: 48.8584, location_lng: 2.2945 })!; + const moved = (await svc.updateEntry(entry.id, user.id, { location_lat: 48.8584, location_lng: 2.2945 }))!; expect(moved.country_code).toBe('FR'); }); @@ -2615,16 +2617,16 @@ describe('country_code', () => { it('JOURNEY-SVC-112: taking the pin off clears the country', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15', location_lat: 52.52, location_lng: 13.405, - })!; + }))!; - const cleared = svc.updateEntry(entry.id, user.id, { + const cleared = (await svc.updateEntry(entry.id, user.id, { location_lat: null as unknown as number, location_lng: null as unknown as number, - })!; + }))!; expect(cleared.country_code).toBeNull(); }); @@ -2639,7 +2641,7 @@ describe('country_code', () => { const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 }); const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); const before = testDb.prepare( "SELECT country_code FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -2647,7 +2649,7 @@ describe('country_code', () => { expect(before.country_code).toBe('FR'); testDb.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.52, 13.405, place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const after = testDb.prepare( "SELECT country_code FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'" @@ -2662,12 +2664,12 @@ describe('country_code', () => { const place = createPlace(testDb, trip.id, { name: 'Grenzstein', lat: 48.8584, lng: 2.2945 }); const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number }; createDayAssignment(testDb, day.id, place.id); - svc.addTripToJourney(journey.id, trip.id, user.id); + await svc.addTripToJourney(journey.id, trip.id, user.id); // Writing a story turns the skeleton into a filled entry. testDb.prepare("UPDATE journey_entries SET type = 'entry' WHERE source_place_id = ?").run(place.id); testDb.prepare('UPDATE places SET lat = ?, lng = ? WHERE id = ?').run(52.52, 13.405, place.id); - svc.onPlaceUpdated(place.id); + await svc.onPlaceUpdated(place.id); const after = testDb.prepare( 'SELECT country_code, location_lat FROM journey_entries WHERE source_place_id = ?' @@ -2678,13 +2680,13 @@ describe('country_code', () => { it('JOURNEY-SVC-113: an edit that does not touch the pin leaves the country alone', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const entry = svc.createEntry(journey.id, user.id, { + const entry = (await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-15', location_lat: 52.52, location_lng: 13.405, - })!; + }))!; - const renamed = svc.updateEntry(entry.id, user.id, { title: 'Berlin' })!; + const renamed = (await svc.updateEntry(entry.id, user.id, { title: 'Berlin' }))!; expect(renamed.country_code).toBe('DE'); }); @@ -2697,7 +2699,7 @@ describe('entry field switches', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record; + const row = (await svc.getJourneyFull(journey.id, user.id))! as unknown as Record; expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 1, 1]); }); @@ -2706,9 +2708,9 @@ describe('entry field switches', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.updateJourney(journey.id, user.id, { show_mood: false, show_weather: false }); + await svc.updateJourney(journey.id, user.id, { show_mood: false, show_weather: false }); - const row = svc.getJourneyFull(journey.id, user.id)! as unknown as Record; + const row = (await svc.getJourneyFull(journey.id, user.id))! as unknown as Record; expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 0, 0]); }); @@ -2718,6 +2720,6 @@ describe('entry field switches', () => { const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, editor.id, 'editor'); - expect(svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull(); + expect(await svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull(); }); }); diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index 0916ee21b..9ae783056 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -49,10 +49,11 @@ let svc: JourneyShareService; beforeAll(async () => { createTables(testDb); runMigrations(testDb); + const uow = await createTestUnitOfWork(testDb); svc = new JourneyShareService( dbs, - new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), - new SettingsService(dbs, await createTestUnitOfWork(testDb)), + new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow), + new SettingsService(dbs, uow), ); }); @@ -104,44 +105,44 @@ function insertJourneyPhoto( // -- Tests -------------------------------------------------------------------- describe('createOrUpdateJourneyShareLink', () => { - it('JOURNEY-SHARE-001: creates a new share link with default permissions', () => { + it('JOURNEY-SHARE-001: creates a new share link with default permissions', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const result = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); expect(result.created).toBe(true); expect(result.token).toBeTruthy(); expect(result.token.length).toBeGreaterThan(10); }); - it('JOURNEY-SHARE-002: creates a share link with custom permissions', () => { + it('JOURNEY-SHARE-002: creates a share link with custom permissions', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: false, }); - const link = svc.getJourneyShareLink(journey.id); + const link = await svc.getJourneyShareLink(journey.id); expect(link).not.toBeNull(); expect(link!.share_timeline).toBe(true); expect(link!.share_gallery).toBe(false); expect(link!.share_map).toBe(false); }); - it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', () => { + it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const first = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const first = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: true, }); - const second = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const second = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: false, @@ -150,35 +151,35 @@ describe('createOrUpdateJourneyShareLink', () => { expect(second.created).toBe(false); expect(second.token).toBe(first.token); - const link = svc.getJourneyShareLink(journey.id); + const link = await svc.getJourneyShareLink(journey.id); expect(link!.share_gallery).toBe(false); expect(link!.share_map).toBe(false); }); - it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', () => { + it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', async () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, helper.id, 'editor'); // Nothing published yet: an owner is allowed to look and finds nothing. - expect(svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null }); + expect(await svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null }); - svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {}); - const asOwner = svc.readJourneyShareLink(journey.id, owner.id); + await svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {}); + const asOwner = await svc.readJourneyShareLink(journey.id, owner.id); expect(asOwner.allowed).toBe(true); expect(asOwner.allowed && asOwner.link?.token).toBeTruthy(); // An editor is refused outright. Answering { link: null } here would tell a // published journey's editor it is unpublished and offer to publish it. - expect(svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false }); + expect(await svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false }); }); - it('JOURNEY-SHARE-027: an update leaves out flags it was not given', () => { + it('JOURNEY-SHARE-027: an update leaves out flags it was not given', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: false, @@ -186,61 +187,61 @@ describe('createOrUpdateJourneyShareLink', () => { }); // A caller that only flips the timeline must not silently re-publish the // gallery and map at the unchanged token. - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false }); + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false }); - const link = svc.getJourneyShareLink(journey.id); + const link = await svc.getJourneyShareLink(journey.id); expect(link!.share_timeline).toBe(false); expect(link!.share_gallery).toBe(false); expect(link!.share_map).toBe(false); expect(link!.newest_first).toBe(true); }); - it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', () => { + it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); - expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(false); + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(false); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true }); - expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true); + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true }); + expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false }); - expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true); + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false }); + expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true); }); - it('JOURNEY-SHARE-004: different journeys get different tokens', () => { + it('JOURNEY-SHARE-004: different journeys get different tokens', async () => { const { user } = createUser(testDb); const j1 = createJourney(testDb, user.id); const j2 = createJourney(testDb, user.id); - const r1 = svc.createOrUpdateJourneyShareLink(j1.id, user.id, {}); - const r2 = svc.createOrUpdateJourneyShareLink(j2.id, user.id, {}); + const r1 = await svc.createOrUpdateJourneyShareLink(j1.id, user.id, {}); + const r2 = await svc.createOrUpdateJourneyShareLink(j2.id, user.id, {}); expect(r1.token).not.toBe(r2.token); }); }); describe('getJourneyShareLink', () => { - it('JOURNEY-SHARE-005: returns null when no share link exists', () => { + it('JOURNEY-SHARE-005: returns null when no share link exists', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const result = svc.getJourneyShareLink(journey.id); + const result = await svc.getJourneyShareLink(journey.id); expect(result).toBeNull(); }); - it('JOURNEY-SHARE-006: returns share link info when it exists', () => { + it('JOURNEY-SHARE-006: returns share link info when it exists', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true, }); - const result = svc.getJourneyShareLink(journey.id); + const result = await svc.getJourneyShareLink(journey.id); expect(result).not.toBeNull(); expect(result!.token).toBeTruthy(); @@ -252,83 +253,83 @@ describe('getJourneyShareLink', () => { }); describe('deleteJourneyShareLink', () => { - it('JOURNEY-SHARE-007: owner can remove an existing share link', () => { + it('JOURNEY-SHARE-007: owner can remove an existing share link', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); - const ok = svc.deleteJourneyShareLink(journey.id, user.id); + const ok = await svc.deleteJourneyShareLink(journey.id, user.id); expect(ok).toBe(true); - expect(svc.getJourneyShareLink(journey.id)).toBeNull(); + expect(await svc.getJourneyShareLink(journey.id)).toBeNull(); }); - it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', () => { + it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(() => svc.deleteJourneyShareLink(journey.id, user.id)).not.toThrow(); + await expect(svc.deleteJourneyShareLink(journey.id, user.id)).resolves.toBe(true); }); }); describe('validateShareTokenForPhoto', () => { - it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', () => { + it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); - const result = svc.validateShareTokenForPhoto(token, photoId); + const result = await svc.validateShareTokenForPhoto(token, photoId); expect(result).not.toBeNull(); expect(result!.journeyId).toBe(journey.id); expect(result!.ownerId).toBe(user.id); }); - it('JOURNEY-SHARE-010: returns null for invalid token', () => { - const result = svc.validateShareTokenForPhoto('nonexistent-token', 1); + it('JOURNEY-SHARE-010: returns null for invalid token', async () => { + const result = await svc.validateShareTokenForPhoto('nonexistent-token', 1); expect(result).toBeNull(); }); - it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', () => { + it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', async () => { const { user } = createUser(testDb); const journey1 = createJourney(testDb, user.id); const journey2 = createJourney(testDb, user.id); const entry2 = createJourneyEntry(testDb, journey2.id, user.id); const photoId = insertJourneyPhoto(entry2.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {}); - const result = svc.validateShareTokenForPhoto(token, photoId); + const result = await svc.validateShareTokenForPhoto(token, photoId); expect(result).toBeNull(); }); - it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', () => { + it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); - const result = svc.validateShareTokenForPhoto(token, photoId); + const result = await svc.validateShareTokenForPhoto(token, photoId); expect(result).not.toBeNull(); expect(result!.ownerId).toBe(user.id); }); // Regression — GHSA-9hc8 sibling: the byte proxy must honour share_gallery. - it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', () => { + it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); - expect(svc.validateShareTokenForPhoto(token, photoId)).toBeNull(); + expect(await svc.validateShareTokenForPhoto(token, photoId)).toBeNull(); }); - it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => { + it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', async () => { // Simulate a user who has many trek_photos from Immich syncs before adding a journey photo. // trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id // instead of jp.photo_id, causing a 404 for Immich photos in public shares. @@ -343,10 +344,10 @@ describe('validateShareTokenForPhoto', () => { // This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1 const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); // photoId = trek_photos.id (6), not journey_photos.id (1) - const result = svc.validateShareTokenForPhoto(token, trekPhotoId); + const result = await svc.validateShareTokenForPhoto(token, trekPhotoId); expect(result).not.toBeNull(); expect(result!.ownerId).toBe(user.id); @@ -355,59 +356,59 @@ describe('validateShareTokenForPhoto', () => { }); describe('validateShareTokenForAsset', () => { - it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', () => { + it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); - const result = svc.validateShareTokenForAsset(token, 'immich-asset-123'); + const result = await svc.validateShareTokenForAsset(token, 'immich-asset-123'); expect(result).not.toBeNull(); expect(result!.ownerId).toBe(user.id); }); - it('JOURNEY-SHARE-014: returns null for invalid token', () => { - const result = svc.validateShareTokenForAsset('bad-token', 'some-asset'); + it('JOURNEY-SHARE-014: returns null for invalid token', async () => { + const result = await svc.validateShareTokenForAsset('bad-token', 'some-asset'); expect(result).toBeNull(); }); // Regression — GHSA-9hc8 sibling: the asset proxy must honour share_gallery. - it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', () => { + it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true }); - expect(svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull(); + expect(await svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull(); }); - it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', () => { + it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id); insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); // Without the fallback the controller filled the gap from the :ownerId path // segment, i.e. an anonymous caller picked whose provider credentials to try. - const result = svc.validateShareTokenForAsset(token, 'immich-asset-orphan'); + const result = await svc.validateShareTokenForAsset(token, 'immich-asset-orphan'); expect(result).not.toBeNull(); expect(result!.ownerId).toBe(user.id); }); - it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', () => { + it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); // A valid share token must NOT resolve arbitrary asset IDs to the owner — // otherwise it could proxy any asset out of the owner's Immich/Synology // library (IDOR). Only assets actually in the journey may resolve. - const result = svc.validateShareTokenForAsset(token, 'nonexistent-asset'); + const result = await svc.validateShareTokenForAsset(token, 'nonexistent-asset'); expect(result).toBeNull(); }); @@ -438,7 +439,7 @@ describe('getPublicJourney', () => { location_name: 'Kyoto', }); insertJourneyPhoto(entry1.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: false, @@ -467,7 +468,7 @@ describe('getPublicJourney', () => { const journey = createJourney(testDb, user.id); testDb.prepare('UPDATE journeys SET show_mood = 0, show_weather = 0 WHERE id = ?').run(journey.id); createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Tag 1', entry_date: '2026-03-20' }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true, }); @@ -489,7 +490,7 @@ describe('getPublicJourney', () => { title: 'Skeleton Entry', entry_date: '2026-01-11', }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.getPublicJourney(token); @@ -510,7 +511,7 @@ describe('getPublicJourney', () => { .run(JSON.stringify(['food', 'culture']), entry.id); insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' }); insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.getPublicJourney(token); @@ -523,7 +524,7 @@ describe('getPublicJourney', () => { it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Empty Journey' }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {}); const result = await svc.getPublicJourney(token); @@ -542,7 +543,7 @@ describe('getPublicJourney', () => { }); testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); insertJourneyPhoto(entry.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false, share_gallery: false, share_map: false, }); @@ -559,7 +560,7 @@ describe('getPublicJourney', () => { type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris', }); testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: false, }); @@ -578,7 +579,7 @@ describe('getPublicJourney', () => { type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris', }); testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false, share_gallery: false, share_map: true, }); @@ -602,7 +603,7 @@ describe('getPublicJourney', () => { testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?') .run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: false, }); @@ -629,7 +630,7 @@ describe('getPublicJourney', () => { testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?') .run(48.8584, 2.2945, trekId); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: true, }); @@ -645,7 +646,7 @@ describe('getPublicJourney', () => { type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', }); insertJourneyPhoto(entry.id, { ownerId: user.id }); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true, }); @@ -671,7 +672,7 @@ describe('getPublicJourney', () => { insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' }); testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run('2026-05-02T16:00:00.000Z', late); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: true, share_map: true, }); @@ -682,7 +683,7 @@ describe('getPublicJourney', () => { it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true }); + const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true }); expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe(''); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 2478923bf..069087a4d 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -66,9 +66,10 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { db as dbConn } from '../../../src/db/database'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; const dbs = new DatabaseService(dbConn); -const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)); +let svc: JourneyDomainService; /** The factory has no coordinate fields, and a route is made of coordinates. */ function placeEntry( @@ -85,9 +86,10 @@ function placeEntry( return entry; } -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(testDb)); }); beforeEach(() => { @@ -100,24 +102,24 @@ afterAll(() => { }); describe('journeyStats access', () => { - it('is null for a journey the user cannot see', () => { + it('is null for a journey the user cannot see', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); - expect(svc.journeyStats(journey.id, stranger.id)).toBeNull(); + expect(await svc.journeyStats(journey.id, stranger.id)).toBeNull(); }); - it('is null for a journey that does not exist', () => { + it('is null for a journey that does not exist', async () => { const { user } = createUser(testDb); - expect(svc.journeyStats(999_999, user.id)).toBeNull(); + expect(await svc.journeyStats(999_999, user.id)).toBeNull(); }); - it('answers for the owner', () => { + it('answers for the owner', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const stats = svc.journeyStats(journey.id, user.id); + const stats = await svc.journeyStats(journey.id, user.id); expect(stats).not.toBeNull(); expect(stats!.journeyId).toBe(journey.id); @@ -125,13 +127,13 @@ describe('journeyStats access', () => { }); describe('journeyStats route', () => { - it('builds the route from the entries when they carry coordinates', () => { + it('builds the route from the entries when they carry coordinates', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']); expect(stats.distance).toBeGreaterThan(240_000); @@ -139,24 +141,24 @@ describe('journeyStats route', () => { expect(stats.steps).toBe(2); }); - it('orders the route by entry date, not by insertion', () => { + it('orders the route by entry date, not by insertion', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' }); placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['first', 'second']); }); - it('skips entries without coordinates but still counts them as steps', () => { + it('skips entries without coordinates but still counts them as steps', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points).toHaveLength(1); expect(stats.steps).toBe(2); @@ -166,7 +168,7 @@ describe('journeyStats route', () => { * The fallback that makes the feature work at all for the common case: a * journey built by adding trips, before anyone has written a word. */ - it('falls back to the trip places when no entry has coordinates', () => { + it('falls back to the trip places when no entry has coordinates', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -179,13 +181,13 @@ describe('journeyStats route', () => { createDayAssignment(testDb, day2.id, b.id); createDayAssignment(testDb, day1.id, a.id); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['Hallgrímskirkja', 'Goðafoss']); expect(stats.days).toBe(3); }); - it('prefers the entries over the trip places rather than mixing them', () => { + it('prefers the entries over the trip places rather than mixing them', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -193,7 +195,7 @@ describe('journeyStats route', () => { createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 }); placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['an entry']); }); @@ -204,7 +206,7 @@ describe('journeyStats route', () => { * journey two extra legs of zero length — harmless — plus two extra stops in * the middle of the route, which is not. */ - it('visits a place assigned to several days only once', () => { + it('visits a place assigned to several days only once', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -215,13 +217,13 @@ describe('journeyStats route', () => { createDayAssignment(testDb, createDay(testDb, trip.id, { date }).id, hotel.id); } - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.filter(p => p.label === 'Hotel')).toHaveLength(1); expect(stats.places).toBe(1); }); - it('puts unscheduled places after the scheduled ones', () => { + it('puts unscheduled places after the scheduled ones', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -232,12 +234,12 @@ describe('journeyStats route', () => { createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, planned.id); expect(loose.id).toBeGreaterThan(0); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['scheduled', 'unscheduled']); }); - it('counts places across every trip on the journey', () => { + it('counts places across every trip on the journey', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const one = createTrip(testDb, user.id); @@ -248,18 +250,18 @@ describe('journeyStats route', () => { createPlace(testDb, two.id, { lat: 52.52, lng: 13.4 }); createPlace(testDb, two.id, { lat: 50.11, lng: 8.68 }); - expect(svc.journeyStats(journey.id, user.id)!.places).toBe(3); + expect((await svc.journeyStats(journey.id, user.id))!.places).toBe(3); }); }); describe('journeyStats countries', () => { - it('resolves each stop to a country and names it', () => { + it('resolves each stop to a country and names it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.countries.map(c => c.code)).toEqual(['IS', 'DE']); expect(stats.countries[0].name).toBe('Iceland'); @@ -271,7 +273,7 @@ describe('journeyStats countries', () => { * of repeating a point-in-polygon test is the difference between a query and * a boundary index. */ - it('prefers the place_regions cache over recomputing a place country', () => { + it('prefers the place_regions cache over recomputing a place country', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -281,7 +283,7 @@ describe('journeyStats countries', () => { .prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') .run(place.id, 'fr', 'FR-75', 'Paris'); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; // The cached answer wins even though the coordinates say Iceland, and the // expensive lookup is never reached for that place. @@ -289,12 +291,12 @@ describe('journeyStats countries', () => { expect(countryCalls).toHaveLength(0); }); - it('leaves a stop with no country out of the list rather than inventing one', () => { + it('leaves a stop with no country out of the list rather than inventing one', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.countries).toEqual([]); expect(stats.points).toHaveLength(1); @@ -303,7 +305,7 @@ describe('journeyStats countries', () => { }); describe('journeyStats totals', () => { - it('counts the gallery photographs', () => { + it('counts the gallery photographs', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const photo = testDb @@ -313,28 +315,28 @@ describe('journeyStats totals', () => { .prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)') .run(journey.id, photo.lastInsertRowid, Date.now()); - expect(svc.journeyStats(journey.id, user.id)!.photos).toBe(1); + expect((await svc.journeyStats(journey.id, user.id))!.photos).toBe(1); }); - it('falls back to the trip dates when the entries carry none', () => { + it('falls back to the trip dates when the entries carry none', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-10' }); linkTripToJourney(testDb, journey.id, trip.id); createPlace(testDb, trip.id, { lat: 64.14, lng: -21.94 }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.start).toBe('2026-06-01'); expect(stats.end).toBe('2026-06-10'); expect(stats.days).toBe(10); }); - it('is all zeroes for an empty journey rather than failing', () => { + it('is all zeroes for an empty journey rather than failing', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.distance).toBe(0); expect(stats.days).toBe(0); @@ -345,7 +347,7 @@ describe('journeyStats totals', () => { expect(stats.points).toEqual([]); }); - it('answers for a contributor, not only the owner', () => { + it('answers for a contributor, not only the owner', async () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -353,7 +355,7 @@ describe('journeyStats totals', () => { .prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)') .run(journey.id, helper.id, 'editor', Date.now()); - expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull(); + expect(await svc.journeyStats(journey.id, helper.id)).not.toBeNull(); }); }); @@ -364,14 +366,14 @@ describe('journeyStats totals', () => { * switched back on. */ describe('journeyStats excluded stops', () => { - it('leaves a switched-off entry out of the route, the distance and the countries', () => { + it('leaves a switched-off entry out of the route, the distance and the countries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']); // Reykjavík to Akureyri and nothing else; the leg in from Berlin is gone. @@ -381,49 +383,49 @@ describe('journeyStats excluded stops', () => { expect(stats.days).toBe(5); }); - it('names the switched-off stop under excluded so it can be switched back on', () => { + it('names the switched-off stop under excluded so it can be switched back on', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 }); placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]); }); - it('does not count a switched-off entry as a step', () => { + it('does not count a switched-off entry as a step', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 }); placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); - expect(svc.journeyStats(journey.id, user.id)!.steps).toBe(1); + expect((await svc.journeyStats(journey.id, user.id))!.steps).toBe(1); }); /* An entry without a place was never a stop, so there is nothing to offer back. */ - it('lists nothing under excluded for a switched-off entry without coordinates', () => { + it('lists nothing under excluded for a switched-off entry without coordinates', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 }); placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.excluded).toEqual([]); expect(stats.steps).toBe(1); }); - it('carries the entry on every stop read from the entries', () => { + it('carries the entry on every stop read from the entries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' }); - expect(svc.journeyStats(journey.id, user.id)!.points.map(p => p.entryId)).toEqual([a.id, b.id]); + expect((await svc.journeyStats(journey.id, user.id))!.points.map(p => p.entryId)).toEqual([a.id, b.id]); }); - it('drops the place behind a switched-off skeleton from the place count', () => { + it('drops the place behind a switched-off skeleton from the place count', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -432,12 +434,12 @@ describe('journeyStats excluded stops', () => { const town = createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 }); createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); testDb .prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?') .run(journey.id, airport.id); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; // The town's skeleton still draws the route, so this is the entries path. expect(stats.points.map(p => p.label)).toEqual(['Vík']); @@ -450,7 +452,7 @@ describe('journeyStats excluded stops', () => { * from a trip place; switching it off and then drawing the place it came * from would put the same stop straight back on the map by its other name. */ - it('leaves the place behind a switched-off skeleton out of the fallback route', () => { + it('leaves the place behind a switched-off skeleton out of the fallback route', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -460,7 +462,7 @@ describe('journeyStats excluded stops', () => { // Only the airport is on a day, so only the airport gets a skeleton; the // town stays a bare trip place that the fallback is there to draw. createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); - svc.syncTripPlaces(journey.id, trip.id, user.id); + await svc.syncTripPlaces(journey.id, trip.id, user.id); // Switched off, and its point cleared by hand afterwards, which is what // sends the journey down the fallback while the skeleton still names the // place it came from. @@ -472,7 +474,7 @@ describe('journeyStats excluded stops', () => { `) .run(journey.id, airport.id); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['Vík']); expect(stats.points[0].entryId).toBeNull(); @@ -484,7 +486,7 @@ describe('journeyStats excluded stops', () => { * The pair of cases that decide which source a journey reads from, now that * a stop can be taken out of one of them. */ - it('draws no route at all when every stop of the journal is switched off', () => { + it('draws no route at all when every stop of the journal is switched off', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -492,14 +494,14 @@ describe('journeyStats excluded stops', () => { createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points).toEqual([]); expect(stats.distance).toBe(0); expect(stats.excluded.map(s => s.label)).toEqual(['Berlin, the airport']); }); - it('still falls back to the trip places for a journal that never had a point', () => { + it('still falls back to the trip places for a journal that never had a point', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -507,7 +509,7 @@ describe('journeyStats excluded stops', () => { createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); createJourneyEntry(testDb, journey.id, user.id, { title: 'written, not placed', stats_excluded: 1 }); - const stats = svc.journeyStats(journey.id, user.id)!; + const stats = (await svc.journeyStats(journey.id, user.id))!; expect(stats.points.map(p => p.label)).toEqual(['a trip place']); }); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 9860fc5e2..1ab9d5d24 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -46,14 +46,6 @@ function ctl(service: JourneyService, books: Partial = {}): return new JourneyController(service, storageStub, books as JourneyBookService, captureBackfillStub); } -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { - expect(err).toBeInstanceOf(HttpException); - const e = err as HttpException; - return { status: e.getStatus(), body: e.getResponse() }; - } - throw new Error('expected throw'); -} async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -66,24 +58,24 @@ async function thrownAsync(fn: () => Promise): Promise<{ status: number beforeEach(() => vi.clearAllMocks()); describe('JourneyController', () => { - it('GET / lists; POST / 400 without title, else creates', () => { - expect(ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).list(user)).toEqual({ journeys: [{ id: 1 }] }); - expect(thrown(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } }); + it('GET / lists; POST / 400 without title, else creates', async () => { + expect(await ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).list(user)).toEqual({ journeys: [{ id: 1 }] }); + expect(await thrownAsync(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } }); const createJourney = vi.fn().mockReturnValue({ id: 9 }); - expect(ctl(svc({ createJourney } as Partial)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 }); + expect(await ctl(svc({ createJourney } as Partial)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 }); expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] }); }); - it('GET /suggestions + /available-trips', () => { - expect(ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).suggestions(user)).toEqual({ trips: [{ id: 1 }] }); - expect(ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] }); + it('GET /suggestions + /available-trips', async () => { + expect(await ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).suggestions(user)).toEqual({ trips: [{ id: 1 }] }); + expect(await ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] }); }); - it('PATCH/DELETE entries map 404', () => { - expect(thrown(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } }); - expect(ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 }); - expect(thrown(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } }); - expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial)).deleteEntry(user, '3')).toEqual({ success: true }); + it('PATCH/DELETE entries map 404', async () => { + expect(await thrownAsync(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } }); + expect(await ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 }); + expect(await thrownAsync(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } }); + expect(await ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial)).deleteEntry(user, '3')).toEqual({ success: true }); }); it('provider-photos: batch, single 400/403, success', async () => { @@ -93,19 +85,19 @@ describe('JourneyController', () => { expect(await thrownAsync(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); }); - it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => { - expect(thrown(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } }); + it('link-photo: 400 without id (accepts legacy photo_id), 403, success', async () => { + expect(await thrownAsync(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } }); const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 }); const c = ctl(svc({ linkPhotoToEntry } as Partial)); - expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 }); + expect(await c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 }); expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1); // accepts the canonical journey_photo_id, 403 when the service refuses - expect(thrown(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await thrownAsync(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => { - expect(thrown(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } }); - expect(ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial)).unlinkPhoto(user, '3', '7')).toBeUndefined(); + expect(await thrownAsync(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } }); + expect(await ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial)).unlinkPhoto(user, '3', '7')).toBeUndefined(); expect(await thrownAsync(() => ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } }); expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial)).deletePhoto(user, '7')).toEqual({ success: true }); }); @@ -154,54 +146,54 @@ describe('JourneyController', () => { expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video'); }); - it('GET/PATCH/DELETE /:id map 404', () => { - expect(thrown(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).get(user, '9')).toEqual({ id: 9 }); - expect(thrown(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(thrown(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + it('GET/PATCH/DELETE /:id map 404', async () => { + expect(await thrownAsync(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).get(user, '9')).toEqual({ id: 9 }); + expect(await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await thrownAsync(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); }); - it('trips: POST 400 without trip_id / 403, DELETE 403', () => { - expect(thrown(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } }); - expect(thrown(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true }); - expect(thrown(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + it('trips: POST 400 without trip_id / 403, DELETE 403', async () => { + expect(await thrownAsync(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } }); + expect(await thrownAsync(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true }); + expect(await thrownAsync(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); - it('entries under journey: list 404, create 400/404, reorder 400/403', () => { - expect(thrown(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] }); - expect(thrown(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } }); - expect(thrown(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(thrown(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } }); - expect(thrown(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + it('entries under journey: list 404, create 400/404, reorder 400/403', async () => { + expect(await thrownAsync(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] }); + expect(await thrownAsync(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } }); + expect(await thrownAsync(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await thrownAsync(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } }); + expect(await thrownAsync(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); - it('contributors: add 400/403, update 403, remove 403', () => { - expect(thrown(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } }); - expect(thrown(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true }); - expect(thrown(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(thrown(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + it('contributors: add 400/403, update 403, remove 403', async () => { + expect(await thrownAsync(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } }); + expect(await thrownAsync(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true }); + expect(await thrownAsync(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await thrownAsync(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); - it('preferences 403, share-link get/set/delete', () => { - expect(thrown(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: { token: 'abc' } }) } as Partial)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } }); + it('preferences 403, share-link get/set/delete', async () => { + expect(await thrownAsync(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: { token: 'abc' } }) } as Partial)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } }); // An owner whose journey has no link gets the null; a non-owner gets the // same refusal set/delete give, so the client can tell the two apart. - expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: null }) } as Partial)).getShareLink(user, '9')).toEqual({ link: null }); - expect(thrown(() => ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial)).getShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); - expect(thrown(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: null }) } as Partial)).getShareLink(user, '9')).toEqual({ link: null }); + expect(await thrownAsync(() => ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial)).getShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await thrownAsync(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } }); const setLink = vi.fn().mockReturnValue({ token: 'abc' }); - expect(ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' }); + expect(await ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' }); // newest_first is part of the shared contract and the public viewer reads // it, so it has to reach the service instead of being dropped here (#1614). expect(setLink).toHaveBeenCalledWith(9, 1, { share_timeline: true, share_gallery: undefined, share_map: undefined, newest_first: undefined }); const withOrder = vi.fn().mockReturnValue({ token: 'abc' }); - ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial)).setShareLink(user, '9', { newest_first: true }); + await ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial)).setShareLink(user, '9', { newest_first: true }); expect(withOrder).toHaveBeenCalledWith(9, 1, { share_timeline: undefined, share_gallery: undefined, share_map: undefined, newest_first: true }); - expect(thrown(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await thrownAsync(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); it('takes the committed bytes back out when the upload is refused', async () => { @@ -272,9 +264,9 @@ describe('JourneyController', () => { expect(await ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 }); }); - it('PATCH photos: 404 then returns the updated photo', () => { - expect(thrown(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } }); - expect(ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 }); + it('PATCH photos: 404 then returns the updated photo', async () => { + expect(await thrownAsync(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } }); + expect(await ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 }); }); it('DELETE photo removes the storage object when a path exists', async () => { @@ -322,8 +314,8 @@ describe('JourneyController', () => { expect(storageStub.delete).toHaveBeenCalledWith('journey', 'poster.jpg'); }); - it('PATCH /:id returns the updated journey on success', () => { - expect(ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).update(user, '9', { title: 'x' })).toEqual({ id: 9 }); + it('PATCH /:id returns the updated journey on success', async () => { + expect(await ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial)).update(user, '9', { title: 'x' })).toEqual({ id: 9 }); }); it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => { @@ -335,42 +327,42 @@ describe('JourneyController', () => { expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' }); }); - it('DELETE /:id and trips/contributors success paths', () => { - expect(ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial)).remove(user, '9')).toEqual({ success: true }); - expect(ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial)).removeTrip(user, '9', '2')).toEqual({ success: true }); - expect(ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true }); - expect(ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial)).removeContributor(user, '9', '2')).toEqual({ success: true }); + it('DELETE /:id and trips/contributors success paths', async () => { + expect(await ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial)).remove(user, '9')).toEqual({ success: true }); + expect(await ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial)).removeTrip(user, '9', '2')).toEqual({ success: true }); + expect(await ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true }); + expect(await ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial)).removeContributor(user, '9', '2')).toEqual({ success: true }); }); - it('addContributor defaults the role to viewer when omitted', () => { + it('addContributor defaults the role to viewer when omitted', async () => { const addContributor = vi.fn().mockReturnValue(true); - ctl(svc({ addContributor } as Partial)).addContributor(user, '9', { user_id: 2 }); + await ctl(svc({ addContributor } as Partial)).addContributor(user, '9', { user_id: 2 }); expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer'); }); - it('createEntry returns the entry when the journey exists', () => { - expect(ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 }); + it('createEntry returns the entry when the journey exists', async () => { + expect(await ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 }); }); - it('reorderEntries succeeds for a numeric array', () => { - expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); + it('reorderEntries succeeds for a numeric array', async () => { + expect(await ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); }); - it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', () => { + it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', async () => { const restore = vi.fn().mockReturnValue({ restored: 3 }); - expect(ctl(svc({ restoreDismissedSuggestions: restore } as Partial)).restoreSuggestions(user, '9')).toEqual({ restored: 3 }); + expect(await ctl(svc({ restoreDismissedSuggestions: restore } as Partial)).restoreSuggestions(user, '9')).toEqual({ restored: 3 }); expect(restore).toHaveBeenCalledWith(9, 1); expect( - thrown(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial)).restoreSuggestions(user, '9')), + await thrownAsync(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial)).restoreSuggestions(user, '9')), ).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); - it('preferences returns the result on success', () => { - expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true }); + it('preferences returns the result on success', async () => { + expect(await ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true }); }); - it('deleteShareLink returns success when removed', () => { - expect(ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial)).deleteShareLink(user, '9')).toEqual({ success: true }); + it('deleteShareLink returns success when removed', async () => { + expect(await ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial)).deleteShareLink(user, '9')).toEqual({ success: true }); }); /* * The Studio book (#1973). Its access shape is the same as everything around @@ -378,25 +370,25 @@ describe('JourneyController', () => { * is a 404 rather than a 403, so the route cannot be used to find out which * journeys exist. */ - it('book: 404 vs a journey with no book yet, and the save status codes', () => { + it('book: 404 vs a journey with no book yet, and the save status codes', async () => { // A journey with no book yet is not an error: Studio opens on it, lays a // book out and saves. 404 here would make "no book" and "no journey" // indistinguishable to the client. - expect(ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null }); - expect(thrown(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } }); + expect(await ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null }); + expect(await thrownAsync(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } }); // A refused save says which refusal it was. Out of reach entirely is still // 404; a viewer who can open the journey but may not write it gets 403, so // the editor can say why instead of claiming the journey vanished. - expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 403, body: { error: 'Not allowed' } }); // The record comes back bare, not in an envelope — the contract in // shared/src/book/book-store.schema.ts is the record itself. const broadcastSaved = vi.fn(); const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } }); - expect(ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 }); + expect(await ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 }); expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 }); // Forwarded so the client that just saved does not process its own change. expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7'); @@ -404,11 +396,11 @@ describe('JourneyController', () => { // A missing title is '' rather than undefined: the column is NOT NULL and // an untitled book is an ordinary thing to have. const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } }); - ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never); + await ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never); expect(untitled.mock.calls[0][2].title).toBe(''); }); - it('book: a conflict is a 409 that carries the other version', () => { + it('book: a conflict is a 409 that carries the other version', async () => { /* * Two people editing is the normal case, not an exception. A bare 409 would * make showing what the other version is a second round trip, at exactly @@ -416,7 +408,7 @@ describe('JourneyController', () => { */ const broadcastSaved = vi.fn(); const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } }); - expect(thrown(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({ + expect(await thrownAsync(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({ status: 409, body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } }, }); @@ -424,13 +416,13 @@ describe('JourneyController', () => { expect(broadcastSaved).not.toHaveBeenCalled(); }); - it('book: delete answers 204, 403 for a viewer, or 404 out of reach', () => { - expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined(); + it('book: delete answers 204, 403 for a viewer, or 404 out of reach', async () => { + expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined(); // False means there was nothing to delete, which is still a 204 — deleting // a book that is already gone is not a failure. - expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined(); - expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); - expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).deleteBook(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); + expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined(); + expect(await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } }); + expect(await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).deleteBook(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } }); }); }); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 12b8a0107..7b5ad4a8e 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -278,16 +278,16 @@ describe('createAuthCode / consumeAuthCode', () => { // ── generateToken ───────────────────────────────────────────────────────────── describe('generateToken', () => { - it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', () => { + it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', async () => { const { user } = createUser(testDb, { email: 'remember@example.com' }); - const token = svc.generateToken({ id: user.id }, true); + const token = await svc.generateToken({ id: user.id }, true); const decoded = jwtLib.decode(token) as { iat: number; exp: number }; expect(decoded.exp - decoded.iat).toBe(2592000); }); - it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', () => { + it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', async () => { const { user } = createUser(testDb, { email: 'default@example.com' }); - for (const token of [svc.generateToken({ id: user.id }, false), svc.generateToken({ id: user.id })]) { + for (const token of [await svc.generateToken({ id: user.id }, false), await svc.generateToken({ id: user.id })]) { const decoded = jwtLib.decode(token) as { iat: number; exp: number }; expect(decoded.exp - decoded.iat).toBe(86400); } @@ -1212,8 +1212,8 @@ describe('wrapper methods', () => { // The SSO configuration read/write moved here from AdminService, which held the SQL // for a domain that already had a module. Same cases, same service, new owner. describe('OIDC settings', () => { - it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => { - const result = svc.getOidcSettings() as any; + it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', async () => { + const result = await svc.getOidcSettings() as any; expect(result.issuer).toBe(''); expect(result.client_id).toBe(''); expect(result.oidc_only).toBe(false); @@ -1224,14 +1224,14 @@ describe('OIDC settings', () => { it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', async () => { await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); - const result = svc.getOidcSettings() as any; + const result = await svc.getOidcSettings() as any; expect(result.issuer).toBe('https://auth.example.com'); expect(result.client_id).toBe('my-client'); }); it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', async () => { await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); - const result = svc.getOidcSettings() as any; + const result = await svc.getOidcSettings() as any; // oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles expect(result.oidc_only).toBe(false); }); @@ -1239,7 +1239,7 @@ describe('OIDC settings', () => { it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', async () => { const result = await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any; expect(result.success).toBe(true); - const settings = svc.getOidcSettings(); + const settings = await svc.getOidcSettings(); expect(settings).toMatchObject({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }); }); }); @@ -1264,13 +1264,13 @@ describe('OIDC settings — the lockout guard', () => { it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', async () => { const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never); await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' }); - expect(svc.getOidcSettings().client_secret_set).toBe(true); + expect((await svc.getOidcSettings()).client_secret_set).toBe(true); // Omitted: the write skips the column entirely rather than blanking it, which is // what lets the admin panel save the form without re-typing the secret. await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' }); - expect(svc.getOidcSettings().client_secret_set).toBe(true); + expect((await svc.getOidcSettings()).client_secret_set).toBe(true); await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' }); - expect(svc.getOidcSettings().client_secret_set).toBe(false); + expect((await svc.getOidcSettings()).client_secret_set).toBe(false); toggles.mockRestore(); }); }); diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 94256772d..bf3a86261 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -145,17 +145,17 @@ beforeEach(() => { // ── Kill switch ────────────────────────────────────────────────────────────── describe('enrichDisabled', () => { - it('ENRICH-001: is off only when the setting is literally "false"', () => { + it('ENRICH-001: is off only when the setting is literally "false"', async () => { const svc = make(mapsStub(), cacheStub()); mockDbGet.mockReturnValue(undefined); - expect(svc.enrichDisabled()).toBe(false); // never configured — fail open + expect(await svc.enrichDisabled()).toBe(false); // never configured — fail open mockDbGet.mockReturnValue({ value: 'true' }); - expect(svc.enrichDisabled()).toBe(false); + expect(await svc.enrichDisabled()).toBe(false); mockDbGet.mockReturnValue({ value: 'false' }); - expect(svc.enrichDisabled()).toBe(true); + expect(await svc.enrichDisabled()).toBe(true); }); it('ENRICH-002: answers the disabled envelope without touching a provider', async () => { diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index 0f8d63600..2002e36e7 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -90,7 +90,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise { it('GET 404 without access, else owner+members+current_user_id', async () => { expect(await thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const s = svc({ listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }) } as Partial); - expect(tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); + expect(await tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 }); }); it('POST 403 without member_manage, else adds + notifies', async () => { @@ -134,7 +134,7 @@ describe('guests (#1362)', () => { expect(await thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } }); const s = svc({ createGuest } as Partial); - expect(tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); + expect(await tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id); }); @@ -142,7 +142,7 @@ describe('guests (#1362)', () => { const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial); expect(await thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } }); const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial); - expect(tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true }); + expect(await tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true }); }); it('delete: 404 when the guest is missing, else success', async () => { diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index 487d9b0a8..c83f4f30b 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -126,7 +126,7 @@ afterAll(() => { // ── Delegating helpers (what the controller calls around the mutations) ─────── describe('TripMembersService delegation', () => { - it('MEMBERS-SVC-001: canAccessTrip forwards the db helper — owner and member get the access row, a stranger gets nothing', () => { + it('MEMBERS-SVC-001: canAccessTrip forwards the db helper — owner and member get the access row, a stranger gets nothing', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const { user: stranger } = createUser(testDb); @@ -136,9 +136,9 @@ describe('TripMembersService delegation', () => { // Every member route answers 404 on a falsy result and reads access.user_id as // the trip owner, so a delegation that stopped forwarding would lock the whole // roster out rather than fail loudly. - expect(roster.canAccessTrip(String(trip.id), owner.id)).toMatchObject({ user_id: owner.id }); - expect(roster.canAccessTrip(trip.id, member.id)).toMatchObject({ user_id: owner.id }); - expect(roster.canAccessTrip(trip.id, stranger.id)).toBeUndefined(); + expect(await roster.canAccessTrip(String(trip.id), owner.id)).toMatchObject({ user_id: owner.id }); + expect(await roster.canAccessTrip(trip.id, member.id)).toMatchObject({ user_id: owner.id }); + expect(await roster.canAccessTrip(trip.id, stranger.id)).toBeUndefined(); }); it('MEMBERS-SVC-002: can() resolves member_manage at its trip_owner default and lets admins through', async () => { @@ -152,7 +152,7 @@ describe('TripMembersService delegation', () => { expect(await roster.can('member_manage', 'admin', owner.id, member.id, true)).toBe(true); }); - it('MEMBERS-SVC-003: broadcast forwards the socket id so the originating client is not echoed', () => { + it('MEMBERS-SVC-003: broadcast forwards the socket id so the originating client is not echoed', async () => { roster.broadcast('9', 'trip:updated', { trip: { id: 9 } } as never, 'sock-1'); expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock-1'); @@ -162,7 +162,7 @@ describe('TripMembersService delegation', () => { expect(broadcast).toHaveBeenLastCalledWith('9', 'trip:updated', { trip: { id: 9 } }, undefined); }); - it('MEMBERS-SVC-004: getTripForViewer re-reads the trip in list shape, with is_owner per viewer', () => { + it('MEMBERS-SVC-004: getTripForViewer re-reads the trip in list shape, with is_owner per viewer', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: 'Handover' }); @@ -171,11 +171,11 @@ describe('TripMembersService delegation', () => { // The handover broadcast hands the raw :id route param straight in, so the // named-parameter query has to keep matching a string id against the INTEGER // column — and the payload the clients re-read must carry their own is_owner. - const asOwner = roster.getTripForViewer(String(trip.id), owner.id) as Record; + const asOwner = await roster.getTripForViewer(String(trip.id), owner.id) as Record; expect(asOwner).toMatchObject({ id: trip.id, title: 'Handover', is_owner: 1, owner_username: owner.username, shared_count: 1 }); - const asMember = roster.getTripForViewer(trip.id, member.id) as Record; + const asMember = await roster.getTripForViewer(trip.id, member.id) as Record; expect(asMember.is_owner).toBe(0); - expect(roster.getTripForViewer(999999, owner.id)).toBeUndefined(); + expect(await roster.getTripForViewer(999999, owner.id)).toBeUndefined(); }); it('MEMBERS-SVC-005: notifyInvite sends a user-scoped trip_invite carrying the actor, invitee and trip', async () => { @@ -217,44 +217,44 @@ describe('addMember fallbacks', () => { // membership is inserted — losing that row must not cost the invitee their // access or throw on an undefined title. const broken = await rosterWithMissingRow('SELECT title FROM trips WHERE id = ?'); - const result = broken.addMember(trip.id, invitee.email, owner.id, owner.id); + const result = await broken.addMember(trip.id, invitee.email, owner.id, owner.id); expect(result.tripTitle).toBe('Untitled'); expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeDefined(); }); - it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', () => { + it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', async () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); const trip = createTrip(testDb, owner.id); // Pasted invites arrive with surrounding whitespace; without the trim the // lookup misses and the box answers 'User not found' for a real account. - expect(roster.addMember(trip.id, ` ${invitee.username} `, owner.id, owner.id).member.id).toBe(invitee.id); + expect((await roster.addMember(trip.id, ` ${invitee.username} `, owner.id, owner.id)).member.id).toBe(invitee.id); }); }); // ── transferOwnership guard rails (#973) ───────────────────────────────────── describe('transferOwnership guard rails', () => { - it('MEMBERS-SVC-009: rejects a trip that no longer exists, before any other check', () => { + it('MEMBERS-SVC-009: rejects a trip that no longer exists, before any other check', async () => { const { user: owner } = createUser(testDb); // NotFoundError, not ValidationError: the controller maps the two to 404 and // 400. Passing the owner as the new owner too proves the trip lookup runs // first — otherwise this would surface as 'You already own this trip'. - expect(() => roster.transferOwnership(999999, owner.id, owner.id)).toThrow(NotFoundError); - expect(() => roster.transferOwnership(999999, owner.id, owner.id)).toThrow('Trip not found'); + await expect(roster.transferOwnership(999999, owner.id, owner.id)).rejects.toThrow(NotFoundError); + await expect(roster.transferOwnership(999999, owner.id, owner.id)).rejects.toThrow('Trip not found'); }); - it('MEMBERS-SVC-010: rejects an id with no user row before it checks membership', () => { + it('MEMBERS-SVC-010: rejects an id with no user row before it checks membership', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); // A stale id from the client must read as a 404 'User not found' rather than // the 400 the non-member branch below it would produce, and the trip must keep // its owner either way. - expect(() => roster.transferOwnership(trip.id, 999999, owner.id)).toThrow(NotFoundError); - expect(() => roster.transferOwnership(trip.id, 999999, owner.id)).toThrow('User not found'); + await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow(NotFoundError); + await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow('User not found'); expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(owner.id); }); @@ -268,7 +268,7 @@ describe('transferOwnership guard rails', () => { // handover halfway, which would leave the owner pointer and the member rows // disagreeing about who owns the trip. const broken = await rosterWithMissingRow('SELECT email FROM users WHERE id = ?'); - const result = broken.transferOwnership(trip.id, member.id, owner.id); + const result = await broken.transferOwnership(trip.id, member.id, owner.id); expect(result.fromEmail).toBe(''); expect(result.toEmail).toBe(member.email); expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(member.id); @@ -278,13 +278,13 @@ describe('transferOwnership guard rails', () => { // ── Guest name validation (#1362) ──────────────────────────────────────────── describe('guest name validation', () => { - it('MEMBERS-SVC-012: createGuest rejects an absent, blank or over-long name and writes nothing', () => { + it('MEMBERS-SVC-012: createGuest rejects an absent, blank or over-long name and writes nothing', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(() => roster.createGuest(trip.id, undefined as never, owner.id)).toThrow(ValidationError); - expect(() => roster.createGuest(trip.id, ' ', owner.id)).toThrow('Guest name is required'); - expect(() => roster.createGuest(trip.id, 'x'.repeat(51), owner.id)).toThrow('Guest name must be 50 characters or fewer'); + await expect(roster.createGuest(trip.id, undefined as never, owner.id)).rejects.toThrow(ValidationError); + await expect(roster.createGuest(trip.id, ' ', owner.id)).rejects.toThrow('Guest name is required'); + await expect(roster.createGuest(trip.id, 'x'.repeat(51), owner.id)).rejects.toThrow('Guest name must be 50 characters or fewer'); // The guards run ahead of the transaction, so a rejected name can never leave // a credential-less users row behind with no trip to belong to. @@ -292,25 +292,25 @@ describe('guest name validation', () => { // 50 is the accepted boundary the DTO shares — off by one here and the API // starts refusing names the client believes are valid. - expect(roster.createGuest(trip.id, 'x'.repeat(50), owner.id).member.username).toHaveLength(50); + expect((await roster.createGuest(trip.id, 'x'.repeat(50), owner.id)).member.username).toHaveLength(50); }); - it('MEMBERS-SVC-013: renameGuest rejects an absent, blank or over-long name — before the trip-scope check', () => { + it('MEMBERS-SVC-013: renameGuest rejects an absent, blank or over-long name — before the trip-scope check', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const { member: guest } = roster.createGuest(trip.id, 'Ida', owner.id); + const { member: guest } = await roster.createGuest(trip.id, 'Ida', owner.id); - expect(() => roster.renameGuest(trip.id, guest.id, undefined as never)).toThrow(ValidationError); - expect(() => roster.renameGuest(trip.id, guest.id, ' ')).toThrow('Guest name is required'); - expect(() => roster.renameGuest(trip.id, guest.id, 'x'.repeat(51))).toThrow('Guest name must be 50 characters or fewer'); + await expect(roster.renameGuest(trip.id, guest.id, undefined as never)).rejects.toThrow(ValidationError); + await expect(roster.renameGuest(trip.id, guest.id, ' ')).rejects.toThrow('Guest name is required'); + await expect(roster.renameGuest(trip.id, guest.id, 'x'.repeat(51))).rejects.toThrow('Guest name must be 50 characters or fewer'); // Order matters for the status code: an unusable name throws (400) even for an // id that is not a guest of this trip, where the scope check returns false (404). - expect(() => roster.renameGuest(trip.id, owner.id, '')).toThrow('Guest name is required'); + await expect(roster.renameGuest(trip.id, owner.id, '')).rejects.toThrow('Guest name is required'); expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida'); // A padded name is stored trimmed, so the roster does not render the spaces. - expect(roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true); + expect(await roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true); expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida M.'); }); @@ -319,7 +319,7 @@ describe('guest name validation', () => { const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); const otherTrip = createTrip(testDb, other.id); - const { member: guest } = roster.createGuest(trip.id, 'Jo', owner.id); + const { member: guest } = await roster.createGuest(trip.id, 'Jo', owner.id); // The route only proves ownership of the trip in the URL, so this check is the // only thing between it and a foreign guest's users row — and the delete @@ -332,7 +332,7 @@ describe('guest name validation', () => { // ── listMembers shaping ────────────────────────────────────────────────────── describe('listMembers shaping', () => { - it('MEMBERS-SVC-015: avatar_url follows the storage form and the inviter is named, not just its id', () => { + it('MEMBERS-SVC-015: avatar_url follows the storage form and the inviter is named, not just its id', async () => { const { user: owner } = createUser(testDb); const { user: uploaded } = createUser(testDb); const { user: sso } = createUser(testDb); @@ -345,7 +345,7 @@ describe('listMembers shaping', () => { testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, sso.id, owner.id); addTripMember(testDb, trip.id, bare.id); - const { owner: ownerRow, members } = roster.listMembers(trip.id, owner.id); + const { owner: ownerRow, members } = await roster.listMembers(trip.id, owner.id); // added_at has second resolution, so rows created in one test are tied — index // by id rather than asserting the ORDER BY. const byId = new Map(members.map(m => [m.id, m])); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 4bc8cf152..2187e5fbb 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -106,7 +106,7 @@ beforeAll(async () => { dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache, - new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), + new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)), makeStorageFixture('').storage, await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 83acee59e..bf340dfb5 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -133,7 +133,7 @@ beforeAll(async () => { new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService(), coversFx.storage), photoCache, - new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())), + new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)), makeStorageFixture('').storage, await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection), ); @@ -589,13 +589,13 @@ describe('resyncAccommodationDays (#1288)', () => { }); describe('transferOwnership (#973)', () => { - it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => { + it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - const result = membersSvc.transferOwnership(trip.id, member.id, owner.id); + const result = await membersSvc.transferOwnership(trip.id, member.id, owner.id); expect(result.toEmail).toBe(member.email); const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }; @@ -607,35 +607,35 @@ describe('transferOwnership (#973)', () => { expect(memberIds).not.toContain(member.id); }); - it('TRIP-SVC-021: rejects a transfer from a non-owner', () => { + it('TRIP-SVC-021: rejects a transfer from a non-owner', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); // member (not the owner) attempts the transfer - expect(() => membersSvc.transferOwnership(trip.id, member.id, member.id)).toThrow(); + await expect(membersSvc.transferOwnership(trip.id, member.id, member.id)).rejects.toThrow(); }); - it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => { + it('TRIP-SVC-022: rejects a transfer to someone who is not a member', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(() => membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member'); + await expect(membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).rejects.toThrow('New owner must be a trip member'); }); - it('TRIP-SVC-023: rejects transferring to yourself', () => { + it('TRIP-SVC-023: rejects transferring to yourself', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - expect(() => membersSvc.transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip'); + await expect(membersSvc.transferOwnership(trip.id, owner.id, owner.id)).rejects.toThrow('You already own this trip'); }); }); describe('guest members (#1362)', () => { - it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => { + it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const { member } = membersSvc.createGuest(trip.id, ' Anna ', owner.id); + const { member } = await membersSvc.createGuest(trip.id, ' Anna ', owner.id); expect(member.username).toBe('Anna'); expect(member.is_guest).toBe(true); @@ -650,17 +650,17 @@ describe('guest members (#1362)', () => { expect(m).toBeTruthy(); // Surfaces in listMembers with is_guest=true and the typed display name. - const { members } = membersSvc.listMembers(trip.id, owner.id) as any; + const { members } = await membersSvc.listMembers(trip.id, owner.id) as any; const guest = members.find((x: any) => x.id === member.id); expect(guest.username).toBe('Anna'); expect(guest.is_guest).toBe(true); }); - it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => { + it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const a = membersSvc.createGuest(trip.id, 'Sam', owner.id); - const b = membersSvc.createGuest(trip.id, 'Sam', owner.id); + const a = await membersSvc.createGuest(trip.id, 'Sam', owner.id); + const b = await membersSvc.createGuest(trip.id, 'Sam', owner.id); // both keep the plain display name; only the internal (uuid) username differs expect(a.member.username).toBe('Sam'); expect(b.member.username).toBe('Sam'); @@ -669,26 +669,26 @@ describe('guest members (#1362)', () => { expect(usernames[0].username).not.toBe(usernames[1].username); }); - it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => { + it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const otherTrip = createTrip(testDb, other.id); const trip = createTrip(testDb, owner.id); - const { member } = membersSvc.createGuest(trip.id, 'Bob', owner.id); + const { member } = await membersSvc.createGuest(trip.id, 'Bob', owner.id); - expect(membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true); + expect(await membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true); expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert'); // A real user cannot be renamed through the guest path… - expect(membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); + expect(await membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); // …and a guest cannot be renamed from a different trip. - expect(membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); + expect(await membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); }); it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const { member } = membersSvc.createGuest(trip.id, 'Carol', owner.id); + const { member } = await membersSvc.createGuest(trip.id, 'Carol', owner.id); // Real members are not deletable via the guest path. expect(await membersSvc.deleteGuest(trip.id, owner.id)).toBe(false); @@ -698,15 +698,15 @@ describe('guest members (#1362)', () => { expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); }); - it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => { + it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const { member } = membersSvc.createGuest(trip.id, 'Dora', owner.id); + const { member } = await membersSvc.createGuest(trip.id, 'Dora', owner.id); // The synthetic username/email must not resolve through the invite box. - expect(() => membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found'); + await expect(membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).rejects.toThrow('User not found'); // Ownership can never be handed to a guest. - expect(() => membersSvc.transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest'); + await expect(membersSvc.transferOwnership(trip.id, member.id, owner.id)).rejects.toThrow('Cannot transfer ownership to a guest'); }); }); @@ -1113,22 +1113,22 @@ describe('folded quirk branches', () => { expect(getDays(trip.id)).toHaveLength(7); }); - it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => { + it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', async () => { const { user: owner } = createUser(testDb); const { user: invitee } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: 'Joinable' }); - const result = membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id); + const result = await membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id); expect(result.member.id).toBe(invitee.id); expect(result.tripTitle).toBe('Joinable'); expect(result.targetUserId).toBe(invitee.id); // Duplicate + owner + missing identifier reject with the byte-identical errors. - expect(() => membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).toThrow('User already has access'); - expect(() => membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).toThrow('Trip owner is already a member'); - expect(() => membersSvc.addMember(trip.id, '', owner.id, owner.id)).toThrow('Email or username required'); + await expect(membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).rejects.toThrow('User already has access'); + await expect(membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).rejects.toThrow('Trip owner is already a member'); + await expect(membersSvc.addMember(trip.id, '', owner.id, owner.id)).rejects.toThrow('Email or username required'); - membersSvc.removeMember(trip.id, invitee.id); + await membersSvc.removeMember(trip.id, invitee.id); expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined(); }); @@ -1248,7 +1248,7 @@ describe('quirk fixes', () => { it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const { member: guest } = membersSvc.createGuest(trip.id, 'Gia', owner.id); + const { member: guest } = await membersSvc.createGuest(trip.id, 'Gia', owner.id); const item = await budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] }); const broken = await failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1'); @@ -1260,11 +1260,11 @@ describe('quirk fixes', () => { expect(row.persons).toBe(2); }); - it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', () => { + it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', async () => { const { user: owner } = createUser(testDb, { username: 'owner-handle' }); testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id); const trip = createTrip(testDb, owner.id); - const { owner: row } = membersSvc.listMembers(trip.id, owner.id); + const { owner: row } = await membersSvc.listMembers(trip.id, owner.id); expect(row.username).toBe('Olive Displayed'); }); }); diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 60adc2203..50d18c469 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -77,7 +77,7 @@ beforeAll(async () => { new QueryHelpersService(dbs), new UnsplashService(dbs, runtimeEnv, makeStorageFixture('').storage), photoCache, - new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)), makeStorageFixture('').storage, await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection), );