diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts index 5e146bc93..04cc2b2bd 100644 --- a/server/src/nest/journey/journal.rpc.ts +++ b/server/src/nest/journey/journal.rpc.ts @@ -175,7 +175,7 @@ export class JournalRpc { // gallery entry aimed at bytes that never arrived. await this.storage.put('journey', filename, Readable.from(buf), { contentType: MIME_BY_EXT[ext] ?? 'image/jpeg' }); - const photo = this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption); + const photo = await this.journey.addPhoto(entryId, userId, `journey/${filename}`, undefined, input.caption); if (!photo) { // Nothing references the object now, and nothing ever would. await this.storage.delete('journey', filename).catch(() => {}); diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index a150c0ceb..be1c7eb85 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -1552,19 +1552,19 @@ export class JourneyDomainService { * the entry route could not, which is why a video dropped on an entry came * back as a 400 (issue #2341). */ - addPhoto( + async addPhoto( entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string, media?: { mediaType?: string; durationMs?: number | null }, - ): JourneyPhoto | 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; - const trekPhotoId = this.photos.getOrCreateLocal( + const trekPhotoId = await this.photos.getOrCreateLocal( filePath, thumbnailPath, null, @@ -1578,7 +1578,7 @@ export class JourneyDomainService { return result; } - addProviderPhoto( + async addProviderPhoto( entryId: number, userId: number, provider: string, @@ -1586,12 +1586,12 @@ export class JourneyDomainService { caption?: string, passphrase?: string, mediaType: string = 'image', - ): JourneyPhoto | 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; - const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); + const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); // skip if this photo is already linked to this entry const alreadyLinked = this.db @@ -1629,11 +1629,11 @@ export class JourneyDomainService { } // Upload photos to the journey gallery only (no entry association). - uploadGalleryPhotos( + async uploadGalleryPhotos( journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], - ): JourneyPhoto[] { + ): Promise { if (!this.canEdit(journeyId, userId)) return []; const results: any[] = []; const now = this.ts(); @@ -1643,7 +1643,7 @@ export class JourneyDomainService { let nextOrder = (maxOrderRow?.m ?? -1) + 1; for (const f of filePaths) { - const trekPhotoId = this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); + const trekPhotoId = await this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); this.db.prepare( ` INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) @@ -1659,7 +1659,7 @@ export class JourneyDomainService { } // Add a provider photo to the gallery only (no entry link). - addProviderPhotoToGallery( + async addProviderPhotoToGallery( journeyId: number, userId: number, provider: string, @@ -1667,9 +1667,9 @@ export class JourneyDomainService { caption?: string, passphrase?: string, mediaType: string = 'image', - ): any | null { + ): Promise { if (!this.canEdit(journeyId, userId)) return null; - const trekPhotoId = this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); + const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))(); return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null; } @@ -1687,10 +1687,10 @@ export class JourneyDomainService { } // Hard-delete a gallery photo (removes from all entries and the gallery). - deleteGalleryPhoto( + async deleteGalleryPhoto( journeyPhotoId: number, userId: number, - ): { photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null { + ): Promise<{ photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null> { const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as | { id: number; journey_id: number; photo_id: number } | undefined; @@ -1703,18 +1703,18 @@ export class JourneyDomainService { // cascade on journey_entry_photos.journey_photo_id handles junction cleanup this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId); - this.photos.deleteIfOrphan(row.photo_id); + await this.photos.deleteIfOrphan(row.photo_id); return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null }; } - setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { + async setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { // photoId = journey_photos.id (gallery row); look up the trek_photo_id const jp = this.db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as | { photo_id: number } | undefined; if (!jp) return; - this.photos.setProvider(jp.photo_id, provider, assetId, ownerId); + await this.photos.setProvider(jp.photo_id, provider, assetId, ownerId); // also denorm on gallery row for fast reads this.db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run( provider, @@ -1752,10 +1752,10 @@ export class JourneyDomainService { } // deletePhoto: hard-delete (backwards compat name used by old route). - deletePhoto( + async deletePhoto( photoId: number, userId: number, - ): { id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null { + ): Promise<{ id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null> { const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as | { id: number; journey_id: number; photo_id: number } | undefined; @@ -1767,7 +1767,7 @@ export class JourneyDomainService { | undefined; this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); - this.photos.deleteIfOrphan(row.photo_id); + await this.photos.deleteIfOrphan(row.photo_id); return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null, journey_id: row.journey_id }; } diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 09527a102..c39fba457 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -251,7 +251,7 @@ export class JourneyController { const refused: Express.Multer.File[] = []; for (const file of files) { const relativePath = `journey/${file.filename}`; - const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined); + const photo = await this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption as string | undefined); if (!photo) { // No row points at this file, so nothing would ever clean it up. refused.push(file); @@ -262,7 +262,7 @@ export class JourneyController { try { const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname); if (immichId) { - this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id); + await this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id); Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id }); } } catch { @@ -314,7 +314,7 @@ export class JourneyController { } await this.commitJourneyUploads(poster ? [video, poster] : [video]); const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null; - const photo = this.journey.addPhoto( + const photo = await this.journey.addPhoto( Number(entryId), user.id, `journey/${video.filename}`, @@ -332,22 +332,23 @@ export class JourneyController { } @Post('entries/:entryId/provider-photos') - providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) { + async providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) { const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; if (Array.isArray(body.asset_ids) && body.provider) { const added: unknown[] = []; - body.asset_ids.forEach((id, i) => { + for (let i = 0; i < body.asset_ids.length; i++) { + const id = body.asset_ids[i]; const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image'; - const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt); + const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt); if (photo) added.push(photo); - }); + } this.backfillCapture(added, user.id); return { photos: added, added: added.length }; } if (!body.provider || !body.asset_id) { throw new HttpException({ error: 'provider and asset_id required' }, 400); } - const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image'); + const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image'); if (!photo) { throw new HttpException({ error: 'Not allowed or duplicate' }, 403); } @@ -387,7 +388,7 @@ export class JourneyController { @Delete('photos/:photoId') async deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) { - const photo = this.journey.deletePhoto(Number(photoId), user.id); + const photo = await this.journey.deletePhoto(Number(photoId), user.id); if (!photo) { throw new HttpException({ error: 'Photo not found' }, 404); } @@ -432,7 +433,7 @@ export class JourneyController { } await this.commitJourneyUploads(files); const filePaths = files.map((f) => ({ path: `journey/${f.filename}` })); - const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths); + const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths); if (!photos.length) { await this.discardJourneyUploads(files); throw new HttpException({ error: 'Not allowed' }, 403); @@ -471,7 +472,7 @@ export class JourneyController { } await this.commitJourneyUploads(poster ? [video, poster] : [video]); const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null; - const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, [{ + const photos = await this.journey.uploadGalleryPhotos(Number(id), user.id, [{ path: `journey/${video.filename}`, thumbnail: poster ? `journey/${poster.filename}` : undefined, mediaType: 'video', @@ -489,22 +490,23 @@ export class JourneyController { } @Post(':id/gallery/provider-photos') - galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) { + async galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) { const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; if (Array.isArray(body.asset_ids) && body.provider) { const added: unknown[] = []; - body.asset_ids.forEach((aid, i) => { + for (let i = 0; i < body.asset_ids.length; i++) { + const aid = body.asset_ids[i]; const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image'; - const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt); + const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt); if (photo) added.push(photo); - }); + } this.backfillCapture(added, user.id); return { photos: added, added: added.length }; } if (!body.provider || !body.asset_id) { throw new HttpException({ error: 'provider and asset_id required' }, 400); } - const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image'); + const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image'); if (!photo) { throw new HttpException({ error: 'Not allowed or duplicate' }, 403); } @@ -515,7 +517,7 @@ export class JourneyController { @Delete(':id/gallery/:journeyPhotoId') @HttpCode(204) async deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): Promise { - const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id); + const photo = await this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id); if (!photo) { throw new HttpException({ error: 'Photo not found or not allowed' }, 404); } diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 451d5f113..4aec07bc4 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -562,13 +562,14 @@ export class JourneyMcp { } const photos: unknown[] = []; - asset_ids.forEach((assetId, i) => { + for (let i = 0; i < asset_ids.length; i++) { + const assetId = asset_ids[i]; const mediaType = media_types?.[i] === 'video' ? 'video' : 'image'; const photo = entryId === undefined - ? this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType) - : this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType); + ? await this.journey.addProviderPhotoToGallery(journeyId, ctx.userId, provider, assetId, undefined, passphrase, mediaType) + : await this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType); if (photo) photos.push(photo); - }); + } // Detached, exactly as the REST routes schedule it: the provider is asked // when and where each photo was taken, and without that answer an attached diff --git a/server/src/nest/memories/immich.controller.ts b/server/src/nest/memories/immich.controller.ts index 4c8c694af..b2c51a7b4 100644 --- a/server/src/nest/memories/immich.controller.ts +++ b/server/src/nest/memories/immich.controller.ts @@ -42,7 +42,7 @@ export class ImmichMemoriesController { return; } if (typeof auto_upload === 'boolean') { - this.memories.immichSetAutoUpload(user.id, auto_upload); + await this.memories.immichSetAutoUpload(user.id, auto_upload); } if (result.warning) { res.json({ success: true, warning: result.warning }); @@ -102,7 +102,7 @@ export class ImmichMemoriesController { res.status(400).json({ error: 'Invalid asset ID' }); return; } - if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) { res.status(403).json({ error: 'Forbidden' }); return; } @@ -126,7 +126,7 @@ export class ImmichMemoriesController { res.status(400).json({ error: 'Invalid asset ID' }); return; } - if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) { res.status(403).json({ error: 'Forbidden' }); return; } @@ -145,7 +145,7 @@ export class ImmichMemoriesController { res.status(400).json({ error: 'Invalid asset ID' }); return; } - if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich'))) { res.status(403).json({ error: 'Forbidden' }); return; } diff --git a/server/src/nest/memories/immich.service.ts b/server/src/nest/memories/immich.service.ts index 264cf63a3..d70771079 100644 --- a/server/src/nest/memories/immich.service.ts +++ b/server/src/nest/memories/immich.service.ts @@ -40,7 +40,7 @@ export class ImmichService { private readonly storage: StorageService, ) {} - getImmichCredentials(userId: number) { + async getImmichCredentials(userId: number) { const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; if (!user?.immich_url || !user?.immich_api_key) return null; const apiKey = decrypt_api_key(user.immich_api_key); @@ -71,8 +71,8 @@ export class ImmichService { // ── Connection Settings ──────────────────────────────────────────────────── - getConnectionSettings(userId: number) { - const creds = this.getImmichCredentials(userId); + async getConnectionSettings(userId: number) { + const creds = await this.getImmichCredentials(userId); const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; return { immich_url: creds?.immich_url || '', @@ -81,7 +81,7 @@ export class ImmichService { }; } - setImmichAutoUpload(userId: number, enabled: boolean): void { + async setImmichAutoUpload(userId: number, enabled: boolean): Promise { this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId); } @@ -169,7 +169,7 @@ export class ImmichService { async getConnectionStatus( userId: number ): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { connected: false, error: 'Not configured' }; try { const resp = await safeFetch(`${creds.immich_url}/api/users/me`, { @@ -189,7 +189,7 @@ export class ImmichService { async browseTimeline( userId: number ): Promise<{ buckets?: any; error?: string; status?: number }> { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { @@ -271,7 +271,7 @@ export class ImmichService { page: number = 1, size: number = 50, ): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; // Once a day filter is in play the raw pages and the answered pages stop @@ -347,7 +347,7 @@ export class ImmichService { ownerUserId?: number ): Promise<{ data?: any; error?: string; status?: number }> { const effectiveUserId = ownerUserId ?? userId; - const creds = this.getImmichCredentials(effectiveUserId); + const creds = await this.getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; try { @@ -390,7 +390,7 @@ export class ImmichService { ownerUserId?: number ): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { const effectiveUserId = ownerUserId ?? userId; - const creds = this.getImmichCredentials(effectiveUserId); + const creds = await this.getImmichCredentials(effectiveUserId); if (!creds) return { error: 'Not found', status: 404 }; const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; @@ -430,7 +430,7 @@ export class ImmichService { opts?: { mediaType?: string | null; range?: string }, ): Promise { const effectiveUserId = ownerUserId ?? userId; - const creds = this.getImmichCredentials(effectiveUserId); + const creds = await this.getImmichCredentials(effectiveUserId); if (!creds) { handleServiceResult(response, fail('Not found', 404)); return; @@ -467,7 +467,7 @@ export class ImmichService { async listAlbums( userId: number ): Promise<{ albums?: any[]; error?: string; status?: number }> { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { @@ -591,7 +591,7 @@ export class ImmichService { userId: number, albumId: string, ): Promise<{ assets?: any[]; error?: string; status?: number }> { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { @@ -639,10 +639,10 @@ export class ImmichService { linkId: string, userId: number, ): Promise<{ selection: Selection; total: number } | { error: string; status: number }> { - const response = this.access.getAlbumIdFromLink(tripId, linkId, userId); + const response = await this.access.getAlbumIdFromLink(tripId, linkId, userId); if (!response.success) return { error: 'Album link not found', status: 404 }; - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { @@ -664,7 +664,7 @@ export class ImmichService { // ── Upload to Immich ────────────────────────────────────────────────────── async uploadToImmich(userId: number, filePath: string, fileName: string): Promise { - const creds = this.getImmichCredentials(userId); + const creds = await this.getImmichCredentials(userId); if (!creds) return null; // Journey uploads store the uploads-relative 'journey/' path; only diff --git a/server/src/nest/memories/memories-access.service.ts b/server/src/nest/memories/memories-access.service.ts index 947246670..a99a3bee1 100644 --- a/server/src/nest/memories/memories-access.service.ts +++ b/server/src/nest/memories/memories-access.service.ts @@ -15,7 +15,7 @@ import { fail, success, type ServiceResult } from './memories.helpers'; export class MemoriesAccessService { constructor(private readonly db: DatabaseService) {} - canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise { if (requestingUserId === ownerUserId) { return true; } @@ -63,7 +63,7 @@ export class MemoriesAccessService { // ── Unified photo access check (trek_photos based) ────────────────────── - canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean { + async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise { const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId); if (!photo) return false; @@ -107,7 +107,7 @@ export class MemoriesAccessService { // ── Album link syncing ────────────────────────────────────────────────── - getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult { + async getAlbumIdFromLink(tripId: string, linkId: string, userId: number): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) return fail('Trip not found or access denied', 404); @@ -123,7 +123,7 @@ export class MemoriesAccessService { } } - getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> { + async getAlbumLinkForSync(tripId: string, linkId: string, userId: number): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) return fail('Trip not found or access denied', 404); @@ -142,7 +142,7 @@ export class MemoriesAccessService { } } - updateSyncTimeForAlbumLink(linkId: string): void { + async updateSyncTimeForAlbumLink(linkId: string): Promise { this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId); } } diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts index 21b13eab4..26a348121 100644 --- a/server/src/nest/memories/memories.service.ts +++ b/server/src/nest/memories/memories.service.ts @@ -25,7 +25,7 @@ export class MemoriesService { ) {} // ── Access check (reused by both provider asset routes) ────────────────── - canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise { return this.access.canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider); } @@ -71,8 +71,8 @@ export class MemoriesService { return this.immich.saveImmichSettings(userId, immichUrl, immichApiKey, clientIp); } - immichSetAutoUpload(userId: number, enabled: boolean): void { - this.immich.setImmichAutoUpload(userId, enabled); + immichSetAutoUpload(userId: number, enabled: boolean): Promise { + return this.immich.setImmichAutoUpload(userId, enabled); } immichGetConnectionStatus(userId: number) { diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index b7894d6fa..b92acc2c8 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -35,7 +35,7 @@ export class PhotoCaptureBackfillService { async run(trekPhotoIds: number[], userId: number): Promise { for (const id of trekPhotoIds) { try { - const photo = this.photos.resolve(id); + const photo = await this.photos.resolve(id); // A row that already knows both has nothing to gain, and a provider call // per photo is the expensive part of an album import. if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue; @@ -44,14 +44,14 @@ export class PhotoCaptureBackfillService { // getPhotoInfo would only hand back what the DB row already says. if (photo.provider === 'local') { const meta = await this.readLocalExif(photo.file_path); - if (meta) this.photos.recordCaptureMetadata(id, meta); + if (meta) await this.photos.recordCaptureMetadata(id, meta); continue; } const info = await this.resolver.getPhotoInfo(userId, id); if (!info.success) continue; - this.photos.recordCaptureMetadata(id, { + await this.photos.recordCaptureMetadata(id, { takenAt: info.data.takenAt ?? null, lat: info.data.lat ?? null, lng: info.data.lng ?? null, diff --git a/server/src/nest/memories/photo-resolver.service.ts b/server/src/nest/memories/photo-resolver.service.ts index 071bf3252..f96d125bb 100644 --- a/server/src/nest/memories/photo-resolver.service.ts +++ b/server/src/nest/memories/photo-resolver.service.ts @@ -76,7 +76,7 @@ export class PhotoResolverService { kind: 'thumbnail' | 'original', range?: string, ): Promise { - const photo = this.photos.resolve(photoId); + const photo = await this.photos.resolve(photoId); if (!photo) { res.status(404).json({ error: 'Photo not found' }); return; @@ -92,7 +92,7 @@ export class PhotoResolverService { const result = await this.thumbnails.ensureLocalThumbnail(photo.file_path); if (result) { thumbRel = result.thumbnailRelPath; - this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height); + await this.photos.recordLocalThumbnail(photo.id, thumbRel, result.width, result.height); } } if (thumbRel) { @@ -169,7 +169,7 @@ export class PhotoResolverService { userId: number, photoId: number, ): Promise> { - const photo = this.photos.resolve(photoId); + const photo = await this.photos.resolve(photoId); if (!photo) return fail('Photo not found', 404); // Local rows answer from the row itself — nothing to ask. diff --git a/server/src/nest/memories/synology.controller.ts b/server/src/nest/memories/synology.controller.ts index 707d2ae78..20cf5577f 100644 --- a/server/src/nest/memories/synology.controller.ts +++ b/server/src/nest/memories/synology.controller.ts @@ -148,7 +148,7 @@ export class SynologyMemoriesController { @Res() res: Response, ): Promise { const passphrase = passphraseRaw ? String(passphraseRaw) : undefined; - if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) { this.handle(res, fail("You don't have access to this photo", 403)); } else { this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase)); @@ -176,7 +176,7 @@ export class SynologyMemoriesController { return; } - if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + if (!(await this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos'))) { this.handle(res, fail("You don't have access to this photo", 403)); } else { await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase); diff --git a/server/src/nest/memories/synology.service.ts b/server/src/nest/memories/synology.service.ts index 84a3c918e..e6b237a60 100644 --- a/server/src/nest/memories/synology.service.ts +++ b/server/src/nest/memories/synology.service.ts @@ -134,7 +134,7 @@ export class SynologyService { private readonly notifications: NotificationsService, ) {} - private _readSynologyUser(userId: number, columns: string[]): ServiceResult { + private async _readSynologyUser(userId: number, columns: string[]): Promise> { try { const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined; @@ -153,8 +153,8 @@ export class SynologyService { } } - private _getSynologyCredentials(userId: number): ServiceResult { - const user = this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']); + private async _getSynologyCredentials(userId: number): Promise> { + const user = await this._readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']); if (!user.success) return user as ServiceResult; if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400); const password = decrypt_api_key(user.data.synology_password); @@ -251,7 +251,7 @@ export class SynologyService { } private async _requestSynologyApi(userId: number, params: ApiCallParams): Promise> { - const creds = this._getSynologyCredentials(userId); + const creds = await this._getSynologyCredentials(userId); if (!creds.success) { return creds as ServiceResult; } @@ -266,7 +266,7 @@ export class SynologyService { const result = await this._fetchSynologyJson(creds.data.synology_url, body, skipSsl); // 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid if ('error' in result && [106, 107, 119].includes(result.error.status)) { - this._clearSynologySID(userId); + await this._clearSynologySID(userId); const retrySession = await this._getSynologySession(userId); if (!retrySession.success || !retrySession.data) { return retrySession as ServiceResult; @@ -305,11 +305,11 @@ export class SynologyService { } - private _clearSynologySID(userId: number): void { + private async _clearSynologySID(userId: number): Promise { this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId); } - private _clearSynologySession(userId: number): void { + private async _clearSynologySession(userId: number): Promise { this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId); } @@ -322,15 +322,15 @@ export class SynologyService { } private async _getSynologySession(userId: number): Promise> { - const cached = this._readSynologyUser(userId, ['synology_sid', 'synology_did']); + const cached = await this._readSynologyUser(userId, ['synology_sid', 'synology_did']); if (cached.success && cached.data?.synology_sid) { const decryptedSid = decrypt_api_key(cached.data.synology_sid); if (decryptedSid) return success(decryptedSid); // Decryption failed (e.g. key rotation) — clear the stale SID and re-login - this._clearSynologySID(userId); + await this._clearSynologySID(userId); } - const creds = this._getSynologyCredentials(userId); + const creds = await this._getSynologyCredentials(userId); if (!creds.success) { return creds as ServiceResult; } @@ -354,7 +354,7 @@ export class SynologyService { } async getSynologySettings(userId: number): Promise> { - const creds = this._getSynologyCredentials(userId); + const creds = await this._getSynologyCredentials(userId); if (!creds.success) return creds as ServiceResult; const session = await this._getSynologySession(userId); return success({ @@ -372,7 +372,7 @@ export class SynologyService { return fail(ssrf.error, 400); } - const result = this._readSynologyUser(userId, ['synology_password']) + const result = await this._readSynologyUser(userId, ['synology_password']) if (!result.success) return result as ServiceResult; const existingEncryptedPassword = result.data?.synology_password || null; @@ -383,12 +383,12 @@ export class SynologyService { // Only invalidate the session when the account itself changes (different URL or username). // If the user just tested the connection, testSynologyConnection already stored a fresh // sid + did — clearing them here would force an unnecessary re-login that may fail (MFA). - const existing = this._readSynologyUser(userId, ['synology_url', 'synology_username']); + const existing = await this._readSynologyUser(userId, ['synology_url', 'synology_username']); const urlChanged = existing.success && existing.data.synology_url !== synologyUrl; const userChanged = existing.success && existing.data.synology_username !== synologyUsername; const sessionCleared = urlChanged || userChanged; if (sessionCleared) { - this._clearSynologySession(userId); + await this._clearSynologySession(userId); // Fire-and-forget like unifiedService's photos_shared send — without the // .catch an in-app insert failure became an unhandled rejection. // Injected, not imported from the old notifications bridge — that module built @@ -537,7 +537,7 @@ export class SynologyService { * immichService for why the trip-side write lives in the unified service now. */ async collectSynologyAlbumSelection(userId: number, tripId: string, linkId: string): Promise> { - const response = this.access.getAlbumLinkForSync(tripId, linkId, userId); + const response = await this.access.getAlbumLinkForSync(tripId, linkId, userId); if (!response.success) return response as ServiceResult<{ selection: Selection; total: number }>; const { albumId, passphrase } = response.data; @@ -654,7 +654,7 @@ export class SynologyService { const parsedId = this._splitPackedSynologyId(photoId); if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; - const synology_credentials = this._getSynologyCredentials(targetUserId); + const synology_credentials = await this._getSynologyCredentials(targetUserId); if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; const sid = await this._getSynologySession(targetUserId); @@ -704,7 +704,7 @@ export class SynologyService { return; } - const synology_credentials = this._getSynologyCredentials(targetUserId); + const synology_credentials = await this._getSynologyCredentials(targetUserId); if (!synology_credentials.success) { handleServiceResult(response, synology_credentials); return; diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts index d029f9bc1..e298c167d 100644 --- a/server/src/nest/memories/unified-memories.service.ts +++ b/server/src/nest/memories/unified-memories.service.ts @@ -17,6 +17,7 @@ import { type SyncAlbumResult, } from './memories.helpers'; import { NotificationsService } from '../notifications/notifications.service'; +import { UnitOfWork } from '../database/unit-of-work'; /** * The provider-agnostic trip photo surface: which photos a trip shows, which @@ -34,6 +35,7 @@ export class UnifiedMemoriesService { private readonly access: MemoriesAccessService, private readonly notifications: NotificationsService, private readonly addons: AddonsService, + private readonly uow: UnitOfWork, ) {} private async _providers(): Promise> { @@ -139,7 +141,7 @@ export class UnifiedMemoriesService { return providerResult as ServiceResult; } try { - const photoId = this.photos.getOrCreate(provider, assetId, userId, passphrase); + const photoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase); const result = this.db.prepare( 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)' ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null); @@ -221,12 +223,12 @@ export class UnifiedMemoriesService { } } - removeTripPhoto( + async removeTripPhoto( tripId: string, userId: number, photoId: number, sid?: string, - ): ServiceResult { + ): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); @@ -240,7 +242,7 @@ export class UnifiedMemoriesService { AND photo_id = ? `).run(tripId, userId, photoId); - this.photos.deleteIfOrphan(photoId); + await this.photos.deleteIfOrphan(photoId); broadcast(tripId, 'memories:updated', { userId }, sid); return success(true); @@ -291,7 +293,7 @@ export class UnifiedMemoriesService { } } - removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult { + async removeAlbumLink(tripId: string, linkId: string, userId: number): Promise> { const access = this.db.canAccessTrip(tripId, userId); if (!access) { return fail('Trip not found or access denied', 404); @@ -301,7 +303,7 @@ export class UnifiedMemoriesService { const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') .all(tripId, linkId) as Array<{ photo_id: number }>; - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?') .run(tripId, linkId); this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') @@ -309,7 +311,7 @@ export class UnifiedMemoriesService { }); for (const { photo_id } of linkedPhotos) { - this.photos.deleteIfOrphan(photo_id); + await this.photos.deleteIfOrphan(photo_id); } return success(true); @@ -362,7 +364,7 @@ export class UnifiedMemoriesService { const result = await this.addTripPhotos(tripId, userId, true, [collected.selection], sid, linkId); if ('error' in result) return { error: result.error.message, status: result.error.status }; - this.access.updateSyncTimeForAlbumLink(linkId); + await this.access.updateSyncTimeForAlbumLink(linkId); return { success: true, added: result.data.added, total: collected.total }; } @@ -379,7 +381,7 @@ export class UnifiedMemoriesService { const result = await this.addTripPhotos(tripId, userId, true, [collected.data.selection], sid, linkId); if (!result.success) return result as ServiceResult; - this.access.updateSyncTimeForAlbumLink(linkId); + await this.access.updateSyncTimeForAlbumLink(linkId); return success({ added: result.data.added, total: collected.data.total }); } diff --git a/server/src/nest/memories/unified.controller.ts b/server/src/nest/memories/unified.controller.ts index f310a3bb2..99e9a3fc8 100644 --- a/server/src/nest/memories/unified.controller.ts +++ b/server/src/nest/memories/unified.controller.ts @@ -72,7 +72,7 @@ export class UnifiedMemoriesController { @Body() body: RemoveTripPhotoDto, @Res() res: Response, ): Promise { - const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id)); + const result = await this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id)); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); return; @@ -108,13 +108,13 @@ export class UnifiedMemoriesController { } @Delete('trips/:tripId/album-links/:linkId') - removeAlbumLink( + async removeAlbumLink( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('linkId') linkId: string, @Res() res: Response, - ): void { - const result = this.memories.removeAlbumLink(tripId, linkId, user.id); + ): Promise { + const result = await this.memories.removeAlbumLink(tripId, linkId, user.id); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); return; diff --git a/server/src/nest/packing/admin-packing-templates.controller.ts b/server/src/nest/packing/admin-packing-templates.controller.ts index 9e2c0c6d4..b60cd8ff5 100644 --- a/server/src/nest/packing/admin-packing-templates.controller.ts +++ b/server/src/nest/packing/admin-packing-templates.controller.ts @@ -39,19 +39,19 @@ export class AdminPackingTemplatesController { ) {} @Get() - list() { - return { templates: this.packing.listPackingTemplates() }; + async list() { + return { templates: await this.packing.listPackingTemplates() }; } @Get(':id') - get(@Param('id') id: string) { - return ok(this.packing.getPackingTemplate(id)); + async get(@Param('id') id: string) { + return ok(await this.packing.getPackingTemplate(id)); } @Post() @HttpCode(201) async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) { - const result = ok(this.packing.createPackingTemplate(body.name, user.id)); + const result = ok(await this.packing.createPackingTemplate(body.name, user.id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_create', @@ -63,13 +63,13 @@ export class AdminPackingTemplatesController { } @Put(':id') - update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) { - return ok(this.packing.updatePackingTemplate(id, body)); + async update(@Param('id') id: string, @Body() body: AdminTemplateNameDto) { + return ok(await this.packing.updatePackingTemplate(id, body)); } @Delete(':id') async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - const result = ok(this.packing.deletePackingTemplate(id)); + const result = ok(await this.packing.deletePackingTemplate(id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.packing_template_delete', @@ -82,35 +82,35 @@ export class AdminPackingTemplatesController { @Post(':id/categories') @HttpCode(201) - createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) { - return ok(this.packing.createTemplateCategory(id, body.name)); + async createCategory(@Param('id') id: string, @Body() body: AdminTemplateNameDto) { + return ok(await this.packing.createTemplateCategory(id, body.name)); } @Put(':templateId/categories/:catId') - updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) { - return ok(this.packing.updateTemplateCategory(templateId, catId, body)); + async updateCategory(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) { + return ok(await this.packing.updateTemplateCategory(templateId, catId, body)); } @Delete(':templateId/categories/:catId') - deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) { - ok(this.packing.deleteTemplateCategory(templateId, catId)); + async deleteCategory(@Param('templateId') templateId: string, @Param('catId') catId: string) { + ok(await this.packing.deleteTemplateCategory(templateId, catId)); return { success: true }; } @Post(':templateId/categories/:catId/items') @HttpCode(201) - createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) { - return ok(this.packing.createTemplateItem(templateId, catId, body.name)); + async createItem(@Param('templateId') templateId: string, @Param('catId') catId: string, @Body() body: AdminTemplateNameDto) { + return ok(await this.packing.createTemplateItem(templateId, catId, body.name)); } @Put(':templateId/items/:itemId') - updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) { - return ok(this.packing.updateTemplateItem(templateId, itemId, body)); + async updateItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string, @Body() body: AdminTemplateNameDto) { + return ok(await this.packing.updateTemplateItem(templateId, itemId, body)); } @Delete(':templateId/items/:itemId') - deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) { - ok(this.packing.deleteTemplateItem(templateId, itemId)); + async deleteItem(@Param('templateId') templateId: string, @Param('itemId') itemId: string) { + ok(await this.packing.deleteTemplateItem(templateId, itemId)); return { success: true }; } } diff --git a/server/src/nest/packing/packing.controller.ts b/server/src/nest/packing/packing.controller.ts index fd9485c5c..1bef7788e 100644 --- a/server/src/nest/packing/packing.controller.ts +++ b/server/src/nest/packing/packing.controller.ts @@ -64,14 +64,14 @@ export class PackingController { @Get() - list(@CurrentUser() user: User, @Param('tripId') tripId: string) { + async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { // Pass the viewer so private items (#858) owned by other members are hidden. - return { items: this.packing.listItems(tripId, user.id) }; + return { items: await this.packing.listItems(tripId, user.id) }; } @RequirePermission('packing_edit') @Post('import') - importItems( + async importItems( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingImportDto, @@ -81,7 +81,7 @@ export class PackingController { if (body.items.length === 0) { throw new HttpException({ error: 'items must be a non-empty array' }, 400); } - const created = this.packing.bulkImport(tripId, body.items, user.id); + const created = await this.packing.bulkImport(tripId, body.items, user.id); for (const item of created) { this.packing.broadcastItem(tripId, 'packing:created', { item }, item, socketId); } @@ -91,14 +91,14 @@ export class PackingController { @RequirePermission('packing_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingCreateItemDto, @Headers('x-socket-id') socketId?: string, ) { // checked arrives as boolean or legacy 0/1 — the service coerces by truthiness. - const item = this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id); + const item = await this.packing.createItem(tripId, { name: body.name, category: body.category, checked: body.checked === undefined ? undefined : !!body.checked, weight_grams: body.weight_grams, bag_id: body.bag_id, quantity: body.quantity, is_private: body.is_private, visibility: body.visibility, recipient_ids: body.recipient_ids }, user.id); // A bag referenced in the body must exist on this trip (#2154). The payload // is at fault, so 400 — the 404 'Bag not found' stays with the path routes. if (isInvalidBagRef(item)) { @@ -111,19 +111,19 @@ export class PackingController { @RequirePermission('packing_edit') @Put('reorder') - reorder( + async reorder( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingReorderDto, @Headers('x-socket-id') _socketId?: string, ) { - this.packing.reorderItems(tripId, body.orderedIds); + await this.packing.reorderItems(tripId, body.orderedIds); return { success: true }; } @RequirePermission('packing_edit') @Put(':id') - update( + async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -133,12 +133,12 @@ export class PackingController { ) { // Privacy state before the change, so a public↔private toggle (#858) can route // the broadcast correctly instead of leaking a freshly-privatized item. - const before = this.packing.getItemPrivacy(tripId, id); + const before = await this.packing.getItemPrivacy(tripId, id); const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body; // bodyKeys carries which keys the request actually provided (the presence- // sentinel protocol); the parsed body only ever holds known schema keys. // checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds. - const updated = this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id); + const updated = await this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id); if (!updated) { throw new HttpException({ error: 'Item not found' }, 404); } @@ -162,13 +162,13 @@ export class PackingController { @RequirePermission('packing_edit') @Delete(':id') - remove( + async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - const deleted = this.packing.deleteItem(tripId, id, user.id); + const deleted = await this.packing.deleteItem(tripId, id, user.id); if (!deleted) { throw new HttpException({ error: 'Item not found' }, 404); } @@ -180,14 +180,14 @@ export class PackingController { @RequirePermission('packing_edit') @Put(':id/sharing') - setSharing( + async setSharing( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: PackingSetSharingDto, @Headers('x-socket-id') socketId?: string, ) { - const updated = this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []); + const updated = await this.packing.setItemSharing(tripId, id, user.id, body.visibility, Array.isArray(body.recipient_ids) ? body.recipient_ids : []); if (!updated) { throw new HttpException({ error: 'Item not found' }, 404); } @@ -204,13 +204,13 @@ export class PackingController { @RequirePermission('packing_edit') @Post(':id/clone') @HttpCode(201) - clone( + async clone( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - const item = this.packing.cloneItem(tripId, id, user.id); + const item = await this.packing.cloneItem(tripId, id, user.id); if (!item) { throw new HttpException({ error: 'Item not found' }, 404); } @@ -222,13 +222,13 @@ export class PackingController { @RequirePermission('packing_edit') @Post(':id/contributors') - addContributor( + async addContributor( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string, ) { - const item = this.packing.addContributor(tripId, id, user.id); + const item = await this.packing.addContributor(tripId, id, user.id); if (!item) { throw new HttpException({ error: 'Item not found or not a shared list item' }, 404); } @@ -239,7 +239,7 @@ export class PackingController { @RequirePermission('packing_edit') @Delete(':id/contributors/:userId') - removeContributor( + async removeContributor( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @@ -248,7 +248,7 @@ export class PackingController { ) { // You can drop your own pledge; the owner can remove anyone's. const target = Number.parseInt(userId); - const item = this.packing.removeContributor(tripId, id, target); + const item = await this.packing.removeContributor(tripId, id, target); if (!item) { throw new HttpException({ error: 'Item not found' }, 404); } @@ -257,16 +257,16 @@ export class PackingController { } @Get('bags') - listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) { + async listBags(@CurrentUser() user: User, @Param('tripId') tripId: string) { // unassigned_weight_grams rides along so the "no bag" pile and the grand // total follow the same rule as the bags themselves (#2191) — a screen // mixing true totals with per-viewer ones would be worse than either. - return this.packing.listBagsWithWeights(tripId); + return await this.packing.listBagsWithWeights(tripId); } @RequirePermission('packing_edit') @Post('bags') - createBag( + async createBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingCreateBagDto, @@ -276,14 +276,14 @@ export class PackingController { if (!body.name.trim()) { throw new HttpException({ error: 'Name is required' }, 400); } - const bag = this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams }); + const bag = await this.packing.createBag(tripId, { name: body.name, color: body.color, weight_limit_grams: body.weight_limit_grams }); this.packing.broadcast(tripId, 'packing:bag-created', { bag }, socketId); return { bag }; } @RequirePermission('packing_edit') @Put('bags/:bagId') - updateBag( + async updateBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @@ -293,7 +293,7 @@ export class PackingController { const { name, color, weight_limit_grams, user_id } = body; // bodyKeys carries which keys the request actually provided (the presence- // sentinel protocol); the parsed body only ever holds known schema keys. - const updated = this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body)); + const updated = await this.packing.updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(body)); if (!updated) { throw new HttpException({ error: 'Bag not found' }, 404); } @@ -303,13 +303,13 @@ export class PackingController { @RequirePermission('packing_edit') @Delete('bags/:bagId') - deleteBag( + async deleteBag( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @Headers('x-socket-id') socketId?: string, ) { - if (!this.packing.deleteBag(tripId, bagId)) { + if (!(await this.packing.deleteBag(tripId, bagId))) { throw new HttpException({ error: 'Bag not found' }, 404); } this.packing.broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, socketId); @@ -320,14 +320,14 @@ export class PackingController { } @Get('templates') - listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { templates: this.packing.listTemplates() }; + async listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { templates: await this.packing.listTemplates() }; } @RequirePermission('packing_edit') @Post('apply-template/:templateId') @HttpCode(200) - applyTemplate( + async applyTemplate( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('templateId') templateId: string, @@ -335,7 +335,7 @@ export class PackingController { @Headers('x-socket-id') socketId?: string, ) { const visibility = body?.visibility === 'personal' ? 'personal' : 'common'; - const added = this.packing.applyTemplate(tripId, templateId, visibility, user.id); + const added = await this.packing.applyTemplate(tripId, templateId, visibility, user.id); if (!added) { throw new HttpException({ error: 'Template not found or empty' }, 404); } @@ -346,14 +346,14 @@ export class PackingController { @RequirePermission('packing_edit') @Put('bags/:bagId/members') - setBagMembers( + async setBagMembers( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('bagId') bagId: string, @Body() body: PackingBagMembersDto, @Headers('x-socket-id') socketId?: string, ) { - const members = this.packing.setBagMembers(tripId, bagId, body.user_ids); + const members = await this.packing.setBagMembers(tripId, bagId, body.user_ids); if (!members) { throw new HttpException({ error: 'Bag not found' }, 404); } @@ -363,7 +363,7 @@ export class PackingController { @RequirePermission('packing_edit') @Post('save-as-template') - saveAsTemplate( + async saveAsTemplate( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: PackingSaveTemplateDto, @@ -375,7 +375,7 @@ export class PackingController { if (!body.name.trim()) { throw new HttpException({ error: 'Template name is required' }, 400); } - const template = this.packing.saveAsTemplate(tripId, user.id, body.name.trim()); + const template = await this.packing.saveAsTemplate(tripId, user.id, body.name.trim()); if (!template) { throw new HttpException({ error: 'No items to save' }, 400); } @@ -383,13 +383,13 @@ export class PackingController { } @Get('category-assignees') - categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { assignees: this.packing.getCategoryAssignees(tripId) }; + async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { assignees: await this.packing.getCategoryAssignees(tripId) }; } @RequirePermission('packing_edit') @Put('category-assignees/:categoryName') - updateCategoryAssignees( + async updateCategoryAssignees( @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('categoryName') categoryName: string, @@ -397,9 +397,9 @@ export class PackingController { @Headers('x-socket-id') socketId?: string, ) { const category = decodeURIComponent(categoryName); - const rows = this.packing.updateCategoryAssignees(tripId, category, body.user_ids); + const rows = await this.packing.updateCategoryAssignees(tripId, category, body.user_ids); this.packing.broadcast(tripId, 'packing:assignees', { category, assignees: rows }, socketId); - this.packing.notifyTagged(tripId, user, category, body.user_ids); + await this.packing.notifyTagged(tripId, user, category, body.user_ids); return { assignees: rows }; } } diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index e5dc3f4c6..67643f3b0 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -80,9 +80,9 @@ export class PackingMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const item = this.packing.createItem(tripId, { + const item = await this.packing.createItem(tripId, { name, category: category || 'General', bag_id, @@ -117,9 +117,9 @@ export class PackingMcp { }) async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId); + const item = await this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId); if (!item) return errorResult('Packing item not found.'); // Scoped to the people who may see it, exactly as the REST route does // (#858, #1976). A Common item answers null here and goes to the room. @@ -140,9 +140,9 @@ export class PackingMcp { }) async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId); + const deleted = await this.packing.deleteItem(tripId, itemId, ctx.userId); if (!deleted) return errorResult('Packing item not found.'); // deleteItem hands back the row it removed, so the delete can be scoped to // the same people the item was ever visible to (#1976). @@ -175,7 +175,7 @@ export class PackingMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields = { name, category, bag_id, quantity, weight_grams, is_private }; // The service reads presence from bodyKeys, so a field has to be named there @@ -184,8 +184,8 @@ export class PackingMcp { // Privacy state before the write, so a public↔private flip routes the // broadcast the way the REST route does instead of leaking a freshly // privatized item (or leaving a stale copy on everyone else's screen). - const wasPrivate = !!this.packing.getItemPrivacy(tripId, itemId)?.is_private; - const item = this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId); + const wasPrivate = !!(await this.packing.getItemPrivacy(tripId, itemId))?.is_private; + const item = await this.packing.updateItem(tripId, itemId, fields, bodyKeys, undefined, ctx.userId); if (!item) return errorResult('Packing item not found.'); // A referenced bag must exist on this trip (#2154), as on the REST route. if (isInvalidBagRef(item)) return errorResult('Bag not found.'); @@ -231,9 +231,9 @@ export class PackingMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []); + const item = await this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []); if (!item) return errorResult('Packing item not found.'); if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.'); // The viewer set just changed: drop the item from the whole room, then hand @@ -259,9 +259,9 @@ export class PackingMcp { }) async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - this.packing.reorderItems(tripId, orderedIds); + await this.packing.reorderItems(tripId, orderedIds); this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds }); return ok({ success: true }); } @@ -277,8 +277,8 @@ export class PackingMcp { access: { group: 'packing', mode: 'read' }, }) async listPackingBags({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const bags = this.packing.listBags(tripId); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const bags = await this.packing.listBags(tripId); return ok({ bags }); } @@ -297,11 +297,11 @@ export class PackingMcp { }) async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // createBag returns a bare row; hydrate with the empty members array that // listBags and the schema always carry, so the client/AI consumer matches. - const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] }; + const bag = { ...(await this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] }; this.guards.safeBroadcast(tripId, 'packing:bag-created', { bag }); return ok({ bag }); } @@ -326,7 +326,7 @@ export class PackingMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {}; const bodyKeys: string[] = []; @@ -336,10 +336,10 @@ export class PackingMcp { // an explicit null clears it. if (weight_limit_grams !== undefined) { fields.weight_limit_grams = weight_limit_grams; bodyKeys.push('weight_limit_grams'); } if (user_id !== undefined) { fields.user_id = user_id; bodyKeys.push('user_id'); } - const updated = this.packing.updateBag(tripId, bagId, fields, bodyKeys); + const updated = await this.packing.updateBag(tripId, bagId, fields, bodyKeys); if (!updated) return errorResult('Bag not found.'); // Hydrate with the members array (matches create_packing_bag, listBags, and the schema). - const bag = this.packing.listBags(tripId).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] }; + const bag = (await this.packing.listBags(tripId)).find(b => b.id === (updated as { id: number }).id) ?? { ...(updated as object), members: [] }; this.guards.safeBroadcast(tripId, 'packing:bag-updated', { bag }); return ok({ bag }); } @@ -357,9 +357,9 @@ export class PackingMcp { }) async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - this.packing.deleteBag(tripId, bagId); + await this.packing.deleteBag(tripId, bagId); // { bagId } matches the REST route and the plugin host (the legacy // registrar's { id } was the odd one out). this.guards.safeBroadcast(tripId, 'packing:bag-deleted', { bagId }); @@ -383,9 +383,9 @@ export class PackingMcp { }) async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const members = this.packing.setBagMembers(tripId, bagId, userIds); + const members = await this.packing.setBagMembers(tripId, bagId, userIds); if (!members) return errorResult('Bag not found.'); this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members }); return ok({ members }); @@ -402,8 +402,8 @@ export class PackingMcp { access: { group: 'packing', mode: 'read' }, }) async getPackingCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const assignees = this.packing.getCategoryAssignees(tripId); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const assignees = await this.packing.getCategoryAssignees(tripId); return ok({ assignees }); } @@ -421,9 +421,9 @@ export class PackingMcp { }) async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds); + const assignees = await this.packing.updateCategoryAssignees(tripId, categoryName, userIds); this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees }); return ok({ assignees }); } @@ -441,9 +441,9 @@ export class PackingMcp { }) async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const items = this.packing.applyTemplate(tripId, templateId); + const items = await this.packing.applyTemplate(tripId, templateId); if (items === null) return errorResult('Template not found.'); this.guards.safeBroadcast(tripId, 'packing:template-applied', { items }); this.packing.broadcastBagTotals(String(tripId)); @@ -461,8 +461,8 @@ export class PackingMcp { access: { group: 'packing', mode: 'read' }, }) async listPackingTemplates({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - return ok({ templates: this.packing.listTemplates() }); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + return ok({ templates: await this.packing.listTemplates() }); } @Tool({ @@ -478,11 +478,11 @@ export class PackingMcp { }) async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Templates are global; the REST route restricts saving to admins. Match it. if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); - const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName); + const template = await this.packing.saveAsTemplate(tripId, ctx.userId, templateName); if (!template) return errorResult('Nothing to save — the packing list is empty.'); return ok({ template }); } @@ -501,7 +501,7 @@ export class PackingMcp { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Templates are global; the REST route restricts management to admins. Match it. if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); - const result = this.packing.deletePackingTemplate(String(templateId)); + const result = await this.packing.deletePackingTemplate(String(templateId)); if ('error' in result) return errorResult(result.error); return ok({ success: true, name: result.name }); } @@ -529,9 +529,9 @@ export class PackingMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); - const created = this.packing.bulkImport(tripId, items, ctx.userId); + const created = await this.packing.bulkImport(tripId, items, ctx.userId); for (const item of created) { this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item)); } @@ -551,7 +551,7 @@ export class PackingMcp { }) async tripPackingResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -561,7 +561,7 @@ export class PackingMcp { }; } // Hide other members' private items (#858) from the requesting user. - const items = this.packing.listItems(id, ctx.userId); + const items = await this.packing.listItems(id, ctx.userId); return { contents: [{ uri: uri.href, @@ -581,7 +581,7 @@ export class PackingMcp { }) async tripPackingBagsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.packing.verifyTripAccess(id, ctx.userId)) { + if (id === null || !(await this.packing.verifyTripAccess(id, ctx.userId))) { return { contents: [{ uri: uri.href, @@ -590,7 +590,7 @@ export class PackingMcp { }], }; } - const bags = this.packing.listBags(id); + const bags = await this.packing.listBags(id); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/packing/packing.rpc.ts b/server/src/nest/packing/packing.rpc.ts index ea28ab89e..209d001ea 100644 --- a/server/src/nest/packing/packing.rpc.ts +++ b/server/src/nest/packing/packing.rpc.ts @@ -47,7 +47,7 @@ export class PackingRpc { const parsed = packingCreateItemRequestSchema.safeParse(params.input); if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); - const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem; + const item = await this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem; // A referenced bag must exist on this trip (#2154), as on the REST route. if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`); this.packing.emitToViewers(String(tripId), 'packing:created', { item }, item, undefined); @@ -64,9 +64,9 @@ export class PackingRpc { if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); // Read the privacy BEFORE the write, so a public/private toggle routes correctly. - const before = this.packing.getItemPrivacy(tripId, itemId); + const before = await this.packing.getItemPrivacy(tripId, itemId); const input = parsed.data as Record; - const updated = this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor); + const updated = await this.packing.updateItem(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actor); if (!updated) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`); if (isUpdateConflict(updated)) throw new BadParams('packing item was modified concurrently'); // A referenced bag must exist on this trip (#2154), as on the REST route. @@ -84,7 +84,7 @@ export class PackingRpc { const itemId = num(params.itemId, 'itemId'); const actor = this.guards.requireActor(ctx, 'packing item'); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); - const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null; + const deleted = await this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null; if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`); this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined); this.packing.broadcastBagTotals(String(tripId)); @@ -95,7 +95,7 @@ export class PackingRpc { async listBags(params: Record, ctx: PluginRpcContext): Promise { // Note the permission: the envelope really does gate this READ on the write // grant. The decorator makes the oddity visible instead of burying it. - return await this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]); + return await this.guards.tripRead(params, ctx, async () => (await this.packing.listBags(String(num(params.tripId, 'tripId')))) as unknown[]); } @PluginMethod('packing.createBag', { permission: 'db:write:packing' }) @@ -105,7 +105,7 @@ export class PackingRpc { const input = asPayload(params.input); if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required'); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); - const bag = this.packing.createBag(String(tripId), { + const bag = await this.packing.createBag(String(tripId), { name: input.name, color: typeof input.color === 'string' ? input.color : undefined, weight_limit_grams: typeof input.weight_limit_grams === 'number' ? input.weight_limit_grams : undefined, @@ -121,7 +121,7 @@ export class PackingRpc { const actor = this.guards.requireActor(ctx, 'packing bag'); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); const input = asPayload(params.input); - const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input)); + const bag = await this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input)); if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); this.realtime.broadcast(tripId, 'packing:bag-updated', { bag }, undefined); return bag; @@ -133,7 +133,7 @@ export class PackingRpc { const bagId = num(params.bagId, 'bagId'); const actor = this.guards.requireActor(ctx, 'packing bag'); await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION); - if (!this.packing.deleteBag(String(tripId), String(bagId))) { + if (!(await this.packing.deleteBag(String(tripId), String(bagId)))) { throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); } this.realtime.broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined); @@ -151,7 +151,7 @@ export class PackingRpc { // userIds sits on the params object itself, not under `input`. const raw = asPayload(params).userIds; const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : []; - const members = this.packing.setBagMembers(String(tripId), String(bagId), userIds); + const members = await this.packing.setBagMembers(String(tripId), String(bagId), userIds); if (!members) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`); this.realtime.broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined); return members; diff --git a/server/src/nest/packing/packing.service.ts b/server/src/nest/packing/packing.service.ts index 45512cb40..21914ec54 100644 --- a/server/src/nest/packing/packing.service.ts +++ b/server/src/nest/packing/packing.service.ts @@ -7,6 +7,7 @@ import type { UpdateConflict } from '../common/conflictResult'; import type { User } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { UnitOfWork } from '../database/unit-of-work'; /** Privacy fields stamped on a packing item (#858). */ type PrivacyFields = { is_private?: number; owner_id?: number | null }; @@ -60,9 +61,10 @@ export class PackingService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -168,7 +170,7 @@ export class PackingService { * items (#858 three-tier sharing). Batched so the list endpoint stays one round * of queries regardless of item count. */ - private enrichItems(items: any[]): any[] { + private async enrichItems(items: any[]): Promise { if (items.length === 0) return items; const ids = items.map(i => i.id); const placeholders = ids.map(() => '?').join(','); @@ -206,7 +208,7 @@ export class PackingService { })); } - listItems(tripId: string | number, userId?: number) { + async listItems(tripId: string | number, userId?: number) { // Three-tier visibility (#858): Common (is_private=0) is visible to everyone; // Personal/Shared (is_private=1) only to the owner (bringer) and the recipients // it was explicitly shared with. Without a userId the unfiltered list is @@ -229,12 +231,12 @@ export class PackingService { ORDER BY sort_order ASC, created_at ASC `, tripId, userId, userId); } - return this.enrichItems(rows); + return await this.enrichItems(rows); } /** Reads an item's current privacy fields (#858) before an update, so the * controller can detect a public↔private transition and route the broadcast. */ - getItemPrivacy(tripId: string | number, id: string | number): PrivacyFields | undefined { + async getItemPrivacy(tripId: string | number, id: string | number): Promise { return this.db.get('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?', id, tripId); } @@ -244,18 +246,18 @@ export class PackingService { return isPrivateFallback ? 1 : 0; } - createItem( + async createItem( tripId: string | number, data: { name: string; category?: string; checked?: boolean; quantity?: number; weight_grams?: number | null; bag_id?: number | null; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] }, ownerId?: number, ) { - if (data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) return { invalidBag: true } as const; + if (data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) return { invalidBag: true } as const; const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!; const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1)); const isPrivate = this.visibilityToPrivate(data.visibility, data.is_private); - const itemId = this.db.transaction(() => { + const itemId = await this.uow.transactional(async () => { const result = this.db.run( 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order, quantity, weight_grams, bag_id, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', tripId, data.name, data.checked ? 1 : 0, data.category || 'Other', sortOrder, qty, data.weight_grams ?? null, data.bag_id ?? null, isPrivate, ownerId ?? null @@ -264,26 +266,26 @@ export class PackingService { // "Shared with specific people" — record the recipients it covers. if (data.visibility === 'shared' && Array.isArray(data.recipient_ids)) { const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)'); - const roster = this.tripRosterIds(tripId); + const roster = await this.tripRosterIds(tripId); for (const uid of data.recipient_ids) if (uid !== ownerId && roster.has(uid)) ins.run(id, uid); } return id; }); - return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)])[0]; + return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', itemId)]))[0]; } - updateItem( + async updateItem( tripId: string | number, id: string | number, data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean }, bodyKeys: string[], ifMatch?: string, actingUserId?: number, - ): unknown | UpdateConflict | null { + ): Promise { // Was a trip-scoped lookup, which let any member with packing_edit write to // another member's restricted item (and read it back off the response). - const item = this.getItemInTrip(tripId, id, actingUserId); + const item = await this.getItemInTrip(tripId, id, actingUserId); if (!item) return null; // Optimistic concurrency (#1135): reject a stale offline overwrite. Absent @@ -294,7 +296,7 @@ export class PackingService { // A non-null bag about to be bound must belong to this trip (#2154) — the // FK alone let any member point an item at another trip's bag. - if (bodyKeys.includes('bag_id') && data.bag_id != null && !this.bagInTrip(tripId, data.bag_id)) { + if (bodyKeys.includes('bag_id') && data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) { return { invalidBag: true } as const; } @@ -332,7 +334,7 @@ export class PackingService { id ); - return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0]; + return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0]; } // ── Three-tier sharing (#858): recipients, contributors, clone ─────────────── @@ -359,7 +361,7 @@ export class PackingService { * these routes cannot be used to probe for ids. A missing actor denies too, * rather than falling through unfiltered. */ - private getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) { + private async getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) { if (actorId == null) return undefined; return this.db.get<{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; quantity: number; weight_grams: number | null; bag_id: number | null; updated_at?: string | null }>( `SELECT * FROM packing_items WHERE id = ? AND trip_id = ? AND ${PackingService.VISIBLE_TO_ACTOR}`, @@ -371,53 +373,53 @@ export class PackingService { * Re-set who a "shared with specific people" item covers, and its visibility tier. * Only the owner (bringer) may change this; a non-owner caller is rejected with null. */ - setItemSharing( + async setItemSharing( tripId: string | number, id: string | number, actingUserId: number, visibility: PackingVisibility, recipientIds: number[], ) { - const item = this.getItemInTrip(tripId, id, actingUserId); + const item = await this.getItemInTrip(tripId, id, actingUserId); if (!item) return null; // The owner controls sharing; an unowned legacy item is claimed by the actor. if (item.owner_id != null && item.owner_id !== actingUserId) return { forbidden: true as const }; - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('UPDATE packing_items SET is_private = ?, owner_id = COALESCE(owner_id, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?', this.visibilityToPrivate(visibility), actingUserId, id); this.db.run('DELETE FROM packing_item_recipients WHERE item_id = ?', id); if (visibility === 'shared') { const ins = this.db.prepare('INSERT OR IGNORE INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)'); const owner = item.owner_id ?? actingUserId; - const roster = this.tripRosterIds(tripId); + const roster = await this.tripRosterIds(tripId); for (const uid of recipientIds) if (uid !== owner && roster.has(uid)) ins.run(id, uid); } // Leaving the Common tier drops any co-contributors (they only apply to Common). if (visibility !== 'common') this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ?', id); }); - return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0]; + return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0]; } /** "I can bring that too" — adds the user as a co-contributor on a Common item. */ - addContributor(tripId: string | number, id: string | number, userId: number) { - const item = this.getItemInTrip(tripId, id, userId); + async addContributor(tripId: string | number, id: string | number, userId: number) { + const item = await this.getItemInTrip(tripId, id, userId); if (!item || item.is_private !== 0) return null; // co-contribution is a Common-list concept if (item.owner_id === userId) return null; // the bringer is already covering it this.db.run("INSERT OR IGNORE INTO packing_item_contributors (item_id, user_id, status) VALUES (?, ?, 'accepted')", id, userId); - return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0]; + return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0]; } - removeContributor(tripId: string | number, id: string | number, userId: number) { - const item = this.getItemInTrip(tripId, id, userId); + async removeContributor(tripId: string | number, id: string | number, userId: number) { + const item = await this.getItemInTrip(tripId, id, userId); if (!item) return null; this.db.run('DELETE FROM packing_item_contributors WHERE item_id = ? AND user_id = ?', id, userId); - return this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)])[0]; + return (await this.enrichItems([this.db.get('SELECT * FROM packing_items WHERE id = ?', id)]))[0]; } /** True when the bag exists AND belongs to the trip — the referenced-id rule * the FK cannot enforce (it only checks existence). */ - private bagInTrip(tripId: string | number, bagId: number): boolean { + private async bagInTrip(tripId: string | number, bagId: number): Promise { return !!this.db.get('SELECT id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId); } @@ -426,7 +428,7 @@ export class PackingService { * owns, or one they belong to. Inheriting someone else's bag would drop the copy into * their luggage and inflate their weight (#207). */ - private bagForCloner(tripId: string | number, bagId: number | null, userId: number): number | null { + private async bagForCloner(tripId: string | number, bagId: number | null, userId: number): Promise { if (bagId == null) return null; const bag = this.db.get<{ user_id: number | null }>('SELECT user_id FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId); if (!bag) return null; @@ -441,25 +443,25 @@ export class PackingService { * Weight comes along — it is a property of the thing, and re-entering it by hand for * every traveller was the whole complaint in #207. */ - cloneItem(tripId: string | number, id: string | number, userId: number) { - const item = this.getItemInTrip(tripId, id, userId); + async cloneItem(tripId: string | number, id: string | number, userId: number) { + const item = await this.getItemInTrip(tripId, id, userId); if (!item) return null; - return this.createItem(tripId, { + return await this.createItem(tripId, { name: item.name, category: item.category || undefined, quantity: item.quantity, weight_grams: item.weight_grams, - bag_id: this.bagForCloner(tripId, item.bag_id, userId), + bag_id: await this.bagForCloner(tripId, item.bag_id, userId), visibility: 'personal', }, userId); } - deleteItem(tripId: string | number, id: string | number, actingUserId?: number) { + async deleteItem(tripId: string | number, id: string | number, actingUserId?: number) { // Return the deleted row (not just a boolean) so callers can target the // delete broadcast at the owner when the item was private (#858). // Scoped to what the actor may see: trip membership alone used to be enough // to delete another member's restricted item. - const item = this.getItemInTrip(tripId, id, actingUserId); + const item = await this.getItemInTrip(tripId, id, actingUserId); if (!item) return null; this.db.run('DELETE FROM packing_items WHERE id = ?', id); @@ -468,14 +470,14 @@ export class PackingService { // ── Bulk Import ──────────────────────────────────────────────────────────── - bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) { + async bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) { const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?', tripId)!; let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const stmt = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)'); const created: any[] = []; - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const item of items) { if (!item.name?.trim()) continue; const checked = item.checked ? 1 : 0; @@ -524,7 +526,7 @@ export class PackingService { * Keyed by bag id, with the unassigned pile under `null` — the same shape the * "no bag" row on every packing surface needs. */ - private bagWeightTotals(tripId: string | number): Map { + private async bagWeightTotals(tripId: string | number): Promise> { const rows = this.db.all<{ bag_id: number | null; total: number | null }>(` SELECT bag_id, SUM(COALESCE(weight_grams, 0) * COALESCE(quantity, 1)) AS total FROM packing_items @@ -535,8 +537,8 @@ export class PackingService { } /** The weight of everything in the trip that is in no bag (#2191). */ - unassignedWeightGrams(tripId: string | number): number { - return this.bagWeightTotals(tripId).get(null) ?? 0; + async unassignedWeightGrams(tripId: string | number): Promise { + return (await this.bagWeightTotals(tripId)).get(null) ?? 0; } /** @@ -547,16 +549,16 @@ export class PackingService { * SUM…GROUP BY twice per request is not a cost worth paying for a nicer * method list. */ - listBagsWithWeights(tripId: string | number): { bags: unknown[]; unassigned_weight_grams: number } { - const totals = this.bagWeightTotals(tripId); - return { bags: this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 }; + async listBagsWithWeights(tripId: string | number): Promise<{ bags: unknown[]; unassigned_weight_grams: number }> { + const totals = await this.bagWeightTotals(tripId); + return { bags: await this.decorateBags(tripId, totals), unassigned_weight_grams: totals.get(null) ?? 0 }; } - listBags(tripId: string | number) { - return this.decorateBags(tripId, this.bagWeightTotals(tripId)); + async listBags(tripId: string | number) { + return await this.decorateBags(tripId, await this.bagWeightTotals(tripId)); } - private decorateBags(tripId: string | number, totals: Map) { + private async decorateBags(tripId: string | number, totals: Map) { const bags = this.db.all('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id', tripId); const members = this.db.all<{ bag_id: number; user_id: number; username: string; avatar: string | null }>(` SELECT bm.bag_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar @@ -583,18 +585,18 @@ export class PackingService { * "assigned to a bag", which is why the category and recipient writes below * grew up without it. */ - private tripRosterIds(tripId: string | number): Set { + private async tripRosterIds(tripId: string | number): Promise> { return this.db.rosterUserIds(tripId); } - setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { + async setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) { const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId); if (!bag) return null; - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM packing_bag_members WHERE bag_id = ?', bagId); const ins = this.db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); // Only real trip members may be bag members — never write an arbitrary account id. - const roster = this.tripRosterIds(tripId); + const roster = await this.tripRosterIds(tripId); for (const uid of userIds) if (roster.has(uid)) ins.run(bagId, uid); }); const rows = this.db.all<{ user_id: number; username: string; avatar: string | null }>(` @@ -605,7 +607,7 @@ export class PackingService { return rows.map(m => ({ ...m, avatar: avatarUrl(m) })); } - createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) { + async createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) { const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?', tripId)!; const result = this.db.run('INSERT INTO packing_bags (trip_id, name, color, sort_order, weight_limit_grams) VALUES (?, ?, ?, ?, ?)', tripId, data.name.trim(), data.color || '#6366f1', (maxOrder.max ?? -1) + 1, data.weight_limit_grams ?? null @@ -613,7 +615,7 @@ export class PackingService { return this.db.get('SELECT * FROM packing_bags WHERE id = ?', result.lastInsertRowid); } - updateBag( + async updateBag( tripId: string | number, bagId: string | number, data: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null }, @@ -623,7 +625,7 @@ export class PackingService { if (!bag) return null; // A bag may only be assigned to a real trip member; an off-roster id becomes unassigned. - const assignUser = data.user_id != null && this.tripRosterIds(tripId).has(data.user_id) ? data.user_id : null; + const assignUser = data.user_id != null && (await this.tripRosterIds(tripId)).has(data.user_id) ? data.user_id : null; // weight_limit_grams follows the bodyKeys presence protocol like user_id: // an omitted key leaves the limit unchanged, an explicit null clears it. this.db.run(`UPDATE packing_bags SET @@ -643,7 +645,7 @@ export class PackingService { return this.db.get('SELECT b.*, COALESCE(u.display_name, u.username) as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?', bagId); } - deleteBag(tripId: string | number, bagId: string | number): boolean { + async deleteBag(tripId: string | number, bagId: string | number): Promise { const bag = this.db.get('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?', bagId, tripId); if (!bag) return false; @@ -658,7 +660,7 @@ export class PackingService { * can pick a template to apply. Management (create/edit/delete) stays admin-only * under /api/admin/packing-templates. */ - listTemplates() { + async listTemplates() { return this.db.all<{ id: number; name: string; item_count: number }>(` SELECT pt.id, pt.name, (SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count @@ -669,7 +671,7 @@ export class PackingService { // ── Apply Template ───────────────────────────────────────────────────────── - applyTemplate( + async applyTemplate( tripId: string | number, templateId: string | number, visibility: 'common' | 'personal' = 'common', @@ -692,7 +694,7 @@ export class PackingService { const insert = this.db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)'); const added: any[] = []; - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const ti of templateItems) { const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner); const item = this.db.get('SELECT * FROM packing_items WHERE id = ?', result.lastInsertRowid); @@ -705,7 +707,7 @@ export class PackingService { // ── Save as Template ────────────────────────────────────────────────────── - saveAsTemplate(tripId: string | number, userId: number, templateName: string) { + async saveAsTemplate(tripId: string | number, userId: number, templateName: string) { // A template is a durable, shareable artifact, so it may only capture what is // the actor's to publish: the Common list plus their own items. It used to // take every row in the trip, restricted ones included. @@ -718,7 +720,7 @@ export class PackingService { const categories = [...new Set(items.map(i => i.category || 'Other'))]; - const templateId = this.db.transaction(() => { + const templateId = await this.uow.transactional(async () => { const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', templateName, userId); const id = result.lastInsertRowid; @@ -743,7 +745,7 @@ export class PackingService { // ── Category Assignees ───────────────────────────────────────────────────── - getCategoryAssignees(tripId: string | number) { + async getCategoryAssignees(tripId: string | number) { const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(` SELECT pca.category_name, pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar FROM packing_category_assignees pca @@ -761,14 +763,14 @@ export class PackingService { return assignees; } - updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) { - this.db.transaction(() => { + async updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) { + await this.uow.transactional(async () => { this.db.run('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName); if (Array.isArray(userIds) && userIds.length > 0) { const insert = this.db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)'); // Same rule as setBagMembers: only people on this trip may be assigned. - const roster = this.tripRosterIds(tripId); + const roster = await this.tripRosterIds(tripId); for (const uid of userIds) if (roster.has(uid)) insert.run(tripId, categoryName, uid); } }); @@ -784,9 +786,9 @@ export class PackingService { // ── Reorder ──────────────────────────────────────────────────────────────── - reorderItems(tripId: string | number, orderedIds: number[]): void { + async reorderItems(tripId: string | number, orderedIds: number[]): Promise { const update = this.db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { orderedIds.forEach((id, index) => { update.run(index, id, tripId); }); @@ -810,7 +812,7 @@ export class PackingService { // packing_template_categories like the sibling category routes do. /** An item looked up through its category, so :templateId actually scopes it. */ - private scopedTemplateItem(templateId: string, itemId: string) { + private async scopedTemplateItem(templateId: string, itemId: string) { return this.db.get(` SELECT ti.* FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id @@ -818,7 +820,7 @@ export class PackingService { `, itemId, templateId); } - listPackingTemplates() { + async listPackingTemplates() { return this.db.all(` SELECT pt.*, u.username as created_by_name, (SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count, @@ -829,7 +831,7 @@ export class PackingService { `); } - getPackingTemplate(id: string) { + async getPackingTemplate(id: string) { const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id); if (!template) return { error: 'Template not found', status: 404 }; const categories = this.db.all('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id', id); @@ -841,21 +843,21 @@ export class PackingService { return { template, categories, items }; } - createPackingTemplate(name: string, createdBy: number) { + async createPackingTemplate(name: string, createdBy: number) { if (!name?.trim()) return { error: 'Name is required', status: 400 }; const result = this.db.run('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)', name.trim(), createdBy); const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', result.lastInsertRowid); return { template }; } - updatePackingTemplate(id: string, data: { name?: string }) { + async updatePackingTemplate(id: string, data: { name?: string }) { const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', id); if (!template) return { error: 'Template not found', status: 404 }; if (data.name?.trim()) this.db.run('UPDATE packing_templates SET name = ? WHERE id = ?', data.name.trim(), id); return { template: this.db.get('SELECT * FROM packing_templates WHERE id = ?', id) }; } - deletePackingTemplate(id: string) { + async deletePackingTemplate(id: string) { const template = this.db.get<{ name?: string }>('SELECT * FROM packing_templates WHERE id = ?', id); if (!template) return { error: 'Template not found', status: 404 }; this.db.run('DELETE FROM packing_templates WHERE id = ?', id); @@ -864,7 +866,7 @@ export class PackingService { // Template categories - createTemplateCategory(templateId: string, name: string) { + async createTemplateCategory(templateId: string, name: string) { if (!name?.trim()) return { error: 'Category name is required', status: 400 }; const template = this.db.get('SELECT * FROM packing_templates WHERE id = ?', templateId); if (!template) return { error: 'Template not found', status: 404 }; @@ -873,7 +875,7 @@ export class PackingService { return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', result.lastInsertRowid) }; } - updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) { + async updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) { const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId); if (!cat) return { error: 'Category not found', status: 404 }; if (data.name?.trim()) @@ -881,7 +883,7 @@ export class PackingService { return { category: this.db.get('SELECT * FROM packing_template_categories WHERE id = ?', catId) }; } - deleteTemplateCategory(templateId: string, catId: string) { + async deleteTemplateCategory(templateId: string, catId: string) { const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId); if (!cat) return { error: 'Category not found', status: 404 }; this.db.run('DELETE FROM packing_template_categories WHERE id = ?', catId); @@ -890,7 +892,7 @@ export class PackingService { // Template items - createTemplateItem(templateId: string, catId: string, name: string) { + async createTemplateItem(templateId: string, catId: string, name: string) { if (!name?.trim()) return { error: 'Item name is required', status: 400 }; const cat = this.db.get('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?', catId, templateId); if (!cat) return { error: 'Category not found', status: 404 }; @@ -899,23 +901,23 @@ export class PackingService { return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', result.lastInsertRowid) }; } - updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) { - const item = this.scopedTemplateItem(templateId, itemId); + async updateTemplateItem(templateId: string, itemId: string, data: { name?: string }) { + const item = await this.scopedTemplateItem(templateId, itemId); if (!item) return { error: 'Item not found', status: 404 }; if (data.name?.trim()) this.db.run('UPDATE packing_template_items SET name = ? WHERE id = ?', data.name.trim(), itemId); return { item: this.db.get('SELECT * FROM packing_template_items WHERE id = ?', itemId) }; } - deleteTemplateItem(templateId: string, itemId: string) { - const item = this.scopedTemplateItem(templateId, itemId); + async deleteTemplateItem(templateId: string, itemId: string) { + const item = await this.scopedTemplateItem(templateId, itemId); if (!item) return { error: 'Item not found', status: 404 }; this.db.run('DELETE FROM packing_template_items WHERE id = ?', itemId); return {}; } /** Fire-and-forget tag notification, mirroring the legacy dynamic import. */ - notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void { + async notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): Promise { if (!Array.isArray(userIds) || userIds.length === 0) return; // Injected, not a lazy import of the old notifications bridge. The laziness bought // nothing the module graph does not already give — NotificationsModule diff --git a/server/src/nest/photos/photos.controller.ts b/server/src/nest/photos/photos.controller.ts index 4fa6c9cc3..65caf07ea 100644 --- a/server/src/nest/photos/photos.controller.ts +++ b/server/src/nest/photos/photos.controller.ts @@ -19,12 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator'; export class PhotosController { constructor(private readonly photos: PhotosService) {} - private requireAccess(user: User, rawId: string): number { + private async requireAccess(user: User, rawId: string): Promise { const photoId = Number(rawId); if (!Number.isFinite(photoId)) { throw new HttpException({ error: 'Invalid photo ID' }, 400); } - if (!this.photos.canAccess(user.id, photoId)) { + if (!(await this.photos.canAccess(user.id, photoId))) { throw new HttpException({ error: 'Forbidden' }, 403); } return photoId; @@ -32,19 +32,19 @@ export class PhotosController { @Get(':id/thumbnail') async thumbnail(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise { - const photoId = this.requireAccess(user, id); + const photoId = await this.requireAccess(user, id); await this.photos.stream(res, user.id, photoId, 'thumbnail'); } @Get(':id/original') async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response, @Headers('range') range?: string): Promise { - const photoId = this.requireAccess(user, id); + const photoId = await this.requireAccess(user, id); await this.photos.stream(res, user.id, photoId, 'original', range); } @Get(':id/info') async info(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise { - const photoId = this.requireAccess(user, id); + const photoId = await this.requireAccess(user, id); const result = await this.photos.info(user.id, photoId); if ('error' in result) { res.status(result.error.status).json({ error: result.error.message }); diff --git a/server/src/nest/photos/photos.service.ts b/server/src/nest/photos/photos.service.ts index 4f01200a7..ba7f152e9 100644 --- a/server/src/nest/photos/photos.service.ts +++ b/server/src/nest/photos/photos.service.ts @@ -15,7 +15,7 @@ export class PhotosService { private readonly resolver: PhotoResolverService, ) {} - canAccess(userId: number, photoId: number): boolean { + canAccess(userId: number, photoId: number): Promise { return this.access.canAccessTrekPhoto(userId, photoId); } diff --git a/server/src/nest/photos/trek-photos.repository.ts b/server/src/nest/photos/trek-photos.repository.ts index 593f8812b..76df4df3a 100644 --- a/server/src/nest/photos/trek-photos.repository.ts +++ b/server/src/nest/photos/trek-photos.repository.ts @@ -21,13 +21,13 @@ import type { TrekPhoto } from '../../types'; export class TrekPhotosRepository { constructor(private readonly db: DatabaseService) {} - getOrCreate( + async getOrCreate( provider: string, assetId: string, ownerId: number, passphrase?: string, mediaType: string = 'image', - ): number { + ): Promise { const existing = this.db.get<{ id: number }>( 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?', provider, assetId, ownerId, @@ -46,14 +46,14 @@ export class TrekPhotosRepository { return Number(res.lastInsertRowid); } - getOrCreateLocal( + async getOrCreateLocal( filePath: string, thumbnailPath?: string | null, width?: number | null, height?: number | null, mediaType: string = 'image', durationMs?: number | null, - ): number { + ): Promise { const existing = this.db.get<{ id: number }>( "SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath, ); @@ -66,12 +66,12 @@ export class TrekPhotosRepository { return Number(res.lastInsertRowid); } - resolve(photoId: number): TrekPhoto | null { + async resolve(photoId: number): Promise { return this.db.get('SELECT * FROM trek_photos WHERE id = ?', photoId) || null; } /** Retarget an existing row — used when a local photo is uploaded to Immich. */ - setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): void { + async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise { this.db.run( 'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?', provider, assetId, ownerId, trekPhotoId, @@ -82,7 +82,7 @@ export class TrekPhotosRepository { * Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions * that were already known — a re-generated thumbnail must not blank them. */ - recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): void { + async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise { this.db.run( 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?', thumbnailPath, width, height, photoId, @@ -100,10 +100,10 @@ export class TrekPhotosRepository { * * A photo with neither is the normal case, not a failure. */ - recordCaptureMetadata( + async recordCaptureMetadata( photoId: number, meta: { takenAt?: string | null; lat?: number | null; lng?: number | null }, - ): void { + ): Promise { const { takenAt = null, lat = null, lng = null } = meta; if (takenAt == null && lat == null && lng == null) return; // Coordinates are stored as a pair or not at all — a lone latitude is not a @@ -123,7 +123,7 @@ export class TrekPhotosRepository { * Drop the row once no trip and no journey references it. Local photos are * kept: their bytes are ours, and the file would outlive the row. */ - deleteIfOrphan(photoId: number): void { + async deleteIfOrphan(photoId: number): Promise { const stillUsed = this.db.get(` SELECT 1 FROM trip_photos WHERE photo_id = ? UNION ALL diff --git a/server/src/nest/share/share.controller.ts b/server/src/nest/share/share.controller.ts index 42bb1ccd2..fdb7bc4c9 100644 --- a/server/src/nest/share/share.controller.ts +++ b/server/src/nest/share/share.controller.ts @@ -25,7 +25,7 @@ export class TripShareController { constructor(private readonly share: ShareService) {} private async requireManage(tripId: string, user: User) { - const trip = this.share.verifyTripAccess(tripId, user.id); + const trip = await this.share.verifyTripAccess(tripId, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } @@ -42,7 +42,7 @@ export class TripShareController { @Res({ passthrough: true }) res: Response, ) { await this.requireManage(tripId, user); - const result = this.share.createOrUpdate(tripId, user.id, { + const result = await this.share.createOrUpdate(tripId, user.id, { share_map: body.share_map, share_bookings: body.share_bookings, share_packing: body.share_packing, @@ -62,14 +62,14 @@ export class TripShareController { // trip while signed in; it does not let them hand out a copy that works // without an account and outlives their membership. await this.requireManage(tripId, user); - const info = this.share.get(tripId); + const info = await this.share.get(tripId); return info ? info : { token: null }; } @Delete() async remove(@CurrentUser() user: User, @Param('tripId') tripId: string) { await this.requireManage(tripId, user); - this.share.remove(tripId); + await this.share.remove(tripId); return { success: true }; } } diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 4d5303442..564b8d0ef 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -40,9 +40,9 @@ export class ShareMcp { // requires share_manage on every verb including this one: the payload is the // token itself, and a token is an anonymous copy of the trip. Leaving this // one on membership alone would just move the same hole to MCP. - if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); + if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); - const link = this.share.get(String(tripId)); + const link = await this.share.get(String(tripId)); return ok({ link }); } @@ -67,11 +67,11 @@ export class ShareMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); + if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); // The zod .default()s above fill omitted flags, and ShareService applies // the same defaults again for undefined — no re-defaulting needed here. - const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, { + const { token, created } = await this.share.createOrUpdate(String(tripId), ctx.userId, { share_map, share_bookings, share_packing, share_budget, share_collab, }); return ok({ token, created }); @@ -88,9 +88,9 @@ export class ShareMcp { }) async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); + if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); - this.share.remove(String(tripId)); + await this.share.remove(String(tripId)); return ok({ success: true }); } } diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index e25c83892..e5d4cc66c 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -7,6 +7,7 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility'; import { SettingsService } from '../settings/settings.service'; +import { UnitOfWork } from '../database/unit-of-work'; import type { User } from '../../types'; type Trip = TripAccess; @@ -155,9 +156,10 @@ export class ShareService { private readonly permissions: PermissionsService, private readonly queryHelpers: QueryHelpersService, private readonly photoCache: PlacePhotoCacheService, + private readonly uow: UnitOfWork, ) {} - verifyTripAccess(tripId: string, userId: number) { + async verifyTripAccess(tripId: string, userId: number) { return this.dbs.canAccessTrip(tripId, userId); } @@ -174,7 +176,7 @@ export class ShareService { * before the expires_at migration keep NULL until touched and remain valid * indefinitely; an explicit update moves them onto the TTL. */ - createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): { token: string; created: boolean } { + async createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): Promise<{ token: string; created: boolean }> { const { share_map = true, share_bookings = true, @@ -184,7 +186,7 @@ export class ShareService { } = permissions; const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); - return this.dbs.transaction(() => { + return await this.uow.transactional(async () => { const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId); if (existing) { this.dbs.run( @@ -206,7 +208,7 @@ export class ShareService { /** * Returns share token info for a trip, or null if no share link exists. */ - get(tripId: string): ShareTokenInfo | null { + async get(tripId: string): Promise { const row = this.dbs.get('SELECT * FROM share_tokens WHERE trip_id = ?', tripId); if (!row) return null; return { @@ -223,7 +225,7 @@ export class ShareService { /** * Deletes the share token for a trip. */ - remove(tripId: string): void { + async remove(tripId: string): Promise { this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId); } @@ -243,7 +245,7 @@ export class ShareService { * route from these, and a trip with forty bookings is not forty round trips * to the database. */ - private publicEndpointsByReservation(tripId: number): Map>> { + private async publicEndpointsByReservation(tripId: number): Promise>>> { const rows = this.dbs.all<{ reservation_id: number } & Record>( `SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id @@ -380,7 +382,7 @@ export class ShareService { // an imported ticket keeps its seat and its record locator. A public // link shows what the booking is, not what it would take to change it // (#2320). The endpoints ride along, since the map draws from them. - const endpoints = this.publicEndpointsByReservation(tripId); + const endpoints = await this.publicEndpointsByReservation(tripId); reservations = this.dbs.all( `SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r WHERE r.trip_id = ? AND ${publicReservationSql('r')} 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 325cf54ff..fd5cba41b 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 @@ -72,7 +72,7 @@ export class TripReadModelService { // Thread the viewer so another member's private/personal packing items (#858) // stay hidden — without it listItems returns the UNFILTERED list. - const packingItems = this.packing.listItems(tripId, viewerUserId); + const packingItems = await this.packing.listItems(tripId, viewerUserId); const packing = { items: packingItems, total: packingItems.length, @@ -106,7 +106,7 @@ export class TripReadModelService { places: await this.places.list(String(tripId), {}), // Scope to the requesting member so other members' private packing items // (#858) never land in this viewer's offline cache. - packingItems: this.packing.listItems(tripId, viewerId), + packingItems: await this.packing.listItems(tripId, viewerId), todoItems: await this.todo.listItems(tripId), budgetItems: await this.budget.listBudgetItems(tripId), reservations: this.reservations.list(tripId), diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 095598e05..a232cd078 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -77,11 +77,11 @@ export class TripPromptsMcp { when: packingAddonOn, }) async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.packing.verifyTripAccess(tripId, ctx.userId)) { + if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } // Hide other members' private items (#858) from the requesting user. - const items = this.packing.listItems(tripId, ctx.userId); + const items = await this.packing.listItems(tripId, ctx.userId); if (!items.length) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] }; } diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 77ffc5b92..6e3bdaade 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -150,7 +150,7 @@ export async function createMcpTestRegistry(): Promise { const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection)); const todoService = new TodoService(dbService, permissionsService, realtimeService, await createTestUnitOfWork(dbService.connection)); - const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub()); + const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)); const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbService.connection)); // Exactly one instance, shared by maps, places and share: its stampede guard // and its on-disk set only work if all three readers see the same maps. @@ -233,7 +233,7 @@ export async function createMcpTestRegistry(): Promise { new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards), new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards), new TripPromptsMcp(tripsService, readModelService, packingService, addonsService), - new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache), authService, guards), + new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(dbService.connection)), authService, guards), new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards), new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)), new MapsMcp(mapsService), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 7d79fb9a5..1cc9a2c7b 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -94,7 +94,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const addons = new AddonsService(dbs); const queryHelpers = new QueryHelpersService(dbs); const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection)); - const packing = new PackingService(dbs, permissions, realtime, notificationsStub()); + const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection)); const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage); const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); const vacay = new VacayService(dbs, realtime, notificationsStub()); diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index a4c637019..81c839fcc 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -1182,17 +1182,17 @@ const deleteTrekPhotoIfOrphan = (id: number) => trekPhotos.deleteIfOrphan(id); import { decrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto'; describe('trek_photos passphrase healing (SYNO-090)', () => { - it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => { + it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', async () => { const { user } = createUser(testDb); const wrongPass = 'wrong-passphrase'; const correctPass = 'correct-passphrase'; - const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass); + const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass); const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string }; expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass); - const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass); + const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass); expect(id2).toBe(id1); const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string }; expect(decrypt_api_key(row2.passphrase)).toBe(correctPass); @@ -1200,28 +1200,28 @@ describe('trek_photos passphrase healing (SYNO-090)', () => { }); describe('trek_photos orphan cleanup (SYNO-091)', () => { - it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => { + it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); - const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A'); + const trekPhotoId = await getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A'); testDb.prepare( 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)' ).run(trip.id, user.id, trekPhotoId); // Still referenced — must not be deleted. - deleteTrekPhotoIfOrphan(trekPhotoId); + await deleteTrekPhotoIfOrphan(trekPhotoId); expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined(); // Remove the reference, then orphan-cleanup should delete the trek_photos row. testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId); - deleteTrekPhotoIfOrphan(trekPhotoId); + await deleteTrekPhotoIfOrphan(trekPhotoId); expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined(); }); - it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => { + it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); @@ -1230,18 +1230,18 @@ describe('trek_photos orphan cleanup (SYNO-091)', () => { const secondPass = 'second-passphrase'; // Add with wrong passphrase, then remove (simulating the bug scenario). - const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass); + const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass); testDb.prepare( 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)' ).run(trip.id, user.id, id1); testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1); - deleteTrekPhotoIfOrphan(id1); + await deleteTrekPhotoIfOrphan(id1); // trek_photos row should be gone. expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined(); // Re-add with the correct passphrase. - const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass); + const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass); const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string }; expect(decrypt_api_key(row.passphrase)).toBe(secondPass); }); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 4236c1e25..3a7d14fe9 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -133,7 +133,7 @@ beforeAll(async () => { addonsStub, promptGuards, ); - promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub()); + promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(promptDbs().connection)); packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards); budgetMcp = new BudgetMcp( new BudgetService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(promptDbs().connection)), diff --git a/server/tests/unit/nest/admin-packing-templates.controller.test.ts b/server/tests/unit/nest/admin-packing-templates.controller.test.ts index d0c3a62a8..535d87f63 100644 --- a/server/tests/unit/nest/admin-packing-templates.controller.test.ts +++ b/server/tests/unit/nest/admin-packing-templates.controller.test.ts @@ -19,17 +19,17 @@ const writeAudit = vi.fn(); function controller(over: Partial = {}) { const packing = { - listPackingTemplates: vi.fn(() => [{ id: 1 }]), - getPackingTemplate: vi.fn(() => ({ template: { id: 1 } })), - createPackingTemplate: vi.fn(() => ({ template: { id: 9 } })), - updatePackingTemplate: vi.fn(() => ({ template: { id: 1 } })), - deletePackingTemplate: vi.fn(() => ({ name: 'Beach' })), - createTemplateCategory: vi.fn(() => ({ category: { id: 2 } })), - updateTemplateCategory: vi.fn(() => ({ category: { id: 2 } })), - deleteTemplateCategory: vi.fn(() => ({ ok: true })), - createTemplateItem: vi.fn(() => ({ item: { id: 3 } })), - updateTemplateItem: vi.fn(() => ({ item: { id: 3 } })), - deleteTemplateItem: vi.fn(() => ({ ok: true })), + listPackingTemplates: vi.fn(async () => [{ id: 1 }]), + getPackingTemplate: vi.fn(async () => ({ template: { id: 1 } })), + createPackingTemplate: vi.fn(async () => ({ template: { id: 9 } })), + updatePackingTemplate: vi.fn(async () => ({ template: { id: 1 } })), + deletePackingTemplate: vi.fn(async () => ({ name: 'Beach' })), + createTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })), + updateTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })), + deleteTemplateCategory: vi.fn(async () => ({ ok: true })), + createTemplateItem: vi.fn(async () => ({ item: { id: 3 } })), + updateTemplateItem: vi.fn(async () => ({ item: { id: 3 } })), + deleteTemplateItem: vi.fn(async () => ({ ok: true })), ...over, } as unknown as PackingService; return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing }; @@ -45,9 +45,9 @@ function offContract(name: K, impl: () => unknow return { [name]: vi.fn(impl) } as unknown as Partial; } -const thrown = (run: () => unknown) => { +const thrown = async (run: () => unknown) => { try { - run(); + await run(); return null; } catch (e) { return e instanceof HttpException ? { status: e.getStatus(), body: e.getResponse() } : e; @@ -59,18 +59,18 @@ describe('AdminPackingTemplatesController', () => { vi.clearAllMocks(); }); - it('PACKTPL-001 list wraps the service result in the { templates } envelope', () => { - expect(controller().c.list()).toEqual({ templates: [{ id: 1 }] }); + it('PACKTPL-001 list wraps the service result in the { templates } envelope', async () => { + expect(await controller().c.list()).toEqual({ templates: [{ id: 1 }] }); }); - it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => { - const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) }); - expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } }); + it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', async () => { + const { c } = controller({ getPackingTemplate: vi.fn(async () => ({ error: 'not found', status: 404 })) }); + expect(await thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } }); }); - it('PACKTPL-003 an error without a status defaults to 400', () => { - const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' }))); - expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } }); + it('PACKTPL-003 an error without a status defaults to 400', async () => { + const { c } = controller(offContract('createTemplateCategory', async () => ({ error: 'name required' }))); + expect(await thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } }); }); it('PACKTPL-004 create audits with the new template id', async () => { @@ -90,26 +90,26 @@ describe('AdminPackingTemplatesController', () => { ); }); - it('PACKTPL-006 the nested category and item routes forward their ids in order', () => { + it('PACKTPL-006 the nested category and item routes forward their ids in order', async () => { const { c, packing } = controller(); - c.createCategory('1', { name: 'Clothes' }); + await c.createCategory('1', { name: 'Clothes' }); expect(packing.createTemplateCategory).toHaveBeenCalledWith('1', 'Clothes'); - c.updateCategory('1', '2', { name: 'Warm' }); + await c.updateCategory('1', '2', { name: 'Warm' }); expect(packing.updateTemplateCategory).toHaveBeenCalledWith('1', '2', { name: 'Warm' }); - expect(c.deleteCategory('1', '2')).toEqual({ success: true }); - c.createItem('1', '2', { name: 'Socks' }); + expect(await c.deleteCategory('1', '2')).toEqual({ success: true }); + await c.createItem('1', '2', { name: 'Socks' }); expect(packing.createTemplateItem).toHaveBeenCalledWith('1', '2', 'Socks'); - c.updateItem('1', '3', { name: 'Wool socks' }); + await c.updateItem('1', '3', { name: 'Wool socks' }); expect(packing.updateTemplateItem).toHaveBeenCalledWith('1', '3', { name: 'Wool socks' }); - expect(c.deleteItem('1', '3')).toEqual({ success: true }); + expect(await c.deleteItem('1', '3')).toEqual({ success: true }); }); - it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', () => { + it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', async () => { // The client branches on `success`; leaking the raw service result here would be a // silent shape change for every caller. const { c } = controller(); - expect(c.deleteCategory('1', '2')).toEqual({ success: true }); - expect(c.deleteItem('1', '3')).toEqual({ success: true }); + expect(await c.deleteCategory('1', '2')).toEqual({ success: true }); + expect(await c.deleteItem('1', '3')).toEqual({ success: true }); }); it('PACKTPL-008 the class is listed in its module controllers', () => { diff --git a/server/tests/unit/nest/immich.service.test.ts b/server/tests/unit/nest/immich.service.test.ts index a4b2b8107..bc87b118e 100644 --- a/server/tests/unit/nest/immich.service.test.ts +++ b/server/tests/unit/nest/immich.service.test.ts @@ -91,27 +91,27 @@ beforeEach(() => { afterAll(() => testDb.close()); describe('getImmichCredentials', () => { - it('IMMICH-001: returns null when the user row is missing', () => { - expect(svc.getImmichCredentials(999)).toBeNull(); + it('IMMICH-001: returns null when the user row is missing', async () => { + expect(await svc.getImmichCredentials(999)).toBeNull(); }); - it('IMMICH-002: returns null without a URL', () => { + it('IMMICH-002: returns null without a URL', async () => { seedUser(2, null, 'key'); - expect(svc.getImmichCredentials(2)).toBeNull(); + expect(await svc.getImmichCredentials(2)).toBeNull(); }); - it('IMMICH-003: returns null without an API key', () => { + it('IMMICH-003: returns null without an API key', async () => { seedUser(3, 'https://immich.test', null); - expect(svc.getImmichCredentials(3)).toBeNull(); + expect(await svc.getImmichCredentials(3)).toBeNull(); }); - it('IMMICH-004: returns null when the stored key cannot be decrypted', () => { + it('IMMICH-004: returns null when the stored key cannot be decrypted', async () => { decryptMock.mockReturnValue(null); - expect(svc.getImmichCredentials(USER)).toBeNull(); + expect(await svc.getImmichCredentials(USER)).toBeNull(); }); - it('IMMICH-005: returns the decrypted pair otherwise', () => { - expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' }); + it('IMMICH-005: returns the decrypted pair otherwise', async () => { + expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' }); }); }); @@ -124,20 +124,20 @@ describe('isValidAssetId', () => { }); describe('getConnectionSettings / setImmichAutoUpload', () => { - it('IMMICH-007: reports connected with the URL when configured', () => { - expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false }); + it('IMMICH-007: reports connected with the URL when configured', async () => { + expect(await svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false }); }); - it('IMMICH-008: reports an empty URL and not connected when it is not', () => { + it('IMMICH-008: reports an empty URL and not connected when it is not', async () => { seedUser(4, null, null); - expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false }); + expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false }); }); - it('IMMICH-009: surfaces the auto-upload flag both ways', () => { - svc.setImmichAutoUpload(USER, true); - expect(svc.getConnectionSettings(USER).auto_upload).toBe(true); - svc.setImmichAutoUpload(USER, false); - expect(svc.getConnectionSettings(USER).auto_upload).toBe(false); + it('IMMICH-009: surfaces the auto-upload flag both ways', async () => { + await svc.setImmichAutoUpload(USER, true); + expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(true); + await svc.setImmichAutoUpload(USER, false); + expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(false); }); }); @@ -148,14 +148,14 @@ describe('saveImmichSettings', () => { const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null); expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' }); - expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test'); + expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test'); }); it('IMMICH-011: stores a trimmed URL and reports plain success', async () => { const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null); expect(result).toEqual({ success: true }); - expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' }); + expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' }); }); it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => { @@ -173,7 +173,7 @@ describe('saveImmichSettings', () => { expect(result).toEqual({ success: true }); expect(checkSsrf).not.toHaveBeenCalled(); - expect(svc.getImmichCredentials(USER)).toBeNull(); + expect(await svc.getImmichCredentials(USER)).toBeNull(); }); }); diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 24abff868..3f1e94159 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -78,7 +78,7 @@ afterAll(() => { // -- Access control ----------------------------------------------------------- describe('canAccessJourney', () => { - it('JOURNEY-SVC-001: returns journey for owner', () => { + it('JOURNEY-SVC-001: returns journey for owner', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'My Journey' }); @@ -89,7 +89,7 @@ describe('canAccessJourney', () => { expect(result!.title).toBe('My Journey'); }); - it('JOURNEY-SVC-002: returns journey for contributor', () => { + it('JOURNEY-SVC-002: returns journey for contributor', async () => { const { user: owner } = createUser(testDb); const { user: contrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -101,7 +101,7 @@ describe('canAccessJourney', () => { expect(result!.id).toBe(journey.id); }); - it('JOURNEY-SVC-003: returns null for stranger', () => { + it('JOURNEY-SVC-003: returns null for stranger', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -113,14 +113,14 @@ describe('canAccessJourney', () => { }); describe('isOwner', () => { - it('JOURNEY-SVC-004: returns true for owner', () => { + it('JOURNEY-SVC-004: returns true for owner', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); expect(svc.isOwner(journey.id, user.id)).toBe(true); }); - it('JOURNEY-SVC-005: returns false for contributor', () => { + it('JOURNEY-SVC-005: returns false for contributor', async () => { const { user: owner } = createUser(testDb); const { user: contrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -129,7 +129,7 @@ describe('isOwner', () => { expect(svc.isOwner(journey.id, contrib.id)).toBe(false); }); - it('JOURNEY-SVC-006: returns false for stranger', () => { + it('JOURNEY-SVC-006: returns false for stranger', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -139,14 +139,14 @@ describe('isOwner', () => { }); describe('canEdit', () => { - it('JOURNEY-SVC-007: owner can edit', () => { + it('JOURNEY-SVC-007: owner can edit', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); expect(svc.canEdit(journey.id, user.id)).toBe(true); }); - it('JOURNEY-SVC-008: editor contributor can edit', () => { + it('JOURNEY-SVC-008: editor contributor can edit', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -155,7 +155,7 @@ describe('canEdit', () => { expect(svc.canEdit(journey.id, editor.id)).toBe(true); }); - it('JOURNEY-SVC-009: viewer contributor cannot edit', () => { + it('JOURNEY-SVC-009: viewer contributor cannot edit', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -164,7 +164,7 @@ describe('canEdit', () => { expect(svc.canEdit(journey.id, viewer.id)).toBe(false); }); - it('JOURNEY-SVC-010: stranger cannot edit', () => { + it('JOURNEY-SVC-010: stranger cannot edit', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -176,7 +176,7 @@ describe('canEdit', () => { // -- Journey CRUD ------------------------------------------------------------- describe('listJourneys', () => { - it('JOURNEY-SVC-011: returns owned journeys with counts', () => { + it('JOURNEY-SVC-011: returns owned journeys with counts', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Road Trip' }); createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', location_name: 'Paris' }); @@ -190,7 +190,7 @@ describe('listJourneys', () => { expect(result[0].place_count).toBe(2); }); - it('JOURNEY-SVC-012: includes journeys where user is contributor', () => { + it('JOURNEY-SVC-012: includes journeys where user is contributor', async () => { const { user: owner } = createUser(testDb); const { user: contrib } = createUser(testDb); const journey = createJourney(testDb, owner.id, { title: 'Shared Trip' }); @@ -202,7 +202,7 @@ describe('listJourneys', () => { expect(result[0].title).toBe('Shared Trip'); }); - it('JOURNEY-SVC-013: does not include other users journeys', () => { + it('JOURNEY-SVC-013: does not include other users journeys', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); createJourney(testDb, owner.id, { title: 'Private' }); @@ -212,7 +212,7 @@ describe('listJourneys', () => { expect(result).toHaveLength(0); }); - it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => { + it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', async () => { const { user } = createUser(testDb); 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' }); @@ -229,7 +229,7 @@ describe('listJourneys', () => { }); describe('createJourney (service)', () => { - it('JOURNEY-SVC-014: creates journey with contributor record', () => { + 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' }); @@ -247,7 +247,7 @@ describe('createJourney (service)', () => { expect(contrib!.role).toBe('owner'); }); - it('JOURNEY-SVC-015: links trips when trip_ids provided', () => { + it('JOURNEY-SVC-015: links trips when trip_ids provided', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris 2026' }); @@ -261,7 +261,7 @@ describe('createJourney (service)', () => { }); describe('getJourneyFull', () => { - it('JOURNEY-SVC-016: returns full journey with entries, trips, contributors', () => { + it('JOURNEY-SVC-016: returns full journey with entries, trips, contributors', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Full Journey' }); createJourneyEntry(testDb, journey.id, user.id, { @@ -280,7 +280,7 @@ describe('getJourneyFull', () => { expect(result!.stats.entries).toBe(1); }); - it('JOURNEY-SVC-017: returns null for unauthorized user', () => { + it('JOURNEY-SVC-017: returns null for unauthorized user', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -292,7 +292,7 @@ describe('getJourneyFull', () => { }); describe('updateJourney', () => { - it('JOURNEY-SVC-018: owner can update title and subtitle', () => { + it('JOURNEY-SVC-018: owner can update title and subtitle', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Old Title' }); @@ -303,7 +303,7 @@ describe('updateJourney', () => { expect(updated!.subtitle).toBe('New Sub'); }); - it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', () => { + it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', async () => { // Post-#732: journey-level settings (title/cover/status) are owner-only. // Editors keep access to entries and photos, but not the journey shell. const { user: owner } = createUser(testDb); @@ -316,7 +316,7 @@ describe('updateJourney', () => { expect(updated).toBeNull(); }); - it('JOURNEY-SVC-020: viewer cannot update', () => { + it('JOURNEY-SVC-020: viewer cannot update', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -327,7 +327,7 @@ describe('updateJourney', () => { expect(result).toBeNull(); }); - it('JOURNEY-SVC-021: returns journey unchanged when no valid fields provided', () => { + it('JOURNEY-SVC-021: returns journey unchanged when no valid fields provided', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Same' }); @@ -337,7 +337,7 @@ describe('updateJourney', () => { expect(result!.title).toBe('Same'); }); - it('JOURNEY-SVC-021b: accepts archived status', () => { + it('JOURNEY-SVC-021b: accepts archived status', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'To Archive' }); @@ -347,7 +347,7 @@ describe('updateJourney', () => { expect(result!.status).toBe('archived'); }); - it('JOURNEY-SVC-021c: ignores invalid status value', () => { + it('JOURNEY-SVC-021c: ignores invalid status value', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Stay Active' }); @@ -357,7 +357,7 @@ describe('updateJourney', () => { expect(result!.status).toBe('active'); }); - it('JOURNEY-SVC-021d: trip GPX tracks are off until the owner switches them on (#2194)', () => { + it('JOURNEY-SVC-021d: trip GPX tracks are off until the owner switches them on (#2194)', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Norway' }); @@ -370,7 +370,7 @@ describe('updateJourney', () => { expect(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)', () => { + it('JOURNEY-SVC-021e: an editor cannot switch the trip tracks on (#2194)', async () => { const { user } = createUser(testDb); const { user: editor } = createUser(testDb, { username: 'tracks-editor' }); const journey = createJourney(testDb, user.id, { title: 'Norway' }); @@ -383,7 +383,7 @@ describe('updateJourney', () => { }); describe('deleteJourney', () => { - it('JOURNEY-SVC-022: owner can delete', () => { + it('JOURNEY-SVC-022: owner can delete', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -394,7 +394,7 @@ describe('deleteJourney', () => { expect(row).toBeUndefined(); }); - it('JOURNEY-SVC-023: non-owner cannot delete', () => { + it('JOURNEY-SVC-023: non-owner cannot delete', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -411,7 +411,7 @@ describe('deleteJourney', () => { // -- Trip management ---------------------------------------------------------- describe('addTripToJourney / removeTripFromJourney', () => { - it('JOURNEY-SVC-024: links a trip to a journey', () => { + it('JOURNEY-SVC-024: links a trip to a journey', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Linked Trip' }); @@ -425,7 +425,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { expect(link).toBeDefined(); }); - it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', () => { + it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -441,7 +441,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { expect(link).toBeUndefined(); }); - it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', () => { + it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -461,7 +461,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { expect(skeletons.length).toBe(1); }); - it('JOURNEY-SVC-026: owner can remove a trip from journey', () => { + it('JOURNEY-SVC-026: owner can remove a trip from journey', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Remove Me' }); @@ -476,7 +476,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { expect(link).toBeUndefined(); }); - it('JOURNEY-SVC-027: non-owner cannot remove a trip', () => { + it('JOURNEY-SVC-027: non-owner cannot remove a trip', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -493,7 +493,7 @@ describe('addTripToJourney / removeTripFromJourney', () => { // -- Entries ------------------------------------------------------------------ describe('listEntries', () => { - it('JOURNEY-SVC-028: returns entries with photos for authorized user', () => { + it('JOURNEY-SVC-028: returns entries with photos for authorized user', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { @@ -509,7 +509,7 @@ describe('listEntries', () => { expect(result![0].photos).toEqual([]); }); - it('JOURNEY-SVC-029: returns null for unauthorized user', () => { + it('JOURNEY-SVC-029: returns null for unauthorized user', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -524,7 +524,7 @@ describe('listEntries', () => { * The column holds 0 or 1; a client handed the integer on one read and the * boolean on another could compare against neither. */ - it('answers stats_excluded as a boolean, on the list and on the full journey alike', () => { + it('answers stats_excluded as a boolean, on the list and on the full journey alike', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const off = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', stats_excluded: 1 }); @@ -540,7 +540,7 @@ describe('listEntries', () => { }); describe('createEntry', () => { - it('JOURNEY-SVC-030: creates entry for editor', () => { + it('JOURNEY-SVC-030: creates entry for editor', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -561,7 +561,7 @@ describe('createEntry', () => { expect(entry!.author_id).toBe(user.id); }); - it('JOURNEY-SVC-031: viewer cannot create entry', () => { + it('JOURNEY-SVC-031: viewer cannot create entry', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -577,7 +577,7 @@ describe('createEntry', () => { }); describe('updateEntry', () => { - it('JOURNEY-SVC-032: updates entry fields', () => { + it('JOURNEY-SVC-032: updates entry fields', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { @@ -592,7 +592,7 @@ describe('updateEntry', () => { expect(updated!.mood).toBe('excited'); }); - it('JOURNEY-SVC-033: promotes skeleton to entry when story is added', () => { + it('JOURNEY-SVC-033: promotes skeleton to entry when story is added', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { @@ -616,7 +616,7 @@ describe('updateEntry', () => { * string, the client spread it into an entry it had typed as `string[]`, and * the journey page threw `tags.map is not a function`. */ - it('answers with tags decoded, not as the JSON string the column holds', () => { + 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, { @@ -629,7 +629,7 @@ describe('updateEntry', () => { expect(updated!.tags).toEqual(['beach', 'sunset']); }); - it('decodes an emptied tag list rather than answering the string "[]"', () => { + it('decodes an emptied tag list rather than answering the string "[]"', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); @@ -640,7 +640,7 @@ describe('updateEntry', () => { expect(updated!.tags).toEqual([]); }); - it('decodes pros_cons the same way, on create and on update', () => { + it('decodes pros_cons the same way, on create and on update', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const prosCons = { pros: ['warm'], cons: ['crowded'] }; @@ -653,7 +653,7 @@ describe('updateEntry', () => { }); /* An update that changes nothing takes its own early return out of the method. */ - it('decodes on the no-op update too', () => { + 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, { @@ -668,7 +668,7 @@ describe('updateEntry', () => { * — the journey page reloads on any event — so this is about not leaving a * payload that contradicts every read path for a listener to trust later. */ - it('broadcasts the decoded entry, not the row', () => { + 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, { @@ -692,7 +692,7 @@ describe('updateEntry', () => { * be the boolean on the way out as well as the integer on the way in, and * the broadcast has to say the same thing the answer does. */ - it('switches a stop off and back on, and answers with the flag as a boolean', () => { + it('switches a stop off and back on, and answers with the flag as a boolean', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); @@ -715,7 +715,7 @@ describe('updateEntry', () => { } }); - it('a viewer cannot switch a stop off', () => { + it('a viewer cannot switch a stop off', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -726,7 +726,7 @@ describe('updateEntry', () => { expect(svc.listEntries(journey.id, owner.id)![0].stats_excluded).toBe(false); }); - it('JOURNEY-SVC-034: returns null for non-existent entry', () => { + 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' }); @@ -734,7 +734,7 @@ describe('updateEntry', () => { expect(result).toBeNull(); }); - it('JOURNEY-SVC-034b: ignores injection column keys and mass-assignment attempts', () => { + it('JOURNEY-SVC-034b: ignores injection column keys and mass-assignment attempts', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { @@ -762,7 +762,7 @@ describe('updateEntry', () => { }); describe('deleteEntry', () => { - it('JOURNEY-SVC-035: deletes entry for editor', () => { + it('JOURNEY-SVC-035: deletes entry for editor', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); @@ -774,13 +774,13 @@ describe('deleteEntry', () => { expect(row).toBeUndefined(); }); - it('JOURNEY-SVC-036: returns false for non-existent entry', () => { + it('JOURNEY-SVC-036: returns false for non-existent entry', async () => { const { user } = createUser(testDb); expect(svc.deleteEntry(99999, user.id)).toBe(false); }); - it('JOURNEY-SVC-037: viewer cannot delete entry', () => { + it('JOURNEY-SVC-037: viewer cannot delete entry', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -790,7 +790,7 @@ describe('deleteEntry', () => { expect(svc.deleteEntry(entry.id, viewer.id)).toBe(false); }); - it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => { + it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -818,7 +818,7 @@ describe('deleteEntry', () => { expect(reverted.title).toBe('Tokyo Tower'); }); - it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => { + it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' }); @@ -834,12 +834,12 @@ describe('deleteEntry', () => { // -- Photos ------------------------------------------------------------------- describe('addPhoto / addProviderPhoto / deletePhoto', () => { - it('JOURNEY-SVC-038: addPhoto creates a local photo on an entry', () => { + it('JOURNEY-SVC-038: addPhoto creates a local photo on an entry', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset'); expect(photo).not.toBeNull(); expect(photo!.file_path).toBe('/uploads/photo.jpg'); @@ -848,20 +848,20 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { expect(photo!.provider).toBe('local'); }); - it('JOURNEY-SVC-039: addPhoto returns null for non-existent entry', () => { + it('JOURNEY-SVC-039: addPhoto returns null for non-existent entry', async () => { const { user } = createUser(testDb); - const result = svc.addPhoto(99999, user.id, '/uploads/photo.jpg'); + const result = await svc.addPhoto(99999, user.id, '/uploads/photo.jpg'); expect(result).toBeNull(); }); - it('JOURNEY-SVC-040: addProviderPhoto creates a provider-backed photo', () => { + it('JOURNEY-SVC-040: addProviderPhoto creates a provider-backed photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption'); + const photo = await svc.addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption'); expect(photo).not.toBeNull(); expect(photo!.provider).toBe('immich'); @@ -869,24 +869,24 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { expect(photo!.caption).toBe('My caption'); }); - it('JOURNEY-SVC-041: addProviderPhoto skips duplicate asset', () => { + it('JOURNEY-SVC-041: addProviderPhoto skips duplicate asset', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); - const duplicate = svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); + await svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); + const duplicate = await svc.addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset'); expect(duplicate).toBeNull(); }); - it('JOURNEY-SVC-042: deletePhoto removes photo and returns it', () => { + it('JOURNEY-SVC-042: deletePhoto removes photo and returns it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/delete-me.jpg'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/delete-me.jpg'); - const deleted = svc.deletePhoto(photo!.id, user.id); + const deleted = await svc.deletePhoto(photo!.id, user.id); expect(deleted).not.toBeNull(); expect(deleted!.id).toBe(photo!.id); @@ -894,20 +894,20 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { expect(row).toBeUndefined(); }); - it('JOURNEY-SVC-043: deletePhoto returns null for non-existent photo', () => { + it('JOURNEY-SVC-043: deletePhoto returns null for non-existent photo', async () => { const { user } = createUser(testDb); - expect(svc.deletePhoto(99999, user.id)).toBeNull(); + expect(await svc.deletePhoto(99999, user.id)).toBeNull(); }); - it('JOURNEY-SVC-044: viewer cannot add photo', () => { + it('JOURNEY-SVC-044: viewer cannot add photo', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const result = svc.addPhoto(entry.id, viewer.id, '/uploads/no.jpg'); + const result = await svc.addPhoto(entry.id, viewer.id, '/uploads/no.jpg'); expect(result).toBeNull(); }); @@ -916,7 +916,7 @@ describe('addPhoto / addProviderPhoto / deletePhoto', () => { // -- Contributors ------------------------------------------------------------- describe('addContributor / updateContributorRole / removeContributor', () => { - it('JOURNEY-SVC-045: owner can add contributor', () => { + it('JOURNEY-SVC-045: owner can add contributor', async () => { const { user: owner } = createUser(testDb); const { user: newContrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -931,7 +931,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(row!.role).toBe('editor'); }); - it('JOURNEY-SVC-046: non-owner cannot add contributor', () => { + it('JOURNEY-SVC-046: non-owner cannot add contributor', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const { user: newUser } = createUser(testDb); @@ -943,7 +943,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(result).toBe(false); }); - it('JOURNEY-SVC-047: owner cannot add themselves as contributor', () => { + it('JOURNEY-SVC-047: owner cannot add themselves as contributor', async () => { const { user: owner } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -952,7 +952,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(result).toBe(false); }); - it('JOURNEY-SVC-048: owner can update contributor role', () => { + it('JOURNEY-SVC-048: owner can update contributor role', async () => { const { user: owner } = createUser(testDb); const { user: contrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -967,7 +967,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(row.role).toBe('editor'); }); - it('JOURNEY-SVC-049: non-owner cannot update contributor role', () => { + it('JOURNEY-SVC-049: non-owner cannot update contributor role', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const { user: target } = createUser(testDb); @@ -980,7 +980,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(result).toBe(false); }); - it('JOURNEY-SVC-050: owner can remove contributor', () => { + it('JOURNEY-SVC-050: owner can remove contributor', async () => { const { user: owner } = createUser(testDb); const { user: contrib } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -995,7 +995,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { expect(row).toBeUndefined(); }); - it('JOURNEY-SVC-051: removeContributor does not remove owner contributor record', () => { + it('JOURNEY-SVC-051: removeContributor does not remove owner contributor record', async () => { const { user: owner } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -1013,7 +1013,7 @@ describe('addContributor / updateContributorRole / removeContributor', () => { // -- Suggestions -------------------------------------------------------------- describe('getSuggestions', () => { - it('JOURNEY-SVC-052: returns recently ended trips not yet in a journey', () => { + it('JOURNEY-SVC-052: returns recently ended trips not yet in a journey', async () => { const { user } = createUser(testDb); // Trip that ended 5 days ago (within 30-day window) const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; @@ -1030,7 +1030,7 @@ describe('getSuggestions', () => { expect((suggestions[0] as any).title).toBe('Recent Trip'); }); - it('JOURNEY-SVC-053: excludes trips already linked to a journey', () => { + it('JOURNEY-SVC-053: excludes trips already linked to a journey', async () => { const { user } = createUser(testDb); const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; @@ -1047,7 +1047,7 @@ describe('getSuggestions', () => { expect(suggestions.length).toBe(0); }); - it('JOURNEY-SVC-054: excludes trips ending in the future', () => { + it('JOURNEY-SVC-054: excludes trips ending in the future', async () => { const { user } = createUser(testDb); const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; createTrip(testDb, user.id, { @@ -1065,7 +1065,7 @@ describe('getSuggestions', () => { // -- syncTripPlaces ------------------------------------------------------------ describe('syncTripPlaces', () => { - it('JOURNEY-SVC-055: creates skeleton entries for each trip place', () => { + it('JOURNEY-SVC-055: creates skeleton entries for each trip place', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1089,7 +1089,7 @@ describe('syncTripPlaces', () => { expect(names).toEqual(['Eiffel Tower', 'Louvre']); }); - it('JOURNEY-SVC-056: skips places that already have skeleton entries', () => { + it('JOURNEY-SVC-056: skips places that already have skeleton entries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1110,7 +1110,7 @@ describe('syncTripPlaces', () => { expect(skeletons.length).toBe(1); }); - it('JOURNEY-SVC-057: uses day date for skeleton entry_date when available', () => { + it('JOURNEY-SVC-057: uses day date for skeleton entry_date when available', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); // Trip with dates auto-creates days; grab an existing day to assign the place @@ -1138,7 +1138,7 @@ describe('syncTripPlaces', () => { // -- onPlaceCreated / onPlaceUpdated / onPlaceDeleted ------------------------- describe('onPlaceCreated', () => { - it('JOURNEY-SVC-058: creates skeleton entry in linked journeys', () => { + it('JOURNEY-SVC-058: creates skeleton entry in linked journeys', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1160,7 +1160,7 @@ describe('onPlaceCreated', () => { expect(skeleton).toBeDefined(); }); - it('JOURNEY-SVC-059: does nothing if trip is not linked to any journey', () => { + it('JOURNEY-SVC-059: does nothing if trip is not linked to any journey', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Unlinked Trip' }); const place = createPlace(testDb, trip.id, { name: 'Remote Place' }); @@ -1173,7 +1173,7 @@ describe('onPlaceCreated', () => { expect(entries.length).toBe(0); }); - it('JOURNEY-SVC-060: does not duplicate if skeleton already exists', () => { + it('JOURNEY-SVC-060: does not duplicate if skeleton already exists', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1197,7 +1197,7 @@ describe('onPlaceCreated', () => { }); describe('onPlaceUpdated', () => { - it('JOURNEY-SVC-061: updates skeleton entry fields when place changes', () => { + it('JOURNEY-SVC-061: updates skeleton entry fields when place changes', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1222,7 +1222,7 @@ describe('onPlaceUpdated', () => { expect(entry.location_name).toBe('New Address'); }); - it('JOURNEY-SVC-062: only updates location on filled entries, not title', () => { + it('JOURNEY-SVC-062: only updates location on filled entries, not title', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1252,7 +1252,7 @@ describe('onPlaceUpdated', () => { expect(entry.location_name).toBe('Changed Addr'); // location updated }); - it('JOURNEY-SVC-063: does nothing if place has no linked entries', () => { + it('JOURNEY-SVC-063: does nothing if place has no linked entries', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Orphan Trip' }); const place = createPlace(testDb, trip.id, { name: 'Orphan Place' }); @@ -1268,7 +1268,7 @@ describe('onPlaceUpdated', () => { }); describe('onPlaceDeleted', () => { - it('JOURNEY-SVC-064: deletes empty skeleton entries', () => { + it('JOURNEY-SVC-064: deletes empty skeleton entries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1287,7 +1287,7 @@ describe('onPlaceDeleted', () => { expect(entry).toBeUndefined(); }); - it('JOURNEY-SVC-065: detaches filled entries and adds note instead of deleting', () => { + it('JOURNEY-SVC-065: detaches filled entries and adds note instead of deleting', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1317,7 +1317,7 @@ describe('onPlaceDeleted', () => { expect(entry.story).toContain('original trip place was removed'); }); - it('JOURNEY-SVC-066: does nothing for unlinked places', () => { + it('JOURNEY-SVC-066: does nothing for unlinked places', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Unlinked' }); const place = createPlace(testDb, trip.id, { name: 'Nowhere' }); @@ -1334,13 +1334,13 @@ describe('onPlaceDeleted', () => { // -- linkPhotoToEntry ---------------------------------------------------------- describe('linkPhotoToEntry', () => { - it('JOURNEY-SVC-067: moves photo from one entry to another', () => { + it('JOURNEY-SVC-067: moves photo from one entry to another', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); const entry2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' }); - const photo = svc.addPhoto(entry1.id, user.id, '/uploads/link-test.jpg'); + 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); @@ -1348,11 +1348,11 @@ describe('linkPhotoToEntry', () => { expect(result!.entry_id).toBe(entry2.id); }); - it('JOURNEY-SVC-068: returns same photo if already on target entry', () => { + it('JOURNEY-SVC-068: returns same photo if already on target entry', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/same-entry.jpg'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/same-entry.jpg'); const result = svc.linkPhotoToEntry(entry.id, photo!.id, user.id); expect(result).not.toBeNull(); @@ -1360,14 +1360,14 @@ describe('linkPhotoToEntry', () => { expect(result!.entry_id).toBe(entry.id); }); - it('JOURNEY-SVC-069: returns null for non-existent entry', () => { + it('JOURNEY-SVC-069: returns null for non-existent entry', async () => { const { user } = createUser(testDb); const result = svc.linkPhotoToEntry(99999, 1, user.id); expect(result).toBeNull(); }); - it('JOURNEY-SVC-070: returns null for non-existent photo', () => { + it('JOURNEY-SVC-070: returns null for non-existent photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); @@ -1376,13 +1376,13 @@ describe('linkPhotoToEntry', () => { expect(result).toBeNull(); }); - it('JOURNEY-SVC-071: viewer cannot link photo', () => { + it('JOURNEY-SVC-071: viewer cannot link photo', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg'); + const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg'); const result = svc.linkPhotoToEntry(entry.id, photo!.id, viewer.id); expect(result).toBeNull(); @@ -1392,13 +1392,13 @@ describe('linkPhotoToEntry', () => { // -- setPhotoProvider ---------------------------------------------------------- describe('setPhotoProvider', () => { - it('JOURNEY-SVC-072: sets provider info on an existing photo', () => { + it('JOURNEY-SVC-072: sets provider info on an existing photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/provider-test.jpg'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/provider-test.jpg'); - svc.setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); + await svc.setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); const updated = testDb.prepare(` SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id @@ -1414,11 +1414,11 @@ describe('setPhotoProvider', () => { // -- updatePhoto --------------------------------------------------------------- describe('updatePhoto', () => { - it('JOURNEY-SVC-073: updates caption on photo', () => { + it('JOURNEY-SVC-073: updates caption on photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption'); + 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' }); @@ -1426,11 +1426,11 @@ describe('updatePhoto', () => { expect(result!.caption).toBe('New caption'); }); - it('JOURNEY-SVC-074: updates sort_order on photo', () => { + it('JOURNEY-SVC-074: updates sort_order on photo', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/sort-test.jpg'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/sort-test.jpg'); const result = svc.updatePhoto(photo!.id, user.id, { sort_order: 10 }); @@ -1438,18 +1438,18 @@ describe('updatePhoto', () => { expect(result!.sort_order).toBe(10); }); - it('JOURNEY-SVC-075: returns null for non-existent photo', () => { + it('JOURNEY-SVC-075: returns null for non-existent photo', async () => { const { user } = createUser(testDb); const result = svc.updatePhoto(99999, user.id, { caption: 'Nope' }); expect(result).toBeNull(); }); - it('JOURNEY-SVC-076: returns photo unchanged when no fields provided', () => { + it('JOURNEY-SVC-076: returns photo unchanged when no fields provided', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay'); const result = svc.updatePhoto(photo!.id, user.id, {}); @@ -1457,13 +1457,13 @@ describe('updatePhoto', () => { expect(result!.caption).toBe('Stay'); }); - it('JOURNEY-SVC-077: viewer cannot update photo', () => { + it('JOURNEY-SVC-077: viewer cannot update photo', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); addJourneyContributor(testDb, journey.id, viewer.id, 'viewer'); const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg'); + const photo = await svc.addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg'); const result = svc.updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' }); expect(result).toBeNull(); @@ -1473,7 +1473,7 @@ describe('updatePhoto', () => { // -- listUserTrips ------------------------------------------------------------- describe('listUserTrips', () => { - it('JOURNEY-SVC-078: returns all user trips', () => { + it('JOURNEY-SVC-078: returns all user trips', async () => { const { user } = createUser(testDb); 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' }); @@ -1486,7 +1486,7 @@ describe('listUserTrips', () => { expect((trips[1] as any).title).toBe('Trip A'); }); - it('JOURNEY-SVC-079: returns empty for user with no trips', () => { + it('JOURNEY-SVC-079: returns empty for user with no trips', async () => { const { user } = createUser(testDb); const trips = svc.listUserTrips(user.id); @@ -1494,7 +1494,7 @@ describe('listUserTrips', () => { expect(trips.length).toBe(0); }); - it('JOURNEY-SVC-080: does not return other users trips', () => { + it('JOURNEY-SVC-080: does not return other users trips', async () => { const { user: user1 } = createUser(testDb); const { user: user2 } = createUser(testDb); createTrip(testDb, user1.id, { title: 'User1 Trip' }); @@ -1508,11 +1508,11 @@ describe('listUserTrips', () => { // -- Edge cases ---------------------------------------------------------------- describe('Edge cases', () => { - it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', () => { + it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); - const photo = svc.addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg'); + const photo = await svc.addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg'); const result = svc.deleteEntry(entry.id, user.id); expect(result).toBe(true); @@ -1523,7 +1523,7 @@ describe('Edge cases', () => { expect(junctionRow).toBeUndefined(); }); - it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { + it('JOURNEY-SVC-082: updateJourney can set cover_gradient', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -1533,7 +1533,7 @@ describe('Edge cases', () => { expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)'); }); - it('JOURNEY-SVC-083: updateJourney ignores unknown fields', () => { + it('JOURNEY-SVC-083: updateJourney ignores unknown fields', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Original' }); @@ -1543,7 +1543,7 @@ describe('Edge cases', () => { expect(result!.title).toBe('Original'); }); - it('JOURNEY-SVC-084: createEntry stores tags and pros_cons as JSON', () => { + it('JOURNEY-SVC-084: createEntry stores tags and pros_cons as JSON', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -1560,7 +1560,7 @@ describe('Edge cases', () => { expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Great view'], cons: ['Expensive'] }); }); - it('JOURNEY-SVC-085: updateEntry handles tags and pros_cons update', () => { + it('JOURNEY-SVC-085: updateEntry handles tags and pros_cons update', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); @@ -1580,7 +1580,7 @@ describe('Edge cases', () => { // trip_photos into the gallery; that surface lost its UI in 3.1.0, nothing // writes to it on a newer install, and the copy was how a photo one member had // chosen not to share could reach a journey at all. - it('JOURNEY-SVC-086: addTripToJourney no longer pulls trip photos into the gallery', () => { + it('JOURNEY-SVC-086: addTripToJourney no longer pulls trip photos into the gallery', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1596,7 +1596,7 @@ describe('Edge cases', () => { expect(photos).toHaveLength(0); }); - it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', () => { + it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1638,12 +1638,12 @@ describe('Edge cases', () => { // -- Passphrase on addProviderPhoto ------------------------------------------- describe('addProviderPhoto — passphrase', () => { - it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => { + it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' }); - const photo = svc.addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp'); + const photo = await svc.addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp'); expect(photo).not.toBeNull(); @@ -1668,7 +1668,7 @@ function insertEntry(journeyId: number, authorId: number, opts: { entry_date: st } describe('reorderEntries', () => { - it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => { + it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 }); @@ -1682,7 +1682,7 @@ describe('reorderEntries', () => { expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]); }); - it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => { + it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', async () => { const { user } = createUser(testDb); const j1 = createJourney(testDb, user.id); const j2 = createJourney(testDb, user.id); @@ -1692,7 +1692,7 @@ describe('reorderEntries', () => { expect(ok).toBe(false); }); - it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => { + it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 }); @@ -1708,7 +1708,7 @@ describe('reorderEntries', () => { }); describe('syncTripPlaces sort_order', () => { - it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => { + it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1736,7 +1736,7 @@ describe('syncTripPlaces sort_order', () => { }); describe('onPlaceCreated sort_order', () => { - it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => { + it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { @@ -1790,7 +1790,7 @@ describe('reconcileTripSkeletons', () => { .get(journeyId, placeId) as any; } - it('JOURNEY-SVC-094: adds a skeleton for a newly assigned place', () => { + it('JOURNEY-SVC-094: adds a skeleton for a newly assigned place', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'New Museum' }); @@ -1805,7 +1805,7 @@ describe('reconcileTripSkeletons', () => { expect(skeleton.entry_date).toBe(days[0].date); }); - it('JOURNEY-SVC-095: removes a pure skeleton when its place is unassigned', () => { + it('JOURNEY-SVC-095: removes a pure skeleton when its place is unassigned', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'To Remove' }); @@ -1819,7 +1819,7 @@ describe('reconcileTripSkeletons', () => { expect(skeletonFor(journey.id, place.id)).toBeUndefined(); }); - it('JOURNEY-SVC-096: preserves a filled entry on unassign (detaches + notes it)', () => { + it('JOURNEY-SVC-096: preserves a filled entry on unassign (detaches + notes it)', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Filled Place' }); @@ -1843,7 +1843,7 @@ describe('reconcileTripSkeletons', () => { expect(kept.story).toContain('was removed from the trip plan'); }); - it('JOURNEY-SVC-097: refreshes skeleton entry_date when a place is moved to another day', () => { + it('JOURNEY-SVC-097: refreshes skeleton entry_date when a place is moved to another day', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Moving Place' }); @@ -1857,7 +1857,7 @@ describe('reconcileTripSkeletons', () => { expect(skeletonFor(journey.id, place.id).entry_date).toBe(days[1].date); }); - it('JOURNEY-SVC-098: is idempotent — a second call makes no changes', () => { + it('JOURNEY-SVC-098: is idempotent — a second call makes no changes', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Stable Place' }); @@ -1875,7 +1875,7 @@ describe('reconcileTripSkeletons', () => { expect(after).toEqual(before); }); - it('JOURNEY-SVC-099: no-ops when the trip is linked to no journey', () => { + it('JOURNEY-SVC-099: no-ops when the trip is linked to no journey', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Unlinked', start_date: '2026-05-01', end_date: '2026-05-02' }); const days = daysOf(trip.id); @@ -1916,7 +1916,7 @@ describe('a place standing on more than one day', () => { .all(journeyId, placeId) as any[]; } - it('JOURNEY-SVC-REPEAT-001: syncTripPlaces writes one skeleton per day, not one per place', () => { + it('JOURNEY-SVC-REPEAT-001: syncTripPlaces writes one skeleton per day, not one per place', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Two Nights', start_date: '2026-05-01', end_date: '2026-05-03' }); @@ -1930,7 +1930,7 @@ describe('a place standing on more than one day', () => { expect(skeletonsFor(journey.id, place.id).map((e) => e.entry_date)).toEqual([days[0].date, days[1].date]); }); - it('JOURNEY-SVC-REPEAT-002: a second call adds nothing', () => { + it('JOURNEY-SVC-REPEAT-002: a second call adds nothing', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Idempotent', start_date: '2026-05-01', end_date: '2026-05-03' }); @@ -1945,7 +1945,7 @@ describe('a place standing on more than one day', () => { expect(skeletonsFor(journey.id, place.id)).toHaveLength(2); }); - it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', () => { + it('JOURNEY-SVC-REPEAT-003: onPlaceCreated fires once per day the place already stands on', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Höfn' }); @@ -1957,7 +1957,7 @@ describe('a place standing on more than one day', () => { 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', () => { + 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 days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Akureyri' }); @@ -1976,7 +1976,7 @@ describe('a place standing on more than one day', () => { expect(both[1].type).toBe('skeleton'); }); - it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', () => { + it('JOURNEY-SVC-REPEAT-005: unassigning one day drops only that day', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Selfoss' }); @@ -1991,7 +1991,7 @@ describe('a place standing on more than one day', () => { 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', () => { + it('JOURNEY-SVC-REPEAT-006: moving one of the two assignments moves its entry rather than replacing it', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Geysir' }); @@ -2008,7 +2008,7 @@ describe('a place standing on more than one day', () => { expect(after[1].id).toBe(movedId); }); - it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', () => { + it('JOURNEY-SVC-REPEAT-007: editing the place leaves each entry on its own day', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Old Name' }); @@ -2024,7 +2024,7 @@ describe('a place standing on more than one day', () => { expect(after.map((e) => e.title)).toEqual(['New Name', 'New Name']); }); - it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', () => { + it('JOURNEY-SVC-REPEAT-008: an entry with no assignment link is claimed, not annotated out', async () => { const { journey, trip } = linkedJourneyTrip(); const days = daysOf(trip.id); const place = createPlace(testDb, trip.id, { name: 'Legacy Stop' }); @@ -2068,7 +2068,7 @@ function skeletonsOf(journeyId: number) { } describe('skeleton sync', () => { - it('JOURNEY-SVC-SKEL-001: linking a trip materialises one skeleton per assigned place', () => { + 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] }); @@ -2080,7 +2080,7 @@ describe('skeleton sync', () => { expect(skeletons[0].source_trip_id).toBe(trip.id); }); - it('JOURNEY-SVC-SKEL-002: syncing the same trip twice does not duplicate skeletons', () => { + 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] }); @@ -2088,7 +2088,7 @@ describe('skeleton sync', () => { expect(skeletonsOf(journey.id)).toHaveLength(1); }); - it('JOURNEY-SVC-SKEL-003: onPlaceCreated adds a skeleton to every journey the trip is linked to', () => { + 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] }); @@ -2103,13 +2103,13 @@ describe('skeleton sync', () => { } }); - it('JOURNEY-SVC-SKEL-004: onPlaceCreated is a no-op for a trip in no journey', () => { + 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(); }); - it('JOURNEY-SVC-SKEL-005: onPlaceUpdated carries the rename and the new address onto the skeleton', () => { + 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] }); @@ -2122,13 +2122,13 @@ describe('skeleton sync', () => { expect(skeleton.location_name).toBe('1 Kinkakujicho'); }); - it('JOURNEY-SVC-SKEL-006: onPlaceUpdated is a no-op when no skeleton points at the place', () => { + 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(); }); - it('JOURNEY-SVC-SKEL-007: onPlaceDeleted drops an empty skeleton outright', () => { + 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] }); @@ -2138,7 +2138,7 @@ describe('skeleton sync', () => { 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', () => { + 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] }); @@ -2156,7 +2156,7 @@ describe('skeleton sync', () => { expect(kept.story).toContain('removed from the trip plan'); }); - it('JOURNEY-SVC-SKEL-009: reconcileTripSkeletons adds what is missing and removes what is gone', () => { + 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] }); @@ -2173,13 +2173,13 @@ describe('skeleton sync', () => { expect(titles).not.toContain('Fushimi Inari'); }); - it('JOURNEY-SVC-SKEL-010: reconcileTripSkeletons is a no-op for a trip in no journey', () => { + 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(); }); - it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', () => { + it('JOURNEY-SVC-SKEL-011: createJourney takes its cover from the first linked trip and strips the /uploads prefix', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare('UPDATE trips SET cover_image = ? WHERE id = ?').run('/uploads/covers/kyoto.jpg', trip.id); @@ -2198,29 +2198,29 @@ describe('journey gallery', () => { return { user, journey, entry }; } - it('JOURNEY-SVC-PHOTO-001: addPhoto puts the file in the gallery and links it to the 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 photo = svc.addPhoto(entry.id, user.id, 'journey/a.jpg', 'journey/a-thumb.jpg', 'Torii'); + 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)!; 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', () => { + it('JOURNEY-SVC-PHOTO-002: addPhoto refuses an unknown entry and a non-editor', async () => { const { entry } = ownedEntry(); const { user: stranger } = createUser(testDb); - expect(svc.addPhoto(999999, 1, 'journey/a.jpg')).toBeNull(); - expect(svc.addPhoto(entry.id, stranger.id, 'journey/a.jpg')).toBeNull(); + expect(await svc.addPhoto(999999, 1, 'journey/a.jpg')).toBeNull(); + expect(await svc.addPhoto(entry.id, stranger.id, 'journey/a.jpg')).toBeNull(); }); - it('JOURNEY-SVC-PHOTO-003: uploadGalleryPhotos appends in order and refuses a non-editor', () => { + 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 first = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/1.jpg' }]); - const second = svc.uploadGalleryPhotos(journey.id, user.id, [ + const first = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/1.jpg' }]); + const second = await svc.uploadGalleryPhotos(journey.id, user.id, [ { path: 'journey/2.jpg', thumbnail: 'journey/2-t.jpg' }, { path: 'journey/3.mp4', mediaType: 'video', durationMs: 4200 }, ]); @@ -2232,12 +2232,12 @@ describe('journey gallery', () => { .all(journey.id) as any[]; expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2]); - expect(svc.uploadGalleryPhotos(journey.id, stranger.id, [{ path: 'journey/x.jpg' }])).toEqual([]); + expect(await svc.uploadGalleryPhotos(journey.id, stranger.id, [{ path: 'journey/x.jpg' }])).toEqual([]); }); - it('JOURNEY-SVC-PHOTO-004: linkPhotoToEntry attaches a gallery row, unlinkPhotoFromEntry detaches it', () => { + it('JOURNEY-SVC-PHOTO-004: linkPhotoToEntry attaches a gallery row, unlinkPhotoFromEntry detaches it', async () => { const { user, journey, entry } = ownedEntry(); - const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + 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); @@ -2248,10 +2248,10 @@ describe('journey gallery', () => { 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', () => { + it('JOURNEY-SVC-PHOTO-005: link/unlink refuse an unknown entry and a non-editor', async () => { const { user, journey, entry } = ownedEntry(); const { user: stranger } = createUser(testDb); - const [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + 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(); @@ -2259,38 +2259,38 @@ describe('journey gallery', () => { expect(svc.unlinkPhotoFromEntry(entry.id, gallery.id, stranger.id)).toBe(false); }); - it('JOURNEY-SVC-PHOTO-006: deleteGalleryPhoto removes the row and refuses an unknown id', () => { + 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 [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); - expect(svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy(); + expect(await svc.deleteGalleryPhoto(gallery.id, user.id)).toBeTruthy(); expect(testDb.prepare('SELECT 1 FROM journey_photos WHERE id = ?').get(gallery.id)).toBeUndefined(); - expect(svc.deleteGalleryPhoto(999999, user.id)).toBeNull(); + expect(await svc.deleteGalleryPhoto(999999, user.id)).toBeNull(); }); - it('JOURNEY-SVC-PHOTO-007: deleteGalleryPhoto refuses a non-editor', () => { + 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 [gallery] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); - expect(svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull(); + const [gallery] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/a.jpg' }]); + expect(await svc.deleteGalleryPhoto(gallery.id, stranger.id)).toBeNull(); }); // #2200: the gallery used to come out in upload order, so pictures caught up // 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', () => { + 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 shot = (path: string, takenAt: string) => { - const [row] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]); + const shot = async (path: string, takenAt: string) => { + const [row] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path }]); testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run(takenAt, row.photo_id); }; - shot('journey/day3.jpg', '2026-05-03T18:00:00.000Z'); - shot('journey/day1.jpg', '2026-05-01T08:30:00.000Z'); - shot('journey/day2.jpg', '2026-05-02T12:00:00.000Z'); + await shot('journey/day3.jpg', '2026-05-03T18:00:00.000Z'); + 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 }[]; expect(gallery.map((p) => p.file_path)).toEqual([ @@ -2300,7 +2300,7 @@ describe('journey gallery', () => { ]); }); - it('JOURNEY-SVC-PHOTO-009: a photo with no capture time rides on the date of the stop it hangs on', () => { + 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' })!; @@ -2308,9 +2308,9 @@ describe('journey gallery', () => { // 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] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day2.jpg' }]); - const [dayOnePhoto] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/day1.jpg' }]); - const [loose] = svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/loose.jpg' }]); + 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); // Pin the loose photo's upload time so the run does not depend on today's date. @@ -2328,21 +2328,21 @@ describe('journey gallery', () => { }); describe('per-user journey preferences', () => { - it('JOURNEY-SVC-PREF-001: hide_skeletons round-trips for the owner', () => { + 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 }); }); - it('JOURNEY-SVC-PREF-002: an empty patch is accepted and changes nothing', () => { + 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 }); }); - it('JOURNEY-SVC-PREF-003: a stranger gets null, not a row', () => { + 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' }); @@ -2351,7 +2351,7 @@ describe('per-user journey preferences', () => { }); describe('entry enrichment', () => { - it('JOURNEY-SVC-ENRICH-001: tags and pros_cons come back parsed, and source_trip_name is resolved', () => { + it('JOURNEY-SVC-ENRICH-001: tags and pros_cons come back parsed, and source_trip_name is resolved', async () => { const { user } = createUser(testDb); const { trip } = tripWithPlace(user.id); testDb.prepare('UPDATE trips SET title = ? WHERE id = ?').run('Japan 2026', trip.id); @@ -2368,7 +2368,7 @@ describe('entry enrichment', () => { expect((entry as any).source_trip_name).toBe('Japan 2026'); }); - it('JOURNEY-SVC-ENRICH-002: an entry with no trip and no tags gets [] and null, not undefined', () => { + 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' })!; @@ -2388,7 +2388,7 @@ describe('journeyTracks', () => { .prepare('UPDATE places SET route_geometry = ?, route_color = ? WHERE id = ?') .run(typeof geometry === 'string' ? geometry : JSON.stringify(geometry), color, placeId); - it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', () => { + it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000'); @@ -2400,7 +2400,7 @@ describe('journeyTracks', () => { expect(tracks[0].points).toEqual([[35.1, 135.7], [35.2, 135.8]]); }); - it('JOURNEY-SVC-TRACKS-002: keeps the elevation out and the pair in', () => { + it('JOURNEY-SVC-TRACKS-002: keeps the elevation out and the pair in', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); // The importer keeps elevation as a third value where the file had it. @@ -2410,7 +2410,7 @@ describe('journeyTracks', () => { expect(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', () => { + 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] }); @@ -2418,7 +2418,7 @@ describe('journeyTracks', () => { expect(svc.journeyTracks(journey.id, user.id)).toEqual([]); }); - it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', () => { + it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); const second = createPlace(testDb, trip.id, { name: 'Good one' }); @@ -2430,7 +2430,7 @@ describe('journeyTracks', () => { expect(tracks.map(t => t.place_id)).toEqual([second.id]); }); - it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', () => { + it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', async () => { const { user } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); withGeometry(place.id, [[35.1, 135.7]]); @@ -2439,7 +2439,7 @@ describe('journeyTracks', () => { expect(svc.journeyTracks(journey.id, user.id)).toEqual([]); }); - it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', () => { + it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const { trip, place } = tripWithPlace(user.id); @@ -2453,7 +2453,7 @@ describe('journeyTracks', () => { // ── Trip linking: whose journey, and whose photos (#1614 review) ───────────── describe('addTripToJourney guards', () => { - it('JOURNEY-SVC-100: refuses to link into a journey the caller cannot reach', () => { + it('JOURNEY-SVC-100: refuses to link into a journey the caller cannot reach', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); const journey = createJourney(testDb, owner.id, { title: "Owner's journey" }); @@ -2465,7 +2465,7 @@ describe('addTripToJourney guards', () => { expect(links).toHaveLength(0); }); - it('JOURNEY-SVC-101: a contributor may still link, the owner obviously too', () => { + it('JOURNEY-SVC-101: a contributor may still link, the owner obviously too', async () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id, { title: 'Shared journey' }); @@ -2476,7 +2476,7 @@ describe('addTripToJourney guards', () => { expect(svc.addTripToJourney(journey.id, trip.id, helper.id)).toBe(true); }); - it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', () => { + it('JOURNEY-SVC-102: not even a shared trip photo is copied any more', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id, { title: 'Photo journey' }); const trip = createTrip(testDb, user.id, { title: 'Photo trip' }); @@ -2497,7 +2497,7 @@ describe('addTripToJourney guards', () => { // -- Dismissing a suggestion (discussion #2299) -------------------------------- describe('dismissed suggestions', () => { - it('JOURNEY-SVC-103: a dismissed suggestion leaves every read but keeps its row', () => { + it('JOURNEY-SVC-103: a dismissed suggestion leaves every read but keeps its row', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const keep = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton', title: 'Museum' }); @@ -2514,7 +2514,7 @@ describe('dismissed suggestions', () => { }); }); - it('JOURNEY-SVC-104: the journey reports how many were dismissed', () => { + it('JOURNEY-SVC-104: the journey reports how many were dismissed', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); @@ -2527,7 +2527,7 @@ describe('dismissed suggestions', () => { expect(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', () => { + it('JOURNEY-SVC-105: a dismissed place is not offered again by the trip sync', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id); @@ -2544,7 +2544,7 @@ describe('dismissed suggestions', () => { expect(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', () => { + it('JOURNEY-SVC-106: restoring brings them all back and says how many', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const a = createJourneyEntry(testDb, journey.id, user.id, { type: 'skeleton' }); @@ -2556,14 +2556,14 @@ describe('dismissed suggestions', () => { expect(svc.listEntries(journey.id, user.id)).toHaveLength(2); }); - it('JOURNEY-SVC-107: restoring nothing is not an error', () => { + 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 }); }); - it('JOURNEY-SVC-108: a viewer may not restore', () => { + it('JOURNEY-SVC-108: a viewer may not restore', async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const journey = createJourney(testDb, owner.id); @@ -2576,7 +2576,7 @@ describe('dismissed suggestions', () => { // -- The country behind an entry's coordinates --------------------------------- describe('country_code', () => { - it('JOURNEY-SVC-109: a created entry resolves its country from its coordinates', () => { + it('JOURNEY-SVC-109: a created entry resolves its country from its coordinates', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -2589,7 +2589,7 @@ describe('country_code', () => { expect(entry.country_code).toBe('DE'); }); - it('JOURNEY-SVC-110: an entry with no coordinates has no country', () => { + it('JOURNEY-SVC-110: an entry with no coordinates has no country', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -2598,7 +2598,7 @@ describe('country_code', () => { expect(entry.country_code).toBeNull(); }); - it('JOURNEY-SVC-111: moving the pin moves the country with it', () => { + 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, { @@ -2612,7 +2612,7 @@ describe('country_code', () => { expect(moved.country_code).toBe('FR'); }); - it('JOURNEY-SVC-112: taking the pin off clears the country', () => { + 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, { @@ -2629,7 +2629,7 @@ describe('country_code', () => { expect(cleared.country_code).toBeNull(); }); - it('JOURNEY-SVC-114: a place that moves across a border moves its skeleton entry country too', () => { + it('JOURNEY-SVC-114: a place that moves across a border moves its skeleton entry country too', async () => { // The pin can also move without anybody editing the entry: the place it came // from is dragged, and the trip sync writes the new coordinates onto the // skeleton. The flag has to follow that write like it follows a manual one. @@ -2655,7 +2655,7 @@ describe('country_code', () => { expect(after.country_code).toBe('DE'); }); - it('JOURNEY-SVC-115: and so does a filled entry, whose location follows the place silently', () => { + it('JOURNEY-SVC-115: and so does a filled entry, whose location follows the place silently', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); const trip = createTrip(testDb, user.id, { title: 'Border 2', start_date: '2026-08-01', end_date: '2026-08-03' }); @@ -2675,7 +2675,7 @@ describe('country_code', () => { expect(after).toMatchObject({ country_code: 'DE', location_lat: 52.52 }); }); - it('JOURNEY-SVC-113: an edit that does not touch the pin leaves the country alone', () => { + 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, { @@ -2693,7 +2693,7 @@ describe('country_code', () => { // -- Which optional fields a journey keeps ------------------------------------- describe('entry field switches', () => { - it('JOURNEY-SVC-114: a fresh journey keeps all three', () => { + it('JOURNEY-SVC-114: a fresh journey keeps all three', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -2702,7 +2702,7 @@ describe('entry field switches', () => { expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 1, 1]); }); - it('JOURNEY-SVC-115: the owner can put one away, and booleans reach the INTEGER column', () => { + it('JOURNEY-SVC-115: the owner can put one away, and booleans reach the INTEGER column', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); @@ -2712,7 +2712,7 @@ describe('entry field switches', () => { expect([row.show_verdict, row.show_mood, row.show_weather]).toEqual([1, 0, 0]); }); - it('JOURNEY-SVC-116: an editor may not reshape the journey', () => { + it('JOURNEY-SVC-116: an editor may not reshape the journey', async () => { const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 4fdc11e63..9860fc5e2 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -86,11 +86,11 @@ describe('JourneyController', () => { expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial)).deleteEntry(user, '3')).toEqual({ success: true }); }); - it('provider-photos: batch, single 400/403, success', () => { + it('provider-photos: batch, single 400/403, success', async () => { const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial); - expect(ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); - expect(thrown(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } }); - expect(thrown(() => 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' } }); + expect(await ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); + expect(await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } }); + 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', () => { @@ -136,21 +136,21 @@ describe('JourneyController', () => { expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]); }); - it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => { + it('provider-photos forwards per-asset media_types for gallery and entries (#823)', async () => { const add = vi.fn().mockReturnValue({ id: 1 }); - ctl(svc({ addProviderPhotoToGallery: add } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] }); + await ctl(svc({ addProviderPhotoToGallery: add } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] }); expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video'); expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image'); const addOne = vi.fn().mockReturnValue({ id: 2 }); - ctl(svc({ addProviderPhotoToGallery: addOne } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' }); + await ctl(svc({ addProviderPhotoToGallery: addOne } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' }); expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video'); // Entry path mirrors the gallery path. const eAdd = vi.fn().mockReturnValue({ id: 3 }); - ctl(svc({ addProviderPhoto: eAdd } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' }); + await ctl(svc({ addProviderPhoto: eAdd } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' }); expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video'); const eOne = vi.fn().mockReturnValue({ id: 4 }); - ctl(svc({ addProviderPhoto: eOne } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' }); + await ctl(svc({ addProviderPhoto: eOne } as Partial)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' }); expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video'); }); @@ -264,12 +264,12 @@ describe('JourneyController', () => { expect(await ctl(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] }); }); - it('provider-photos batch passes the passphrase through when present', () => { + it('provider-photos batch passes the passphrase through when present', async () => { const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 }); - ctl(svc({ addProviderPhoto } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' }); + await ctl(svc({ addProviderPhoto } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' }); expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image'); // single-photo success path - expect(ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 }); + 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', () => { @@ -291,14 +291,14 @@ describe('JourneyController', () => { expect(storageStub.delete).not.toHaveBeenCalled(); }); - it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => { + it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => { const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 }); const batch = ctl(svc({ addProviderPhotoToGallery } as Partial)); - expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); + expect(await batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image'); - expect(thrown(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } }); - expect(thrown(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); - expect(ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 }); + expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } }); + expect(await thrownAsync(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); + expect(await ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 }); }); it('DELETE gallery photo: 404, then removes the object when present', async () => { diff --git a/server/tests/unit/nest/memories-access.service.test.ts b/server/tests/unit/nest/memories-access.service.test.ts index ecb42e5b2..19d7674a2 100644 --- a/server/tests/unit/nest/memories-access.service.test.ts +++ b/server/tests/unit/nest/memories-access.service.test.ts @@ -57,12 +57,14 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip } from '../../helpers/factories'; -import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers'; +import { mapDbError, pipeAsset, type ServiceResult } from '../../../src/nest/memories/memories.helpers'; import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; const access = new MemoriesAccessService(new DatabaseService(testDb)); -const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access); +// A typed forwarder, not a `.bind` alias: a bound alias is typed `any`, which +// hides a missing `await` from tsc and from all three lint rules. +const getAlbumIdFromLink = (...a: Parameters) => access.getAlbumIdFromLink(...a); import { SsrfBlockedError } from '../../../src/utils/ssrfGuard'; beforeAll(() => { @@ -82,7 +84,7 @@ afterAll(() => { // ── mapDbError ──────────────────────────────────────────────────────────────── describe('mapDbError', () => { - it('MEM-HELPERS-001: returns 409 for unique constraint error', () => { + it('MEM-HELPERS-001: returns 409 for unique constraint error', async () => { const err = new Error('UNIQUE constraint failed: users.email'); const result = mapDbError(err, 'fallback'); expect(result.success).toBe(false); @@ -90,14 +92,14 @@ describe('mapDbError', () => { expect(result.error.message).toBe('Resource already exists'); }); - it('MEM-HELPERS-002: returns 409 for generic constraint error', () => { + it('MEM-HELPERS-002: returns 409 for generic constraint error', async () => { const err = new Error('constraint violation'); const result = mapDbError(err, 'fallback'); expect(result.success).toBe(false); expect(result.error.status).toBe(409); }); - it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => { + it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', async () => { const err = new Error('Something went wrong'); const result = mapDbError(err, 'fallback'); expect(result.success).toBe(false); @@ -105,7 +107,7 @@ describe('mapDbError', () => { expect(result.error.message).toBe('Something went wrong'); }); - it('MEM-HELPERS-004: returns 500 for generic DB error', () => { + it('MEM-HELPERS-004: returns 500 for generic DB error', async () => { const err = new Error('disk I/O error'); const result = mapDbError(err, 'fallback'); expect(result.error.status).toBe(500); @@ -114,24 +116,34 @@ describe('mapDbError', () => { // ── getAlbumIdFromLink ──────────────────────────────────────────────────────── +/** + * The failure arm of a ServiceResult. The assertions below used to read + * `result.error` through a `.bind` alias that typed the whole thing `any`; the + * typed forwarder narrows properly instead of casting. + */ +function failureOf(result: ServiceResult): { message: string; status: number } { + if (!('error' in result)) throw new Error('expected a failing ServiceResult'); + return result.error; +} + describe('getAlbumIdFromLink', () => { - it('MEM-HELPERS-005: returns 404 when trip access is denied', () => { - const result = getAlbumIdFromLink('9999', 'link-1', 1); + it('MEM-HELPERS-005: returns 404 when trip access is denied', async () => { + const result = await getAlbumIdFromLink('9999', 'link-1', 1); expect(result.success).toBe(false); - expect(result.error.status).toBe(404); + expect(failureOf(result).status).toBe(404); }); - it('MEM-HELPERS-006: returns 404 when album link is not found', () => { + it('MEM-HELPERS-006: returns 404 when album link is not found', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id); + const result = await getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id); expect(result.success).toBe(false); - expect(result.error.status).toBe(404); - expect(result.error.message).toBe('Album link not found'); + expect(failureOf(result).status).toBe(404); + expect(failureOf(result).message).toBe('Album link not found'); }); - it('MEM-HELPERS-007: returns album_id when link exists', () => { + it('MEM-HELPERS-007: returns album_id when link exists', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -141,7 +153,7 @@ describe('getAlbumIdFromLink', () => { ).run(trip.id, user.id, 'immich', 'album-123', 'My Album'); const linkId = ins.lastInsertRowid; - const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id); + const result = await getAlbumIdFromLink(String(trip.id), String(linkId), user.id); expect(result.success).toBe(true); expect((result as any).data).toBe('album-123'); }); @@ -308,50 +320,50 @@ function makeJourney(userId: number): number { } describe('canAccessUserPhoto', () => { - it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => { - expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true); + it('MEM-ACCESS-001: the owner always passes, with no lookup needed', async () => { + expect(await access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true); }); - it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => { + it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb, { username: 'member' }); const trip = createTrip(testDb, owner.id); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); shareInTrip(trip.id, owner.id, 'asset-shared'); - expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true); + expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true); }); - it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => { + it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb, { username: 'member' }); const trip = createTrip(testDb, owner.id); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0); - expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false); + expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false); }); - it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => { + it('MEM-ACCESS-004: a stranger is refused even for a shared asset', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb, { username: 'stranger' }); const trip = createTrip(testDb, owner.id); shareInTrip(trip.id, owner.id, 'asset-shared'); - expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false); + expect(await access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false); }); - it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => { + it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb, { username: 'member' }); const trip = createTrip(testDb, owner.id); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); shareInTrip(trip.id, owner.id, 'same-id', 'immich'); - expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false); + expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false); }); - it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => { + it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', async () => { const { user: owner } = createUser(testDb); const { user: contributor } = createUser(testDb, { username: 'contrib' }); const journeyId = makeJourney(owner.id); @@ -361,10 +373,10 @@ describe('canAccessUserPhoto', () => { testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id); - expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true); + expect(await access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true); }); - it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => { + it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb, { username: 'stranger' }); const journeyId = makeJourney(owner.id); @@ -373,31 +385,31 @@ describe('canAccessUserPhoto', () => { ).run(owner.id).lastInsertRowid); testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); - expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false); + expect(await access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false); }); - it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => { + it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false); + expect(await access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false); }); }); describe('canAccessTrekPhoto', () => { - it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => { - expect(access.canAccessTrekPhoto(1, 999999)).toBe(false); + it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', async () => { + expect(await access.canAccessTrekPhoto(1, 999999)).toBe(false); }); - it('MEM-ACCESS-011: the owner passes', () => { + it('MEM-ACCESS-011: the owner passes', async () => { const { user } = createUser(testDb); const photoId = Number(testDb.prepare( "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)" ).run(user.id).lastInsertRowid); - expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true); + expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(true); }); - it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => { + it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb, { username: 'member' }); const { user: stranger } = createUser(testDb, { username: 'stranger' }); @@ -405,20 +417,20 @@ describe('canAccessTrekPhoto', () => { testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id); const photoId = shareInTrip(trip.id, owner.id, 'trek-shared'); - expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true); - expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false); + expect(await access.canAccessTrekPhoto(member.id, photoId)).toBe(true); + expect(await access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false); }); - it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => { + it('MEM-ACCESS-013: the trip owner passes without a trip_members row', async () => { const { user: owner } = createUser(testDb); const { user: uploader } = createUser(testDb, { username: 'uploader' }); const trip = createTrip(testDb, owner.id); const photoId = shareInTrip(trip.id, uploader.id, 'owner-path'); - expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); + expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); }); - it('MEM-ACCESS-014: a journey contributor passes', () => { + it('MEM-ACCESS-014: a journey contributor passes', async () => { const { user: owner } = createUser(testDb); const { user: contributor } = createUser(testDb, { username: 'contrib' }); const journeyId = makeJourney(owner.id); @@ -428,10 +440,10 @@ describe('canAccessTrekPhoto', () => { testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id); - expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true); + expect(await access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true); }); - it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => { + it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', async () => { const { user: owner } = createUser(testDb); const { user: outsider } = createUser(testDb, { username: 'outsider' }); const journeyId = makeJourney(owner.id); @@ -440,16 +452,16 @@ describe('canAccessTrekPhoto', () => { ).run().lastInsertRowid); testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId); - expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); - expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false); + expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true); + expect(await access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false); }); - it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => { + it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', async () => { const { user } = createUser(testDb); const photoId = Number(testDb.prepare( "INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')" ).run().lastInsertRowid); - expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false); + expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(false); }); }); diff --git a/server/tests/unit/nest/memories.controller.test.ts b/server/tests/unit/nest/memories.controller.test.ts index 5c78d4ed6..f4c3eb21b 100644 --- a/server/tests/unit/nest/memories.controller.test.ts +++ b/server/tests/unit/nest/memories.controller.test.ts @@ -195,19 +195,19 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif }); describe('DELETE /trips/:tripId/album-links/:linkId', () => { - it('removes the link on success', () => { + it('removes the link on success', async () => { const removeAlbumLink = vi.fn().mockReturnValue({ data: {} }); const svc = makeService({ removeAlbumLink }); const res = makeRes(); - new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); + await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); expect(res.json).toHaveBeenCalledWith({ success: true }); }); - it('maps the error envelope', () => { + it('maps the error envelope', async () => { const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) }); const res = makeRes(); - new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); + await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' }); }); diff --git a/server/tests/unit/nest/memories.service.test.ts b/server/tests/unit/nest/memories.service.test.ts index bb11a37d7..d16a26e0c 100644 --- a/server/tests/unit/nest/memories.service.test.ts +++ b/server/tests/unit/nest/memories.service.test.ts @@ -98,13 +98,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => await svc.setTripPhotoSharing('5', 7, 9, false); expect(unified.setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, false); - svc.removeTripPhoto('5', 7, 9); + await svc.removeTripPhoto('5', 7, 9); expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9); await svc.listTripAlbumLinks('5', 7); expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7); - svc.removeAlbumLink('5', 'l1', 7); + await svc.removeAlbumLink('5', 'l1', 7); expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7); }); @@ -117,13 +117,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () => }); it('immich methods delegate', async () => { - svc.immichGetConnectionSettings(7); + await svc.immichGetConnectionSettings(7); expect(immich.getConnectionSettings).toHaveBeenCalledWith(7); await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4'); expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4'); - svc.immichSetAutoUpload(7, true); + await svc.immichSetAutoUpload(7, true); expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true); await svc.immichGetConnectionStatus(7); diff --git a/server/tests/unit/nest/packing.controller.test.ts b/server/tests/unit/nest/packing.controller.test.ts index d29b867fa..dd3ba7454 100644 --- a/server/tests/unit/nest/packing.controller.test.ts +++ b/server/tests/unit/nest/packing.controller.test.ts @@ -51,9 +51,9 @@ function makeService(overrides: Partial = {}): PackingService { return svc; } -function thrown(fn: () => unknown): { status: number; body: unknown } { +async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { - fn(); + await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -66,9 +66,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { // trip-access.guard.test.ts with the check itself. describe('PackingController (parity with the legacy /api/trips/:tripId/packing route)', () => { - it('GET / returns items for an accessible trip', () => { + it('GET / returns items for an accessible trip', async () => { const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); + expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); }); describe('POST / (create)', () => { @@ -78,97 +78,97 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // bypass parameter pipes, so that path is covered by the e2e suite and // the schema spec in @trek/shared. - it('creates an item (owned by the creator) and broadcasts a Common item to the room', () => { + it('creates an item (owned by the creator) and broadcasts a Common item to the room', async () => { // Common item (is_private falsy) → viewersOf null → whole-room broadcast. const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 }); const broadcast = vi.fn(); const svc = makeService({ createItem, broadcast } as Partial); - expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } }); + expect(await new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } }); // Stamps the creator as owner (#858) and routes the broadcast to viewers. expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ name: 'Socks' }), user.id); expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks', is_private: 0 } }, 'sock'); }); - it('pings the room that bag weights moved after a create (#2191)', () => { + it('pings the room that bag weights moved after a create (#2191)', async () => { // The totals are summed server-side, so nothing else tells the other // members' screens to re-read them. const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 }); const broadcastBagTotals = vi.fn(); const svc = makeService({ createItem, broadcastBagTotals } as Partial); - new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock'); + await new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock'); expect(broadcastBagTotals).toHaveBeenCalledWith('5'); }); - it('pings after a delete, and after a bag delete that orphans its items (#2191)', () => { + it('pings after a delete, and after a bag delete that orphans its items (#2191)', async () => { const deleteItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 }); const broadcastBagTotals = vi.fn(); const svc = makeService({ deleteItem, broadcastBagTotals } as Partial); - new PackingController(svc).remove(user, '5', '9', 'sock'); + await new PackingController(svc).remove(user, '5', '9', 'sock'); expect(broadcastBagTotals).toHaveBeenCalledWith('5'); // bag_id is ON DELETE SET NULL: the bag's items land in the unassigned pile. const deleteBag = vi.fn().mockReturnValue(true); const bagPing = vi.fn(); const bagSvc = makeService({ deleteBag, broadcastBagTotals: bagPing } as Partial); - new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock'); + await new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock'); expect(bagPing).toHaveBeenCalledWith('5'); }); - it('does NOT ping when the update cannot move a weight (#2191)', () => { + it('does NOT ping when the update cannot move a weight (#2191)', async () => { // Checking an item off is the most frequent packing write there is, and // every ping costs each connected client a listBags round trip. const updateItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 }); const broadcastBagTotals = vi.fn(); const svc = makeService({ updateItem, broadcastBagTotals } as Partial); - new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock'); expect(broadcastBagTotals).not.toHaveBeenCalled(); - new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock'); expect(broadcastBagTotals).toHaveBeenCalledWith('5'); }); - it('routes a Shared item create only to the owner + recipients (#858)', () => { + it('routes a Shared item create only to the owner + recipients (#858)', async () => { const item = { id: 9, name: 'Power bank', is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] }; const createItem = vi.fn().mockReturnValue(item); const broadcastToViewers = vi.fn(); const svc = makeService({ createItem, broadcastToViewers } as Partial); - new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock'); + await new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock'); expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1, 2], 'sock'); }); - it('forwards weight_grams, bag_id and quantity to the service (#2154)', () => { + it('forwards weight_grams, bag_id and quantity to the service (#2154)', async () => { // The create route used to hand only the six legacy fields through, so // the values a caller sent vanished into a 201. const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Tent', is_private: 0 }); const svc = makeService({ createItem, broadcast: vi.fn() } as Partial); - new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock'); + await new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock'); expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ weight_grams: 250, bag_id: 3, quantity: 3 }), user.id); }); - it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => { + it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => { // Body validation error, so 400 — the 404 'Bag not found' stays with the // /bags/:bagId path routes. const broadcast = vi.fn(); const svc = makeService({ createItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial); - expect(thrown(() => new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({ status: 400, body: { error: 'Bag not found' }, }); expect(broadcast).not.toHaveBeenCalled(); }); }); - it('GET / lists items for the trip, scoped to the viewer (#858)', () => { + it('GET / lists items for the trip, scoped to the viewer (#858)', async () => { const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); const svc = makeService({ listItems } as Partial); - expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] }); + expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] }); expect(listItems).toHaveBeenCalledWith('5', user.id); }); describe('POST /import', () => { - it('400 when items is an empty array (bespoke check, schema permits [])', () => { + it('400 when items is an empty array (bespoke check, schema permits [])', async () => { const svc = makeService(); - expect(thrown(() => new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({ status: 400, body: { error: 'items must be a non-empty array' }, }); }); @@ -178,11 +178,11 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // path is covered by the e2e suite and the schema spec in @trek/shared. - it('imports (owned by the importer) and broadcasts per item', () => { + it('imports (owned by the importer) and broadcasts per item', async () => { const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); const broadcastItem = vi.fn(); const svc = makeService({ bulkImport, broadcastItem } as Partial); - const res = new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock'); + const res = await new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock'); expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }], count: 2 }); expect(bulkImport).toHaveBeenCalledWith('5', [{ name: 'a' }, { name: 'b' }], user.id); expect(broadcastItem).toHaveBeenCalledTimes(2); @@ -190,18 +190,18 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); describe('PUT /:id (update)', () => { - it('404 when the item is missing', () => { + it('404 when the item is missing', async () => { const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Item not found' }, }); }); - it('updates, forwards changed keys + acting user, and broadcasts (stays public)', () => { + it('updates, forwards changed keys + acting user, and broadcasts (stays public)', async () => { const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X' }); const broadcast = vi.fn(); const svc = makeService({ updateItem, broadcast } as Partial); - new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock'); // acting user id is forwarded so privatizing an unowned item can stamp the // owner (#858); checked is normalized to the 0/1 the SQL binds. expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: 1 }), ['name', 'checked'], undefined, user.id); @@ -209,56 +209,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X' } }, 'sock'); }); - it('keeps a private update scoped to the owner (#858)', () => { + it('keeps a private update scoped to the owner (#858)', async () => { const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 }); const broadcast = vi.fn(); const broadcastItem = vi.fn(); // Was already private before the change → owner-only update, no room broadcast. const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 }); const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial); - new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock'); expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X', is_private: 1, owner_id: 1 } }, { id: 9, name: 'X', is_private: 1, owner_id: 1 }, 'sock'); expect(broadcast).not.toHaveBeenCalled(); }); - it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', () => { + it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', async () => { const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 }); const broadcast = vi.fn(); const broadcastItem = vi.fn(); // Was public before → public→private transition. const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 0, owner_id: 1 }); const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial); - new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock'); expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:created', expect.anything(), expect.anything(), 'sock'); }); - it('re-shares a freshly-public item to the whole room (#858)', () => { + it('re-shares a freshly-public item to the whole room (#858)', async () => { const updated = { id: 9, name: 'X', is_private: 0, owner_id: 1 }; const updateItem = vi.fn().mockReturnValue(updated); const broadcast = vi.fn(); // Was private before → private→public transition: create for those missing it, then update for all. const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 }); const svc = makeService({ updateItem, broadcast, getItemPrivacy } as Partial); - new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock'); + await new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: updated }, 'sock'); }); - it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => { + it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => { const broadcast = vi.fn(); const svc = makeService({ updateItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial); - expect(thrown(() => new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({ status: 400, body: { error: 'Bag not found' }, }); expect(broadcast).not.toHaveBeenCalled(); }); - it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', () => { + it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', async () => { const updateItem = vi.fn().mockReturnValue({ conflict: true, server: { id: 9, name: 'Theirs' } }); const broadcast = vi.fn(); const svc = makeService({ updateItem, broadcast } as Partial); - expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({ status: 409, body: { error: 'conflict', server: { id: 9, name: 'Theirs' } }, }); expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), ['name'], '2026-01-01 00:00:00', user.id); @@ -267,38 +267,38 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); describe('PUT /reorder', () => { - it('reorders the items and reports success', () => { + it('reorders the items and reports success', async () => { const reorderItems = vi.fn(); const svc = makeService({ reorderItems } as Partial); - expect(new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); + expect(await new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true }); expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]); }); }); describe('DELETE /:id (remove)', () => { - it('404 when the item is missing', () => { + it('404 when the item is missing', async () => { const svc = makeService({ deleteItem: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' }, }); }); - it('deletes a Common item and broadcasts to the room', () => { + it('deletes a Common item and broadcasts to the room', async () => { const deleted = { id: 9, is_private: 0, owner_id: 1 }; const deleteItem = vi.fn().mockReturnValue(deleted); const broadcast = vi.fn(); const svc = makeService({ deleteItem, broadcast } as Partial); - expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true }); + expect(await new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock'); }); - it('scopes the delete of a Shared item to the owner + recipients (#858)', () => { + it('scopes the delete of a Shared item to the owner + recipients (#858)', async () => { const deleted = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] }; const deleteItem = vi.fn().mockReturnValue(deleted); const broadcastToViewers = vi.fn(); const svc = makeService({ deleteItem, broadcastToViewers } as Partial); - new PackingController(svc).remove(user, '5', '9', 'sock'); + await new PackingController(svc).remove(user, '5', '9', 'sock'); expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, [1, 2], 'sock'); }); }); @@ -308,56 +308,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // (packingSetSharingRequestSchema requires the enum) — direct method calls // bypass parameter pipes, so that path is covered by the e2e suite and the // schema spec in @trek/shared. - it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', () => { - expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } }); - expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } }); + it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', async () => { + expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } }); + expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } }); const updated = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] }; const setItemSharing = vi.fn().mockReturnValue(updated); const broadcast = vi.fn(); const broadcastToViewers = vi.fn(); const svc = makeService({ setItemSharing, broadcast, broadcastToViewers } as Partial); - new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock'); + await new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock'); expect(setItemSharing).toHaveBeenCalledWith('5', '9', user.id, 'shared', [2]); expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock'); expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, [1, 2], 'sock'); }); - it('POST /:id/clone 404 missing, else creates a personal copy for the caller', () => { - expect(thrown(() => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } }); + it('POST /:id/clone 404 missing, else creates a personal copy for the caller', async () => { + expect(await thrown(async () => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } }); const item = { id: 12, is_private: 1, owner_id: 1 }; const cloneItem = vi.fn().mockReturnValue(item); const broadcastToViewers = vi.fn(); const svc = makeService({ cloneItem, broadcastToViewers } as Partial); - expect(new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item }); + expect(await new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item }); expect(cloneItem).toHaveBeenCalledWith('5', '9', user.id); expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1], 'sock'); }); - it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', () => { - expect(thrown(() => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } }); + it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', async () => { + expect(await thrown(async () => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } }); const item = { id: 9, is_private: 0, contributors: [{ user_id: 1 }] }; const addContributor = vi.fn().mockReturnValue(item); const broadcast = vi.fn(); const svc = makeService({ addContributor, broadcast } as Partial); - new PackingController(svc).addContributor(user, '5', '9', 'sock'); + await new PackingController(svc).addContributor(user, '5', '9', 'sock'); expect(addContributor).toHaveBeenCalledWith('5', '9', user.id); expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock'); }); - it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', () => { + it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', async () => { const item = { id: 9, is_private: 0, contributors: [] }; const removeContributor = vi.fn().mockReturnValue(item); const broadcast = vi.fn(); const svc = makeService({ removeContributor, broadcast } as Partial); - new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock'); + await new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock'); expect(removeContributor).toHaveBeenCalledWith('5', '9', 2); expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock'); }); }); describe('bags', () => { - it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', () => { + it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', async () => { // One service call, so the SUM…GROUP BY runs once per request — this route // now fires on every item write for every connected client. const listBagsWithWeights = vi.fn().mockReturnValue({ @@ -365,16 +365,16 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r unassigned_weight_grams: 150, }); const svc = makeService({ listBagsWithWeights } as Partial); - expect(new PackingController(svc).listBags(user, '5')).toEqual({ + expect(await new PackingController(svc).listBags(user, '5')).toEqual({ bags: [{ id: 3, name: 'Carry-on', total_weight_grams: 800 }], unassigned_weight_grams: 150, }); expect(listBagsWithWeights).toHaveBeenCalledTimes(1); }); - it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', () => { + it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', async () => { const svc = makeService(); - expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Name is required' }, }); }); @@ -383,66 +383,66 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // (packingCreateBagRequestSchema requires a non-empty name) — direct method // calls bypass parameter pipes, so that path is covered by the e2e suite. - it('creates a bag and broadcasts', () => { + it('creates a bag and broadcasts', async () => { const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Carry-on' }); const broadcast = vi.fn(); const svc = makeService({ createBag, broadcast } as Partial); - expect(new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({ + expect(await new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({ bag: { id: 3, name: 'Carry-on' }, }); expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-created', { bag: { id: 3, name: 'Carry-on' } }, 'sock'); }); - it('forwards weight_limit_grams on bag create (#2154)', () => { + it('forwards weight_limit_grams on bag create (#2154)', async () => { const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Backpack', weight_limit_grams: 8000 }); const svc = makeService({ createBag, broadcast: vi.fn() } as Partial); - new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 }); + await new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 }); expect(createBag).toHaveBeenCalledWith('5', { name: 'Backpack', color: undefined, weight_limit_grams: 8000 }); }); - it('404 on bag update when missing', () => { + it('404 on bag update when missing', async () => { const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Bag not found' }, }); }); - it('updates a bag, forwards changed keys and broadcasts', () => { + it('updates a bag, forwards changed keys and broadcasts', async () => { const updateBag = vi.fn().mockReturnValue({ id: 3, name: 'X' }); const broadcast = vi.fn(); const svc = makeService({ updateBag, broadcast } as Partial); - new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock'); + await new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock'); expect(updateBag).toHaveBeenCalledWith('5', '3', expect.objectContaining({ name: 'X', color: '#000' }), ['name', 'color']); expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-updated', { bag: { id: 3, name: 'X' } }, 'sock'); }); - it('404 on bag delete when missing', () => { + it('404 on bag delete when missing', async () => { const svc = makeService({ deleteBag: vi.fn().mockReturnValue(false) } as Partial); - expect(thrown(() => new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Bag not found' }, }); }); - it('deletes a bag and broadcasts', () => { + it('deletes a bag and broadcasts', async () => { const deleteBag = vi.fn().mockReturnValue(true); const broadcast = vi.fn(); const svc = makeService({ deleteBag, broadcast } as Partial); - expect(new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true }); + expect(await new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-deleted', { bagId: 3 }, 'sock'); }); - it('404 on set-members when the bag is missing', () => { + it('404 on set-members when the bag is missing', async () => { const svc = makeService({ setBagMembers: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({ status: 404, body: { error: 'Bag not found' }, }); }); - it('sets bag members and broadcasts', () => { + it('sets bag members and broadcasts', async () => { const setBagMembers = vi.fn().mockReturnValue([{ user_id: 1 }, { user_id: 2 }]); const broadcast = vi.fn(); const svc = makeService({ setBagMembers, broadcast } as Partial); - const res = new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock'); + const res = await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock'); expect(res).toEqual({ members: [{ user_id: 1 }, { user_id: 2 }] }); expect(setBagMembers).toHaveBeenCalledWith('5', '3', [1, 2]); expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-members-updated', { bagId: 3, members: [{ user_id: 1 }, { user_id: 2 }] }, 'sock'); @@ -454,27 +454,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); describe('templates', () => { - it('GET /templates returns the template list for an accessible trip', () => { + it('GET /templates returns the template list for an accessible trip', async () => { const listTemplates = vi.fn().mockReturnValue([{ id: 1, name: 'Beach', item_count: 4 }]); const svc = makeService({ listTemplates } as Partial); - expect(new PackingController(svc).listTemplates(user, '5')).toEqual({ + expect(await new PackingController(svc).listTemplates(user, '5')).toEqual({ templates: [{ id: 1, name: 'Beach', item_count: 4 }], }); }); - it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => { + it('404 when applying a missing/empty template (POST stays 200 otherwise)', async () => { const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({ status: 404, body: { error: 'Template not found or empty' }, }); }); - it('applies a template, broadcasts the added items and reports the count', () => { + it('applies a template, broadcasts the added items and reports the count', async () => { const items = [{ id: 1 }, { id: 2 }, { id: 3 }]; const applyTemplate = vi.fn().mockReturnValue(items); const broadcastItem = vi.fn(); const svc = makeService({ applyTemplate, broadcastItem } as Partial); - const res = new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock'); + const res = await new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock'); expect(res).toEqual({ items, count: 3 }); expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id); expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock'); @@ -482,27 +482,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // #1565: the template must follow the tab the user is on, and a personal apply // must not be broadcast to the rest of the trip. - it('applies a template into the personal list and keeps the broadcast to its owner', () => { + it('applies a template into the personal list and keeps the broadcast to its owner', async () => { const items = [{ id: 1, is_private: 1, owner_id: user.id }]; const applyTemplate = vi.fn().mockReturnValue(items); const broadcastItem = vi.fn(); const svc = makeService({ applyTemplate, broadcastItem } as Partial); - new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock'); + await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock'); expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'personal', user.id); expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock'); }); - it('falls back to the common pool for an unknown visibility', () => { + it('falls back to the common pool for an unknown visibility', async () => { const applyTemplate = vi.fn().mockReturnValue([{ id: 1 }]); const svc = makeService({ applyTemplate } as Partial); - new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never); + await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never); expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id); }); - it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', () => { + it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', async () => { const saveAsTemplate = vi.fn(); const svc = makeService({ saveAsTemplate } as Partial); - expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Template name is required' }, }); expect(saveAsTemplate).not.toHaveBeenCalled(); @@ -513,26 +513,26 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r // method calls bypass parameter pipes, so that path is covered by the e2e // suite and the schema spec in @trek/shared. - it('403 when a non-admin tries to save a template', () => { + it('403 when a non-admin tries to save a template', async () => { const saveAsTemplate = vi.fn(); const svc = makeService({ saveAsTemplate } as Partial); - expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({ status: 403, body: { error: 'Admin access required' }, }); expect(saveAsTemplate).not.toHaveBeenCalled(); }); - it('400 when an admin saves a template with no items', () => { + it('400 when an admin saves a template with no items', async () => { const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial); - expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({ + expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({ status: 400, body: { error: 'No items to save' }, }); }); - it('saves a template for an admin', () => { + it('saves a template for an admin', async () => { const saveAsTemplate = vi.fn().mockReturnValue({ id: 7, name: 'My template' }); const svc = makeService({ saveAsTemplate } as Partial); - expect(new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({ + expect(await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({ template: { id: 7, name: 'My template' }, }); expect(saveAsTemplate).toHaveBeenCalledWith('5', admin.id, 'My template'); @@ -540,30 +540,30 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r }); describe('category assignees', () => { - it('GET /category-assignees returns the assignee list for an accessible trip', () => { + it('GET /category-assignees returns the assignee list for an accessible trip', async () => { const getCategoryAssignees = vi.fn().mockReturnValue([{ category: 'Clothes', user_id: 2 }]); const svc = makeService({ getCategoryAssignees } as Partial); - expect(new PackingController(svc).categoryAssignees(user, '5')).toEqual({ + expect(await new PackingController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ category: 'Clothes', user_id: 2 }], }); expect(getCategoryAssignees).toHaveBeenCalledWith('5'); }); - it('decodes the URI-encoded category name before forwarding', () => { + it('decodes the URI-encoded category name before forwarding', async () => { const updateCategoryAssignees = vi.fn().mockReturnValue([]); const broadcast = vi.fn(); const notifyTagged = vi.fn(); const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial); - new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] }); + await new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] }); expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'Toys & Games', [2]); }); - it('updates assignees, broadcasts and fires the tag notification', () => { + it('updates assignees, broadcasts and fires the tag notification', async () => { const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn(); const notifyTagged = vi.fn(); const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial); - const res = new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock'); + const res = await new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock'); expect(res).toEqual({ assignees: [{ user_id: 2 }] }); expect(broadcast).toHaveBeenCalledWith('5', 'packing:assignees', { category: 'Clothes', assignees: [{ user_id: 2 }] }, 'sock'); expect(notifyTagged).toHaveBeenCalledWith('5', user, 'Clothes', [2]); diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index f1f130784..e01ba68b3 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -68,14 +68,16 @@ import { PackingService, isInvalidBagRef } from '../../../src/nest/packing/packi const bridgeListItems = (tripId: string | number, viewerId?: number) => svc.listItems(tripId, viewerId); import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; -const svc = new PackingService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), notificationsStub(send)); +let svc: PackingService; // ── Lifecycle ───────────────────────────────────────────────────────────────── -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new PackingService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), notificationsStub(send), await createTestUnitOfWork(testDb)); }); beforeEach(() => { @@ -90,7 +92,7 @@ afterAll(() => { // ── saveAsTemplate ──────────────────────────────────────────────────────────── describe('saveAsTemplate', () => { - it('PACK-SVC-001: saves packing items as a template with correct categories and item count', () => { + it('PACK-SVC-001: saves packing items as a template with correct categories and item count', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -98,7 +100,7 @@ describe('saveAsTemplate', () => { testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shorts', 'Clothes', 1); testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 2); - const result = svc.saveAsTemplate(trip.id, user.id, 'My Template'); + const result = await svc.saveAsTemplate(trip.id, user.id, 'My Template'); expect(result).not.toBeNull(); expect(result!.name).toBe('My Template'); @@ -111,11 +113,11 @@ describe('saveAsTemplate', () => { expect(template.created_by).toBe(user.id); }); - it('PACK-SVC-002: returns null when trip has no packing items', () => { + it('PACK-SVC-002: returns null when trip has no packing items', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.saveAsTemplate(trip.id, user.id, 'Empty'); + const result = await svc.saveAsTemplate(trip.id, user.id, 'Empty'); expect(result).toBeNull(); }); @@ -124,21 +126,21 @@ describe('saveAsTemplate', () => { // ── listTemplates ─────────────────────────────────────────────────────────────── describe('listTemplates', () => { - it('PACK-SVC-LIST-001: returns templates with id, name and item_count', () => { + it('PACK-SVC-LIST-001: returns templates with id, name and item_count', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0); testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 1); - const saved = svc.saveAsTemplate(trip.id, user.id, 'Weekend'); + const saved = await svc.saveAsTemplate(trip.id, user.id, 'Weekend'); - const templates = svc.listTemplates(); + const templates = await svc.listTemplates(); expect(templates).toHaveLength(1); expect(templates[0]).toMatchObject({ id: saved!.id, name: 'Weekend', item_count: 2 }); }); - it('PACK-SVC-LIST-002: returns an empty array when no templates exist', () => { - expect(svc.listTemplates()).toEqual([]); + it('PACK-SVC-LIST-002: returns an empty array when no templates exist', async () => { + expect(await svc.listTemplates()).toEqual([]); }); }); @@ -155,7 +157,7 @@ function seedTemplate(userId: number, itemNames: string[]): number { } describe('applyTemplate', () => { - it('PACK-SVC-003: adds template items to a trip packing list', () => { + it('PACK-SVC-003: adds template items to a trip packing list', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -169,7 +171,7 @@ describe('applyTemplate', () => { testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Tent', 0); testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Sleeping Bag', 1); - const result = svc.applyTemplate(trip.id, templateId); + const result = await svc.applyTemplate(trip.id, templateId); expect(result).not.toBeNull(); expect(Array.isArray(result)).toBe(true); @@ -181,76 +183,76 @@ describe('applyTemplate', () => { expect(items.map((i: any) => i.name)).toContain('Sleeping Bag'); }); - it('PACK-SVC-004: returns null when template has no items', () => { + it('PACK-SVC-004: returns null when template has no items', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Empty Template', user.id); const templateId = templateResult.lastInsertRowid as number; - const result = svc.applyTemplate(trip.id, templateId); + const result = await svc.applyTemplate(trip.id, templateId); expect(result).toBeNull(); }); // #1565: the applied items must land in the view the user is on, not always Common. - it('PACK-SVC-046: applies into the personal list when visibility is personal', () => { + it('PACK-SVC-046: applies into the personal list when visibility is personal', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const templateId = seedTemplate(user.id, ['Tent']); - const result = svc.applyTemplate(trip.id, templateId, 'personal', user.id) as any[]; + const result = await svc.applyTemplate(trip.id, templateId, 'personal', user.id) as any[]; expect(result[0].is_private).toBe(1); expect(result[0].owner_id).toBe(user.id); - expect(svc.listItems(trip.id, user.id).filter((i: any) => i.is_private)).toHaveLength(1); + expect((await svc.listItems(trip.id, user.id)).filter((i: any) => i.is_private)).toHaveLength(1); }); - it('PACK-SVC-047: a personally applied template stays hidden from other members', () => { + it('PACK-SVC-047: a personally applied template stays hidden from other members', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); const trip = createTrip(testDb, user.id); testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id); const templateId = seedTemplate(user.id, ['Tent']); - svc.applyTemplate(trip.id, templateId, 'personal', user.id); + await svc.applyTemplate(trip.id, templateId, 'personal', user.id); - expect(svc.listItems(trip.id, other.id)).toHaveLength(0); + expect(await svc.listItems(trip.id, other.id)).toHaveLength(0); }); - it('PACK-SVC-048: applies into the common pool by default, leaving items unowned', () => { + it('PACK-SVC-048: applies into the common pool by default, leaving items unowned', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const templateId = seedTemplate(user.id, ['Tent']); - const result = svc.applyTemplate(trip.id, templateId, 'common', user.id) as any[]; + const result = await svc.applyTemplate(trip.id, templateId, 'common', user.id) as any[]; expect(result[0].is_private).toBe(0); // Unowned, so any member may still re-share it (setItemSharing claims a null owner). expect(result[0].owner_id).toBeNull(); }); - it('PACK-SVC-049: falls back to common when no owner is given, so items stay visible', () => { + it('PACK-SVC-049: falls back to common when no owner is given, so items stay visible', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const templateId = seedTemplate(user.id, ['Tent']); // A private item with no owner would be invisible to everyone. - const result = svc.applyTemplate(trip.id, templateId, 'personal') as any[]; + const result = await svc.applyTemplate(trip.id, templateId, 'personal') as any[]; expect(result[0].is_private).toBe(0); - expect(svc.listItems(trip.id, user.id)).toHaveLength(1); + expect(await svc.listItems(trip.id, user.id)).toHaveLength(1); }); }); // ── createBag / deleteBag ───────────────────────────────────────────────────── describe('createBag / deleteBag', () => { - it('PACK-SVC-005: createBag inserts a bag and returns it', () => { + it('PACK-SVC-005: createBag inserts a bag and returns it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.createBag(trip.id, { name: 'Carry-On', color: '#ff0000' }) as any; + const result = await svc.createBag(trip.id, { name: 'Carry-On', color: '#ff0000' }) as any; expect(result).not.toBeNull(); expect(result.name).toBe('Carry-On'); @@ -262,14 +264,14 @@ describe('createBag / deleteBag', () => { expect(bag.name).toBe('Carry-On'); }); - it('PACK-SVC-006: deleteBag removes the bag and returns true', () => { + it('PACK-SVC-006: deleteBag removes the bag and returns true', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Checked Bag' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Checked Bag' }) as any; expect(bag).not.toBeNull(); - const deleted = svc.deleteBag(trip.id, bag.id); + const deleted = await svc.deleteBag(trip.id, bag.id); expect(deleted).toBe(true); @@ -277,8 +279,8 @@ describe('createBag / deleteBag', () => { expect(row).toBeUndefined(); }); - it('PACK-SVC-007: deleteBag returns false for non-existent bag', () => { - const result = svc.deleteBag(1, 99999); + it('PACK-SVC-007: deleteBag returns false for non-existent bag', async () => { + const result = await svc.deleteBag(1, 99999); expect(result).toBe(false); }); @@ -287,12 +289,12 @@ describe('createBag / deleteBag', () => { // ── setBagMembers ───────────────────────────────────────────────────────────── describe('setBagMembers', () => { - it('PACK-SVC-008: sets bag members (replaces existing)', () => { + it('PACK-SVC-008: sets bag members (replaces existing)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Main Bag' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Main Bag' }) as any; - const result = svc.setBagMembers(trip.id, bag.id, [user.id]) as any[]; + const result = await svc.setBagMembers(trip.id, bag.id, [user.id]) as any[]; expect(result).not.toBeNull(); expect(Array.isArray(result)).toBe(true); @@ -300,54 +302,54 @@ describe('setBagMembers', () => { expect(result[0].user_id).toBe(user.id); }); - it('PACK-SVC-009: setBagMembers with empty array clears all members', () => { + it('PACK-SVC-009: setBagMembers with empty array clears all members', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Main Bag' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Main Bag' }) as any; // First add a member - svc.setBagMembers(trip.id, bag.id, [user.id]); + await svc.setBagMembers(trip.id, bag.id, [user.id]); // Then clear - const result = svc.setBagMembers(trip.id, bag.id, []) as any[]; + const result = await svc.setBagMembers(trip.id, bag.id, []) as any[]; expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(0); }); - it('PACK-SVC-010: setBagMembers returns null for non-existent bag', () => { - const result = svc.setBagMembers(1, 99999, []); + it('PACK-SVC-010: setBagMembers returns null for non-existent bag', async () => { + const result = await svc.setBagMembers(1, 99999, []); expect(result).toBeNull(); }); - it('PACK-SVC-010a: setBagMembers drops a user who is not on the trip roster', () => { + it('PACK-SVC-010a: setBagMembers drops a user who is not on the trip roster', async () => { const { user } = createUser(testDb); const outsider = createUser(testDb).user; const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Main Bag' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Main Bag' }) as any; // owner is on the roster; the outsider (not owner, not a member) must be filtered out - const result = svc.setBagMembers(trip.id, bag.id, [user.id, outsider.id]) as any[]; + const result = await svc.setBagMembers(trip.id, bag.id, [user.id, outsider.id]) as any[]; const ids = result.map((m) => m.user_id); expect(ids).toContain(user.id); expect(ids).not.toContain(outsider.id); }); - it('PACK-SVC-010b: updateBag ignores an off-roster user_id, leaving the bag unassigned', () => { + it('PACK-SVC-010b: updateBag ignores an off-roster user_id, leaving the bag unassigned', async () => { const { user } = createUser(testDb); const outsider = createUser(testDb).user; const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Main Bag' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Main Bag' }) as any; // assigning to an outsider must not stick — the CASE keeps user_id null - svc.updateBag(trip.id, bag.id, { user_id: outsider.id }, ['user_id']); + await svc.updateBag(trip.id, bag.id, { user_id: outsider.id }, ['user_id']); const stored = testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(bag.id) as { user_id: number | null }; expect(stored.user_id).toBeNull(); // assigning to the owner (on the roster) does stick - svc.updateBag(trip.id, bag.id, { user_id: user.id }, ['user_id']); + await svc.updateBag(trip.id, bag.id, { user_id: user.id }, ['user_id']); const stored2 = testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(bag.id) as { user_id: number | null }; expect(stored2.user_id).toBe(user.id); }); @@ -356,11 +358,11 @@ describe('setBagMembers', () => { // ── bulkImport with bag field ───────────────────────────────────────────────── describe('bulkImport with bag field', () => { - it('PACK-SVC-011: bulk import with bag field creates the bag if it does not exist', () => { + it('PACK-SVC-011: bulk import with bag field creates the bag if it does not exist', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.bulkImport(trip.id, [{ name: 'Shirt', bag: 'Carry-On' }]); + const result = await svc.bulkImport(trip.id, [{ name: 'Shirt', bag: 'Carry-On' }]); expect(result).toHaveLength(1); expect(result[0]).toBeDefined(); @@ -373,11 +375,11 @@ describe('bulkImport with bag field', () => { expect(items[0].bag_id).toBe(bags[0].id); }); - it('PACK-SVC-012: bulk import with same bag name reuses existing bag', () => { + it('PACK-SVC-012: bulk import with same bag name reuses existing bag', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = svc.bulkImport(trip.id, [ + const result = await svc.bulkImport(trip.id, [ { name: 'Shirt', bag: 'Carry-On' }, { name: 'Pants', bag: 'Carry-On' }, ]); @@ -397,11 +399,11 @@ describe('bulkImport with bag field', () => { // ── bulkImport with quantity field ──────────────────────────────────────────── describe('bulkImport with quantity field', () => { - it('PACK-SVC-013: bulk import respects per-item quantity, defaults to 1, and clamps out-of-range', () => { + it('PACK-SVC-013: bulk import respects per-item quantity, defaults to 1, and clamps out-of-range', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.bulkImport(trip.id, [ + await svc.bulkImport(trip.id, [ { name: 'Socks', quantity: 5 }, { name: 'Toothbrush' }, { name: 'Batteries', quantity: 9999 }, @@ -421,12 +423,12 @@ describe('bulkImport with quantity field', () => { // ── Private items (#858) ────────────────────────────────────────────────────── describe('private items (#858)', () => { - it('PACK-SVC-014: createItem stamps the owner and is_private flag', () => { + it('PACK-SVC-014: createItem stamps the owner and is_private flag', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const shared = svc.createItem(trip.id, { name: 'Tent' }, user.id) as any; - const secret = svc.createItem(trip.id, { name: 'Gift', is_private: true }, user.id) as any; + const shared = await svc.createItem(trip.id, { name: 'Tent' }, user.id) as any; + const secret = await svc.createItem(trip.id, { name: 'Gift', is_private: true }, user.id) as any; expect(shared.is_private).toBe(0); expect(shared.owner_id).toBe(user.id); @@ -434,17 +436,17 @@ describe('private items (#858)', () => { expect(secret.owner_id).toBe(user.id); }); - it('PACK-SVC-015: listItems hides another member\'s private items but shows the owner theirs', () => { + it('PACK-SVC-015: listItems hides another member\'s private items but shows the owner theirs', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); - svc.createItem(trip.id, { name: 'Shared' }, owner.id); - svc.createItem(trip.id, { name: 'Private', is_private: true }, owner.id); + await svc.createItem(trip.id, { name: 'Shared' }, owner.id); + await svc.createItem(trip.id, { name: 'Private', is_private: true }, owner.id); - const ownerView = svc.listItems(trip.id, owner.id) as any[]; - const otherView = svc.listItems(trip.id, other.id) as any[]; - const unscoped = svc.listItems(trip.id) as any[]; + const ownerView = await svc.listItems(trip.id, owner.id) as any[]; + const otherView = await svc.listItems(trip.id, other.id) as any[]; + const unscoped = await svc.listItems(trip.id) as any[]; expect(ownerView.map(i => i.name).sort()).toEqual(['Private', 'Shared']); expect(otherView.map(i => i.name)).toEqual(['Shared']); @@ -452,40 +454,40 @@ describe('private items (#858)', () => { expect(unscoped).toHaveLength(2); }); - it('PACK-SVC-016: updateItem toggles privacy and claims an unowned item for the actor', () => { + it('PACK-SVC-016: updateItem toggles privacy and claims an unowned item for the actor', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Legacy-style row with no owner. const id = Number((testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Legacy') as any).lastInsertRowid); - const updated = svc.updateItem(trip.id, id, { is_private: true }, ['is_private'], undefined, user.id) as any; + const updated = await svc.updateItem(trip.id, id, { is_private: true }, ['is_private'], undefined, user.id) as any; expect(updated.is_private).toBe(1); expect(updated.owner_id).toBe(user.id); - const back = svc.updateItem(trip.id, id, { is_private: false }, ['is_private'], undefined, user.id) as any; + const back = await svc.updateItem(trip.id, id, { is_private: false }, ['is_private'], undefined, user.id) as any; expect(back.is_private).toBe(0); // Ownership is retained once claimed. expect(back.owner_id).toBe(user.id); }); - it('PACK-SVC-017: deleteItem returns the removed row (with privacy fields)', () => { + it('PACK-SVC-017: deleteItem returns the removed row (with privacy fields)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Private', is_private: true }, user.id) as any; + const item = await svc.createItem(trip.id, { name: 'Private', is_private: true }, user.id) as any; - const deleted = svc.deleteItem(trip.id, item.id, user.id) as any; + const deleted = await svc.deleteItem(trip.id, item.id, user.id) as any; expect(deleted).not.toBeNull(); expect(deleted.is_private).toBe(1); expect(deleted.owner_id).toBe(user.id); - expect(svc.deleteItem(trip.id, item.id, user.id)).toBeNull(); + expect(await svc.deleteItem(trip.id, item.id, user.id)).toBeNull(); }); - it('PACK-SVC-018: bulkImport stamps the owner on every item', () => { + it('PACK-SVC-018: bulkImport stamps the owner on every item', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.bulkImport(trip.id, [{ name: 'A' }, { name: 'B', is_private: true }], user.id); + await svc.bulkImport(trip.id, [{ name: 'A' }, { name: 'B', is_private: true }], user.id); const rows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY name').all(trip.id) as any[]; expect(rows.every(r => r.owner_id === user.id)).toBe(true); expect(rows.find(r => r.name === 'B').is_private).toBe(1); @@ -498,134 +500,134 @@ describe('private items (#858)', () => { describe('three-tier packing sharing (#858)', () => { const names = (rows: any[]) => rows.map(r => r.name).sort(); - it('PACK-SVC-040: existing/common items are visible to everyone (non-breaking)', () => { + it('PACK-SVC-040: existing/common items are visible to everyone (non-breaking)', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); // A legacy-style row written directly (is_private defaults 0) = Common. testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, sort_order) VALUES (?, ?, 0, 0)').run(trip.id, 'Tent'); - svc.createItem(trip.id, { name: 'Stove', visibility: 'common' }, owner.id); + await svc.createItem(trip.id, { name: 'Stove', visibility: 'common' }, owner.id); - expect(names(svc.listItems(trip.id, owner.id) as any[])).toEqual(['Stove', 'Tent']); - expect(names(svc.listItems(trip.id, other.id) as any[])).toEqual(['Stove', 'Tent']); + expect(names(await svc.listItems(trip.id, owner.id) as any[])).toEqual(['Stove', 'Tent']); + expect(names(await svc.listItems(trip.id, other.id) as any[])).toEqual(['Stove', 'Tent']); }); - it('PACK-SVC-041: a Shared item is visible to its owner + recipients only, marked with the bringer', () => { + it('PACK-SVC-041: a Shared item is visible to its owner + recipients only, marked with the bringer', async () => { const { user: owner } = createUser(testDb); const { user: friend } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, friend.id); - const item = svc.createItem(trip.id, { name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] }, owner.id) as any; + const item = await svc.createItem(trip.id, { name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] }, owner.id) as any; expect(item.is_private).toBe(1); expect(item.owner_username).toBe(owner.username); expect(item.recipients.map((r: any) => r.user_id)).toEqual([friend.id]); - expect(names(svc.listItems(trip.id, owner.id) as any[])).toEqual(['Power bank']); // bringer - expect(names(svc.listItems(trip.id, friend.id) as any[])).toEqual(['Power bank']); // covered person - expect(names(svc.listItems(trip.id, stranger.id) as any[])).toEqual([]); // nobody else + expect(names(await svc.listItems(trip.id, owner.id) as any[])).toEqual(['Power bank']); // bringer + expect(names(await svc.listItems(trip.id, friend.id) as any[])).toEqual(['Power bank']); // covered person + expect(names(await svc.listItems(trip.id, stranger.id) as any[])).toEqual([]); // nobody else }); - it('PACK-SVC-042: a Personal item is visible only to its owner', () => { + it('PACK-SVC-042: a Personal item is visible only to its owner', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); - svc.createItem(trip.id, { name: 'Diary', visibility: 'personal' }, owner.id); - expect(names(svc.listItems(trip.id, owner.id) as any[])).toEqual(['Diary']); - expect(names(svc.listItems(trip.id, other.id) as any[])).toEqual([]); + await svc.createItem(trip.id, { name: 'Diary', visibility: 'personal' }, owner.id); + expect(names(await svc.listItems(trip.id, owner.id) as any[])).toEqual(['Diary']); + expect(names(await svc.listItems(trip.id, other.id) as any[])).toEqual([]); }); - it('PACK-SVC-043: setItemSharing changes the tier + recipients; only the owner may', () => { + it('PACK-SVC-043: setItemSharing changes the tier + recipients; only the owner may', async () => { const { user: owner } = createUser(testDb); const { user: friend } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, friend.id); - const item = svc.createItem(trip.id, { name: 'First aid', visibility: 'personal' }, owner.id) as any; + const item = await svc.createItem(trip.id, { name: 'First aid', visibility: 'personal' }, owner.id) as any; // A non-owner who cannot see the item at all gets the missing-item answer, so // the route cannot be used to confirm that the id exists (GHSA-vh2h-288v-ggch). - expect(svc.setItemSharing(trip.id, item.id, friend.id, 'shared', [friend.id])).toBeNull(); + expect(await svc.setItemSharing(trip.id, item.id, friend.id, 'shared', [friend.id])).toBeNull(); - const updated = svc.setItemSharing(trip.id, item.id, owner.id, 'shared', [friend.id]) as any; + const updated = await svc.setItemSharing(trip.id, item.id, owner.id, 'shared', [friend.id]) as any; expect(updated.recipients.map((r: any) => r.user_id)).toEqual([friend.id]); - expect(names(svc.listItems(trip.id, friend.id) as any[])).toEqual(['First aid']); + expect(names(await svc.listItems(trip.id, friend.id) as any[])).toEqual(['First aid']); // Back to common → visible to everyone, recipients cleared. - svc.setItemSharing(trip.id, item.id, owner.id, 'common', []); + await svc.setItemSharing(trip.id, item.id, owner.id, 'common', []); const { user: stranger } = createUser(testDb); - expect(names(svc.listItems(trip.id, stranger.id) as any[])).toEqual(['First aid']); + expect(names(await svc.listItems(trip.id, stranger.id) as any[])).toEqual(['First aid']); }); - it('PACK-SVC-044: contributors ("I can bring that too") only attach to Common items', () => { + it('PACK-SVC-044: contributors ("I can bring that too") only attach to Common items', async () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const common = svc.createItem(trip.id, { name: 'Sunscreen', visibility: 'common' }, owner.id) as any; - const personal = svc.createItem(trip.id, { name: 'Meds', visibility: 'personal' }, owner.id) as any; + const common = await svc.createItem(trip.id, { name: 'Sunscreen', visibility: 'common' }, owner.id) as any; + const personal = await svc.createItem(trip.id, { name: 'Meds', visibility: 'personal' }, owner.id) as any; - const withHelper = svc.addContributor(trip.id, common.id, helper.id) as any; + const withHelper = await svc.addContributor(trip.id, common.id, helper.id) as any; expect(withHelper.contributors.map((c: any) => c.user_id)).toEqual([helper.id]); // The bringer can't co-contribute to their own item, and personal items have no pool. - expect(svc.addContributor(trip.id, common.id, owner.id)).toBeNull(); - expect(svc.addContributor(trip.id, personal.id, helper.id)).toBeNull(); + expect(await svc.addContributor(trip.id, common.id, owner.id)).toBeNull(); + expect(await svc.addContributor(trip.id, personal.id, helper.id)).toBeNull(); - const cleared = svc.removeContributor(trip.id, common.id, helper.id) as any; + const cleared = await svc.removeContributor(trip.id, common.id, helper.id) as any; expect(cleared.contributors).toEqual([]); }); - it('PACK-SVC-045: cloneItem copies an item onto the cloner\'s personal list', () => { + it('PACK-SVC-045: cloneItem copies an item onto the cloner\'s personal list', async () => { const { user: owner } = createUser(testDb); const { user: cloner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const common = svc.createItem(trip.id, { name: 'Travel adapter', category: 'Electronics', visibility: 'common' }, owner.id) as any; + const common = await svc.createItem(trip.id, { name: 'Travel adapter', category: 'Electronics', visibility: 'common' }, owner.id) as any; - const clone = svc.cloneItem(trip.id, common.id, cloner.id) as any; + const clone = await svc.cloneItem(trip.id, common.id, cloner.id) as any; expect(clone.name).toBe('Travel adapter'); expect(clone.category).toBe('Electronics'); expect(clone.is_private).toBe(1); expect(clone.owner_id).toBe(cloner.id); // The clone is the cloner's alone. - expect(names(svc.listItems(trip.id, owner.id) as any[])).toEqual(['Travel adapter']); // owner sees only the common one - expect(names(svc.listItems(trip.id, cloner.id) as any[])).toEqual(['Travel adapter', 'Travel adapter']); // common + own clone + expect(names(await svc.listItems(trip.id, owner.id) as any[])).toEqual(['Travel adapter']); // owner sees only the common one + expect(names(await svc.listItems(trip.id, cloner.id) as any[])).toEqual(['Travel adapter', 'Travel adapter']); // common + own clone }); // #207: "one person curates the list, everyone copies it" meant re-entering every // weight by hand, because a copy arrived empty. - it('PACK-SVC-073: cloneItem carries the weight over', () => { + it('PACK-SVC-073: cloneItem carries the weight over', async () => { const { user: owner } = createUser(testDb); const { user: cloner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const common = svc.createItem(trip.id, { name: 'Tent', visibility: 'common', weight_grams: 2400, quantity: 2 }, owner.id) as any; + const common = await svc.createItem(trip.id, { name: 'Tent', visibility: 'common', weight_grams: 2400, quantity: 2 }, owner.id) as any; - const clone = svc.cloneItem(trip.id, common.id, cloner.id) as any; + const clone = await svc.cloneItem(trip.id, common.id, cloner.id) as any; expect(clone.weight_grams).toBe(2400); expect(clone.quantity).toBe(2); }); - it('PACK-SVC-074: cloneItem keeps a bag nobody owns', () => { + it('PACK-SVC-074: cloneItem keeps a bag nobody owns', async () => { const { user: owner } = createUser(testDb); const { user: cloner } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const bag = svc.createBag(trip.id, { name: 'Car boot' }) as any; - const common = svc.createItem(trip.id, { name: 'Cool box', visibility: 'common', weight_grams: 3000, bag_id: bag.id }, owner.id) as any; + const bag = await svc.createBag(trip.id, { name: 'Car boot' }) as any; + const common = await svc.createItem(trip.id, { name: 'Cool box', visibility: 'common', weight_grams: 3000, bag_id: bag.id }, owner.id) as any; - const clone = svc.cloneItem(trip.id, common.id, cloner.id) as any; + const clone = await svc.cloneItem(trip.id, common.id, cloner.id) as any; expect(clone.bag_id).toBe(bag.id); }); - it('PACK-SVC-075: cloneItem drops a bag that belongs to someone else', () => { + it('PACK-SVC-075: cloneItem drops a bag that belongs to someone else', async () => { const { user: owner } = createUser(testDb); const { user: cloner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, cloner.id); - const bag = svc.createBag(trip.id, { name: 'Owner backpack' }) as any; - svc.setBagMembers(trip.id, bag.id, [owner.id]); - const common = svc.createItem(trip.id, { name: 'Rope', visibility: 'common', weight_grams: 900, bag_id: bag.id }, owner.id) as any; + const bag = await svc.createBag(trip.id, { name: 'Owner backpack' }) as any; + await svc.setBagMembers(trip.id, bag.id, [owner.id]); + const common = await svc.createItem(trip.id, { name: 'Rope', visibility: 'common', weight_grams: 900, bag_id: bag.id }, owner.id) as any; - const clone = svc.cloneItem(trip.id, common.id, cloner.id) as any; + const clone = await svc.cloneItem(trip.id, common.id, cloner.id) as any; // Weight still comes along — only the foreign bag is dropped, so the copy cannot // land in someone else's luggage and inflate their total. @@ -633,169 +635,169 @@ describe('three-tier packing sharing (#858)', () => { expect(clone.bag_id).toBeNull(); }); - it('PACK-SVC-076: cloneItem keeps a bag the cloner is a member of', () => { + it('PACK-SVC-076: cloneItem keeps a bag the cloner is a member of', async () => { const { user: owner } = createUser(testDb); const { user: cloner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, cloner.id); - const bag = svc.createBag(trip.id, { name: 'Shared duffel' }) as any; - svc.setBagMembers(trip.id, bag.id, [owner.id, cloner.id]); - const common = svc.createItem(trip.id, { name: 'Stove', visibility: 'common', bag_id: bag.id }, owner.id) as any; + const bag = await svc.createBag(trip.id, { name: 'Shared duffel' }) as any; + await svc.setBagMembers(trip.id, bag.id, [owner.id, cloner.id]); + const common = await svc.createItem(trip.id, { name: 'Stove', visibility: 'common', bag_id: bag.id }, owner.id) as any; - expect((svc.cloneItem(trip.id, common.id, cloner.id) as any).bag_id).toBe(bag.id); + expect((await svc.cloneItem(trip.id, common.id, cloner.id) as any).bag_id).toBe(bag.id); }); }); // ── Post-migration fixes over the legacy quirks ─────────────────────────────── describe('legacy-quirk fixes', () => { - it('PACK-SVC-051: createItem defaults the category to "Other" (unified with bulkImport)', () => { + it('PACK-SVC-051: createItem defaults the category to "Other" (unified with bulkImport)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Socks' }, user.id) as any; + const item = await svc.createItem(trip.id, { name: 'Socks' }, user.id) as any; expect(item.category).toBe('Other'); }); - it('PACK-SVC-052: updateBag gates weight_limit_grams on bodyKeys — omitted keeps, explicit null clears', () => { + it('PACK-SVC-052: updateBag gates weight_limit_grams on bodyKeys — omitted keeps, explicit null clears', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Duffel' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Duffel' }) as any; - const limited = svc.updateBag(trip.id, bag.id, { weight_limit_grams: 8000 }, ['weight_limit_grams']) as any; + const limited = await svc.updateBag(trip.id, bag.id, { weight_limit_grams: 8000 }, ['weight_limit_grams']) as any; expect(limited.weight_limit_grams).toBe(8000); // A rename without the key must not touch the limit. - const kept = svc.updateBag(trip.id, bag.id, { name: 'Duffel XL' }, ['name']) as any; + const kept = await svc.updateBag(trip.id, bag.id, { name: 'Duffel XL' }, ['name']) as any; expect(kept.weight_limit_grams).toBe(8000); // An explicit null clears it. - const cleared = svc.updateBag(trip.id, bag.id, { weight_limit_grams: null }, ['weight_limit_grams']) as any; + const cleared = await svc.updateBag(trip.id, bag.id, { weight_limit_grams: null }, ['weight_limit_grams']) as any; expect(cleared.weight_limit_grams).toBeNull(); }); - it('PACK-SVC-053: updateItem clamps a provided quantity into 1..999', () => { + it('PACK-SVC-053: updateItem clamps a provided quantity into 1..999', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Socks', quantity: 5 }, user.id) as any; + const item = await svc.createItem(trip.id, { name: 'Socks', quantity: 5 }, user.id) as any; - expect((svc.updateItem(trip.id, item.id, { quantity: 0 }, ['quantity'], undefined, user.id) as any).quantity).toBe(1); - expect((svc.updateItem(trip.id, item.id, { quantity: 9999 }, ['quantity'], undefined, user.id) as any).quantity).toBe(999); + expect((await svc.updateItem(trip.id, item.id, { quantity: 0 }, ['quantity'], undefined, user.id) as any).quantity).toBe(1); + expect((await svc.updateItem(trip.id, item.id, { quantity: 9999 }, ['quantity'], undefined, user.id) as any).quantity).toBe(999); // Omitted key leaves the quantity unchanged. - expect((svc.updateItem(trip.id, item.id, { name: 'Wool socks' }, ['name'], undefined, user.id) as any).quantity).toBe(999); + expect((await svc.updateItem(trip.id, item.id, { name: 'Wool socks' }, ['name'], undefined, user.id) as any).quantity).toBe(999); }); }); // ── Create-path fields + bag trip scope (#2154) ─────────────────────────────── describe('create-path fields + bag trip scope (#2154)', () => { - it('PACK-SVC-054: createItem persists weight_grams, bag_id and quantity in one write', () => { + it('PACK-SVC-054: createItem persists weight_grams, bag_id and quantity in one write', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Carry-On' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Carry-On' }) as any; - const item = svc.createItem(trip.id, { name: 'Tent', weight_grams: 250, bag_id: bag.id, quantity: 3 }, user.id) as any; + const item = await svc.createItem(trip.id, { name: 'Tent', weight_grams: 250, bag_id: bag.id, quantity: 3 }, user.id) as any; expect(item).toMatchObject({ weight_grams: 250, bag_id: bag.id, quantity: 3 }); }); - it('PACK-SVC-077: a bag weighs what EVERY member put in it, private items included (#2191)', () => { + it('PACK-SVC-077: a bag weighs what EVERY member put in it, private items included (#2191)', async () => { const { user } = createUser(testDb); const { user: buddy } = createUser(testDb, { username: 'bag-buddy' }); const trip = createTrip(testDb, user.id); addTripMember(testDb, trip.id, buddy.id); - const bag = svc.createBag(trip.id, { name: 'Duffel' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Duffel' }) as any; // The reporter's repro: 500 g of mine (personal) + 300 g common + 200 g of // my buddy's personal list. Their item is invisible to me by design (#858) // and used to be missing from the weight along with it. - svc.createItem(trip.id, { name: 'Boots', weight_grams: 500, bag_id: bag.id, visibility: 'personal' }, user.id); - svc.createItem(trip.id, { name: 'Stove', weight_grams: 300, bag_id: bag.id }, user.id); - svc.createItem(trip.id, { name: 'Book', weight_grams: 200, bag_id: bag.id, visibility: 'personal' }, buddy.id); + await svc.createItem(trip.id, { name: 'Boots', weight_grams: 500, bag_id: bag.id, visibility: 'personal' }, user.id); + await svc.createItem(trip.id, { name: 'Stove', weight_grams: 300, bag_id: bag.id }, user.id); + await svc.createItem(trip.id, { name: 'Book', weight_grams: 200, bag_id: bag.id, visibility: 'personal' }, buddy.id); // What I am allowed to SEE is still only my own two items... - const visible = svc.listItems(trip.id, user.id) as any[]; + const visible = await svc.listItems(trip.id, user.id) as any[]; expect(visible.map(i => i.name).sort()).toEqual(['Boots', 'Stove']); // ...but the bag weighs all three. - expect((svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(1000); + expect((await svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(1000); }); - it('PACK-SVC-078: bag weight multiplies by quantity and survives null weights (#2191)', () => { + it('PACK-SVC-078: bag weight multiplies by quantity and survives null weights (#2191)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Carry-On' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Carry-On' }) as any; - svc.createItem(trip.id, { name: 'Socks', weight_grams: 60, bag_id: bag.id, quantity: 3 }, user.id); + await svc.createItem(trip.id, { name: 'Socks', weight_grams: 60, bag_id: bag.id, quantity: 3 }, user.id); // No weight and no quantity: contributes nothing rather than NaN. - svc.createItem(trip.id, { name: 'Passport', bag_id: bag.id }, user.id); + await svc.createItem(trip.id, { name: 'Passport', bag_id: bag.id }, user.id); - expect((svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(180); + expect((await svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(180); }); - it('PACK-SVC-079: an empty bag reports 0, and the unassigned pile is summed too (#2191)', () => { + it('PACK-SVC-079: an empty bag reports 0, and the unassigned pile is summed too (#2191)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Empty' }) as any; + const bag = await svc.createBag(trip.id, { name: 'Empty' }) as any; // 0, never undefined — the client's fallback keys off the field being absent, // so an empty bag must not read as "the server did not tell me". - expect((svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(0); - expect(svc.unassignedWeightGrams(trip.id)).toBe(0); + expect((await svc.listBags(trip.id) as any[])[0].total_weight_grams).toBe(0); + expect(await svc.unassignedWeightGrams(trip.id)).toBe(0); - svc.createItem(trip.id, { name: 'Loose sandwich', weight_grams: 150 }, user.id); - expect(svc.unassignedWeightGrams(trip.id)).toBe(150); + await svc.createItem(trip.id, { name: 'Loose sandwich', weight_grams: 150 }, user.id); + expect(await svc.unassignedWeightGrams(trip.id)).toBe(150); // Still empty: the loose item belongs to no bag. - expect((svc.listBags(trip.id) as any[]).find(b => b.id === bag.id).total_weight_grams).toBe(0); + expect((await svc.listBags(trip.id) as any[]).find(b => b.id === bag.id).total_weight_grams).toBe(0); }); - it('PACK-SVC-080: listBagsWithWeights returns bags and the unassigned pile from one pass (#2191)', () => { + it('PACK-SVC-080: listBagsWithWeights returns bags and the unassigned pile from one pass (#2191)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const bag = svc.createBag(trip.id, { name: 'Duffel' }) as any; - svc.createItem(trip.id, { name: 'Tent', weight_grams: 900, bag_id: bag.id }, user.id); - svc.createItem(trip.id, { name: 'Loose sandwich', weight_grams: 150 }, user.id); + const bag = await svc.createBag(trip.id, { name: 'Duffel' }) as any; + await svc.createItem(trip.id, { name: 'Tent', weight_grams: 900, bag_id: bag.id }, user.id); + await svc.createItem(trip.id, { name: 'Loose sandwich', weight_grams: 150 }, user.id); - const { bags, unassigned_weight_grams } = svc.listBagsWithWeights(trip.id) as any; + const { bags, unassigned_weight_grams } = await svc.listBagsWithWeights(trip.id) as any; expect(bags[0].total_weight_grams).toBe(900); expect(unassigned_weight_grams).toBe(150); }); - it('PACK-SVC-055: createItem refuses a bag off the trip with the invalidBag sentinel, inserting nothing', () => { + it('PACK-SVC-055: createItem refuses a bag off the trip with the invalidBag sentinel, inserting nothing', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); - const foreignBag = svc.createBag(otherTrip.id, { name: 'Not yours' }) as any; + const foreignBag = await svc.createBag(otherTrip.id, { name: 'Not yours' }) as any; // Existence alone is not enough — the bag must belong to THIS trip. - expect(svc.createItem(trip.id, { name: 'Tent', bag_id: foreignBag.id }, user.id)).toEqual({ invalidBag: true }); + expect(await svc.createItem(trip.id, { name: 'Tent', bag_id: foreignBag.id }, user.id)).toEqual({ invalidBag: true }); // A dead id refuses the same way (it used to be an SQLite FK error). - expect(isInvalidBagRef(svc.createItem(trip.id, { name: 'Tent', bag_id: 99999 }, user.id))).toBe(true); + expect(isInvalidBagRef(await svc.createItem(trip.id, { name: 'Tent', bag_id: 99999 }, user.id))).toBe(true); expect((testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(trip.id) as any).n).toBe(0); }); - it('PACK-SVC-056: updateItem refuses a cross-trip bag_id and leaves the row alone; null still clears', () => { + it('PACK-SVC-056: updateItem refuses a cross-trip bag_id and leaves the row alone; null still clears', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const otherTrip = createTrip(testDb, user.id); - const own = svc.createBag(trip.id, { name: 'Mine' }) as any; - const foreign = svc.createBag(otherTrip.id, { name: 'Not mine' }) as any; - const item = svc.createItem(trip.id, { name: 'Tent', bag_id: own.id }, user.id) as any; + const own = await svc.createBag(trip.id, { name: 'Mine' }) as any; + const foreign = await svc.createBag(otherTrip.id, { name: 'Not mine' }) as any; + const item = await svc.createItem(trip.id, { name: 'Tent', bag_id: own.id }, user.id) as any; - const refused = svc.updateItem(trip.id, item.id, { bag_id: foreign.id }, ['bag_id'], undefined, user.id); + const refused = await svc.updateItem(trip.id, item.id, { bag_id: foreign.id }, ['bag_id'], undefined, user.id); expect(isInvalidBagRef(refused)).toBe(true); expect((testDb.prepare('SELECT bag_id FROM packing_items WHERE id = ?').get(item.id) as any).bag_id).toBe(own.id); // Clearing the bag with an explicit null is untouched by the check. - const cleared = svc.updateItem(trip.id, item.id, { bag_id: null }, ['bag_id'], undefined, user.id) as any; + const cleared = await svc.updateItem(trip.id, item.id, { bag_id: null }, ['bag_id'], undefined, user.id) as any; expect(cleared.bag_id).toBeNull(); }); - it('PACK-SVC-057: createBag persists weight_limit_grams; omitted stays null', () => { + it('PACK-SVC-057: createBag persists weight_limit_grams; omitted stays null', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const limited = svc.createBag(trip.id, { name: 'Checked', weight_limit_grams: 23000 }) as any; + const limited = await svc.createBag(trip.id, { name: 'Checked', weight_limit_grams: 23000 }) as any; expect(limited.weight_limit_grams).toBe(23000); - const bare = svc.createBag(trip.id, { name: 'Day pack' }) as any; + const bare = await svc.createBag(trip.id, { name: 'Day pack' }) as any; expect(bare.weight_limit_grams).toBeNull(); }); }); @@ -810,40 +812,40 @@ describe('canEdit', () => { }); describe('broadcast helpers (#858 scoping)', () => { - it('broadcast forwards to the websocket helper', () => { + it('broadcast forwards to the websocket helper', async () => { svc.broadcast('5', 'packing:created', { item: 1 }, 'sock'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock'); }); - it('broadcastBagTotals pings the whole room, content-free and without excluding the sender (#2191)', () => { + it('broadcastBagTotals pings the whole room, content-free and without excluding the sender (#2191)', async () => { // No socket exclusion on purpose: the payload carries nothing to echo, and // the writer cannot recompute a server-side total from its own write either. svc.broadcastBagTotals('5'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:bag-totals', {}, undefined); }); - it('broadcastItem broadcasts a shared item to the whole room (no onlyUserId)', () => { + it('broadcastItem broadcasts a shared item to the whole room (no onlyUserId)', async () => { svc.broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 0, owner_id: 7 }, 'sock'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined); }); - it('broadcastItem scopes a private item to its owner', () => { + it('broadcastItem scopes a private item to its owner', async () => { svc.broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: 7 }, 'sock'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 7); }); - it('broadcastItem falls back to a room broadcast when the private item has no owner', () => { + it('broadcastItem falls back to a room broadcast when the private item has no owner', async () => { svc.broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: null }, 'sock'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined); }); - it('viewersOf: Common → null (whole room); restricted → owner + recipients', () => { + it('viewersOf: Common → null (whole room); restricted → owner + recipients', async () => { expect(svc.viewersOf({ is_private: 0, owner_id: 1 })).toBeNull(); expect(svc.viewersOf(null)).toBeNull(); expect(svc.viewersOf({ is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }, { user_id: 3 }] })).toEqual([1, 2, 3]); }); - it('broadcastToViewers delivers to each viewer (deduped) via onlyUserId', () => { + it('broadcastToViewers delivers to each viewer (deduped) via onlyUserId', async () => { svc.broadcastToViewers('5', 'packing:created', { item: 1 }, [1, 2, 2], 'sock'); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 1); expect(broadcastMock).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 2); @@ -852,20 +854,20 @@ describe('broadcast helpers (#858 scoping)', () => { }); describe('getItemPrivacy', () => { - it('reads the privacy fields for an item (real SQL)', () => { + it('reads the privacy fields for an item (real SQL)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = svc.createItem(trip.id, { name: 'Gift', is_private: true }, user.id) as any; + const item = await svc.createItem(trip.id, { name: 'Gift', is_private: true }, user.id) as any; - expect(svc.getItemPrivacy(trip.id, item.id)).toEqual({ is_private: 1, owner_id: user.id }); - expect(svc.getItemPrivacy(trip.id, 99999)).toBeUndefined(); + expect(await svc.getItemPrivacy(trip.id, item.id)).toEqual({ is_private: 1, owner_id: user.id }); + expect(await svc.getItemPrivacy(trip.id, 99999)).toBeUndefined(); }); }); describe('notifyTagged', () => { it('does nothing when no users are tagged', async () => { - svc.notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', []); - svc.notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', 'nope'); + await svc.notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', []); + await svc.notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', 'nope'); await new Promise((resolve) => setTimeout(resolve, 0)); expect(send).not.toHaveBeenCalled(); }); @@ -875,7 +877,7 @@ describe('notifyTagged', () => { const trip = createTrip(testDb, user.id); const { title } = testDb.prepare('SELECT title FROM trips WHERE id = ?').get(trip.id) as { title: string }; - svc.notifyTagged(String(trip.id), { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3]); + await svc.notifyTagged(String(trip.id), { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3]); // Flush the dynamic import().then microtask chain. await new Promise((resolve) => setTimeout(resolve, 0)); expect(send).toHaveBeenCalledWith( @@ -890,7 +892,7 @@ describe('notifyTagged', () => { }); it('falls back to "Untitled" when the trip row is missing (?? / default branch)', async () => { - svc.notifyTagged('999999', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2]); + await svc.notifyTagged('999999', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2]); await new Promise((resolve) => setTimeout(resolve, 0)); expect(send).toHaveBeenCalledWith( expect.objectContaining({ params: expect.objectContaining({ trip: 'Untitled' }) }), @@ -901,125 +903,125 @@ describe('notifyTagged', () => { // ── Bridge delegation ───────────────────────────────────────────────────────── describe('PackingService — the surface the deleted bridge exposed', () => { - it('PACK-SVC-050: listItems delegates to PackingService over the shared db Proxy', () => { + it('PACK-SVC-050: listItems delegates to PackingService over the shared db Proxy', async () => { const { user: owner } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, owner.id); - svc.createItem(trip.id, { name: 'Shared' }, owner.id); - svc.createItem(trip.id, { name: 'Private', is_private: true }, owner.id); + await svc.createItem(trip.id, { name: 'Shared' }, owner.id); + await svc.createItem(trip.id, { name: 'Private', is_private: true }, owner.id); // Unscoped (internal callers) — unfiltered; viewer-scoped — #858 filtering applies. - expect((bridgeListItems(trip.id) as { name: string }[]).map(i => i.name).sort()).toEqual(['Private', 'Shared']); - expect((bridgeListItems(trip.id, other.id) as { name: string }[]).map(i => i.name)).toEqual(['Shared']); + expect(((await bridgeListItems(trip.id)) as { name: string }[]).map(i => i.name).sort()).toEqual(['Private', 'Shared']); + expect(((await bridgeListItems(trip.id, other.id)) as { name: string }[]).map(i => i.name)).toEqual(['Shared']); }); }); // ── Admin template CRUD (moved from adminService, IDs preserved) ────────────── describe('Packing templates', () => { - it('ADMIN-SVC-031 — createPackingTemplate returns template', () => { + it('ADMIN-SVC-031 — createPackingTemplate returns template', async () => { const { user: admin } = createAdmin(testDb); - const result = svc.createPackingTemplate('Beach Trip', admin.id) as any; + const result = await svc.createPackingTemplate('Beach Trip', admin.id) as any; expect(result.template.name).toBe('Beach Trip'); }); - it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', () => { + it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', async () => { const { user: admin } = createAdmin(testDb); - const result = svc.createPackingTemplate('', admin.id) as any; + const result = await svc.createPackingTemplate('', admin.id) as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-033 — listPackingTemplates returns array', () => { + it('ADMIN-SVC-033 — listPackingTemplates returns array', async () => { const { user: admin } = createAdmin(testDb); - svc.createPackingTemplate('Template A', admin.id); - const templates = svc.listPackingTemplates() as any[]; + await svc.createPackingTemplate('Template A', admin.id); + const templates = await svc.listPackingTemplates() as any[]; expect(templates.length).toBeGreaterThanOrEqual(1); }); - it('ADMIN-SVC-034 — updatePackingTemplate updates name', () => { + it('ADMIN-SVC-034 — updatePackingTemplate updates name', async () => { const { user: admin } = createAdmin(testDb); - const created = svc.createPackingTemplate('Old Name', admin.id) as any; - const result = svc.updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any; + const created = await svc.createPackingTemplate('Old Name', admin.id) as any; + const result = await svc.updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any; expect(result.template.name).toBe('New Name'); }); - it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', () => { - const result = svc.updatePackingTemplate('99999', { name: 'Ghost' }) as any; + it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', async () => { + const result = await svc.updatePackingTemplate('99999', { name: 'Ghost' }) as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-036 — deletePackingTemplate removes template', () => { + it('ADMIN-SVC-036 — deletePackingTemplate removes template', async () => { const { user: admin } = createAdmin(testDb); - const created = svc.createPackingTemplate('To Delete', admin.id) as any; - const result = svc.deletePackingTemplate(String(created.template.id)) as any; + const created = await svc.createPackingTemplate('To Delete', admin.id) as any; + const result = await svc.deletePackingTemplate(String(created.template.id)) as any; expect(result.name).toBe('To Delete'); }); - it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', () => { - const result = svc.deletePackingTemplate('99999') as any; + it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', async () => { + const result = await svc.deletePackingTemplate('99999') as any; expect(result.status).toBe(404); }); }); describe('Template categories', () => { - it('ADMIN-SVC-038 — createTemplateCategory creates a category', () => { + it('ADMIN-SVC-038 — createTemplateCategory creates a category', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const result = svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const result = await svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any; expect(result.category.name).toBe('Clothing'); }); - it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', () => { + it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const result = svc.createTemplateCategory(String(tpl.template.id), '') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const result = await svc.createTemplateCategory(String(tpl.template.id), '') as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', () => { - const result = svc.createTemplateCategory('99999', 'Clothing') as any; + it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', async () => { + const result = await svc.createTemplateCategory('99999', 'Clothing') as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-041 — updateTemplateCategory updates name', () => { + it('ADMIN-SVC-041 — updateTemplateCategory updates name', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Old') as any; - const result = svc.updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Old') as any; + const result = await svc.updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any; expect(result.category.name).toBe('New'); }); - it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', () => { + it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const result = svc.updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const result = await svc.updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-043 — deleteTemplateCategory removes category', () => { + it('ADMIN-SVC-043 — deleteTemplateCategory removes category', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Remove Me') as any; - const result = svc.deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Remove Me') as any; + const result = await svc.deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any; expect(result.error).toBeUndefined(); }); - it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', () => { + it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const result = svc.deleteTemplateCategory(String(tpl.template.id), '99999') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const result = await svc.deleteTemplateCategory(String(tpl.template.id), '99999') as any; expect(result.status).toBe(404); }); }); describe('getPackingTemplate', () => { - it('ADMIN-SVC-056 — returns template with categories and items when template exists', () => { + it('ADMIN-SVC-056 — returns template with categories and items when template exists', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Full Template', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any; - svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt'); + const tpl = await svc.createPackingTemplate('Full Template', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Clothing') as any; + await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt'); - const result = svc.getPackingTemplate(String(tpl.template.id)) as any; + const result = await svc.getPackingTemplate(String(tpl.template.id)) as any; expect(result.template).toBeDefined(); expect(result.template.name).toBe('Full Template'); expect(Array.isArray(result.categories)).toBe(true); @@ -1029,65 +1031,65 @@ describe('getPackingTemplate', () => { expect(result.items[0].name).toBe('T-Shirt'); }); - it('ADMIN-SVC-057 — returns 404 for non-existent template', () => { - const result = svc.getPackingTemplate('99999') as any; + it('ADMIN-SVC-057 — returns 404 for non-existent template', async () => { + const result = await svc.getPackingTemplate('99999') as any; expect(result.status).toBe(404); expect(result.error).toBeDefined(); }); }); describe('Template items', () => { - it('ADMIN-SVC-058 — createTemplateItem returns item with name', () => { + it('ADMIN-SVC-058 — createTemplateItem returns item with name', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; - const result = svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; + const result = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any; expect(result.item).toBeDefined(); expect(result.item.name).toBe('Backpack'); }); - it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', () => { + it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; - const result = svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; + const result = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any; expect(result.status).toBe(400); }); - it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', () => { + it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const result = svc.createTemplateItem(String(tpl.template.id), '99999', 'Item') as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const result = await svc.createTemplateItem(String(tpl.template.id), '99999', 'Item') as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-061 — updateTemplateItem updates name', () => { + it('ADMIN-SVC-061 — updateTemplateItem updates name', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; - const item = svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any; - const result = svc.updateTemplateItem(String(tpl.template.id), String(item.item.id), { name: 'New Item' }) as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; + const item = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any; + const result = await svc.updateTemplateItem(String(tpl.template.id), String(item.item.id), { name: 'New Item' }) as any; expect(result.item.name).toBe('New Item'); }); - it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', () => { - const result = svc.updateTemplateItem('1', '99999', { name: 'Ghost' }) as any; + it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', async () => { + const result = await svc.updateTemplateItem('1', '99999', { name: 'Ghost' }) as any; expect(result.status).toBe(404); }); - it('ADMIN-SVC-063 — deleteTemplateItem removes item', () => { + it('ADMIN-SVC-063 — deleteTemplateItem removes item', async () => { const { user: admin } = createAdmin(testDb); - const tpl = svc.createPackingTemplate('Tpl', admin.id) as any; - const cat = svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; - const item = svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any; - const result = svc.deleteTemplateItem(String(tpl.template.id), String(item.item.id)) as any; + const tpl = await svc.createPackingTemplate('Tpl', admin.id) as any; + const cat = await svc.createTemplateCategory(String(tpl.template.id), 'Gear') as any; + const item = await svc.createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any; + const result = await svc.deleteTemplateItem(String(tpl.template.id), String(item.item.id)) as any; expect(result.error).toBeUndefined(); const check = testDb.prepare('SELECT id FROM packing_template_items WHERE id = ?').get(item.item.id); expect(check).toBeUndefined(); }); - it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', () => { - const result = svc.deleteTemplateItem('1', '99999') as any; + it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', async () => { + const result = await svc.deleteTemplateItem('1', '99999') as any; expect(result.status).toBe(404); }); }); @@ -1095,25 +1097,25 @@ describe('Template items', () => { // ── Quirk fix landed after the 2026-08 admin fold ───────────────────────────── describe('Template item scoping (post-fold quirk fix)', () => { - it('ADMIN-SVC-076 — update/deleteTemplateItem honour :templateId instead of ignoring it', () => { + it('ADMIN-SVC-076 — update/deleteTemplateItem honour :templateId instead of ignoring it', async () => { const { user: admin } = createAdmin(testDb); - const tplA = svc.createPackingTemplate('A', admin.id) as any; - const tplB = svc.createPackingTemplate('B', admin.id) as any; - const catA = svc.createTemplateCategory(String(tplA.template.id), 'Gear') as any; - const item = svc.createTemplateItem(String(tplA.template.id), String(catA.category.id), 'Tent') as any; + const tplA = await svc.createPackingTemplate('A', admin.id) as any; + const tplB = await svc.createPackingTemplate('B', admin.id) as any; + const catA = await svc.createTemplateCategory(String(tplA.template.id), 'Gear') as any; + const item = await svc.createTemplateItem(String(tplA.template.id), String(catA.category.id), 'Tent') as any; // Template B does not own the item — both routes must 404 rather than act. - expect(svc.updateTemplateItem(String(tplB.template.id), String(item.item.id), { name: 'Hijacked' }) as any) + expect(await svc.updateTemplateItem(String(tplB.template.id), String(item.item.id), { name: 'Hijacked' }) as any) .toMatchObject({ status: 404 }); - expect(svc.deleteTemplateItem(String(tplB.template.id), String(item.item.id)) as any) + expect(await svc.deleteTemplateItem(String(tplB.template.id), String(item.item.id)) as any) .toMatchObject({ status: 404 }); expect((testDb.prepare('SELECT name FROM packing_template_items WHERE id = ?').get(item.item.id) as any).name) .toBe('Tent'); // The owning template still works. - expect((svc.updateTemplateItem(String(tplA.template.id), String(item.item.id), { name: 'Tarp' }) as any).item.name) + expect((await svc.updateTemplateItem(String(tplA.template.id), String(item.item.id), { name: 'Tarp' }) as any).item.name) .toBe('Tarp'); - expect(svc.deleteTemplateItem(String(tplA.template.id), String(item.item.id)) as any).toEqual({}); + expect(await svc.deleteTemplateItem(String(tplA.template.id), String(item.item.id)) as any).toEqual({}); }); }); @@ -1126,71 +1128,71 @@ describe('Template item scoping (post-fold quirk fix)', () => { * handed the enriched row back, which made the write a read as well. */ describe('packing item object-level authorization', () => { - const restrictedTrip = () => { + const restrictedTrip = async () => { const { user: owner } = createUser(testDb); const { user: intruder } = createUser(testDb); const { user: friend } = createUser(testDb); const trip = createTrip(testDb, owner.id); // The recipient is a fellow traveller; the intruder stays off the trip on purpose. addTripMember(testDb, trip.id, friend.id); - const personal = svc.createItem(trip.id, { name: 'Diary', visibility: 'personal' }, owner.id) as any; - const shared = svc.createItem(trip.id, { name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] }, owner.id) as any; - const common = svc.createItem(trip.id, { name: 'Tent', visibility: 'common' }, owner.id) as any; + const personal = await svc.createItem(trip.id, { name: 'Diary', visibility: 'personal' }, owner.id) as any; + const shared = await svc.createItem(trip.id, { name: 'Power bank', visibility: 'shared', recipient_ids: [friend.id] }, owner.id) as any; + const common = await svc.createItem(trip.id, { name: 'Tent', visibility: 'common' }, owner.id) as any; return { owner, intruder, friend, trip, personal, shared, common }; }; - it('PACK-SVC-101: a non-viewer cannot update someone else\'s Personal item', () => { - const { trip, personal, intruder } = restrictedTrip(); - expect(svc.updateItem(trip.id, personal.id, { name: 'pwned' }, ['name'], undefined, intruder.id)).toBeNull(); + it('PACK-SVC-101: a non-viewer cannot update someone else\'s Personal item', async () => { + const { trip, personal, intruder } = await restrictedTrip(); + expect(await svc.updateItem(trip.id, personal.id, { name: 'pwned' }, ['name'], undefined, intruder.id)).toBeNull(); expect(testDb.prepare('SELECT name FROM packing_items WHERE id = ?').get(personal.id)).toEqual({ name: 'Diary' }); }); - it('PACK-SVC-102: a non-recipient cannot update a Shared item', () => { - const { trip, shared, intruder } = restrictedTrip(); - expect(svc.updateItem(trip.id, shared.id, { name: 'pwned' }, ['name'], undefined, intruder.id)).toBeNull(); + it('PACK-SVC-102: a non-recipient cannot update a Shared item', async () => { + const { trip, shared, intruder } = await restrictedTrip(); + expect(await svc.updateItem(trip.id, shared.id, { name: 'pwned' }, ['name'], undefined, intruder.id)).toBeNull(); }); - it('PACK-SVC-103: a non-viewer cannot delete a restricted item', () => { - const { trip, personal, intruder } = restrictedTrip(); - expect(svc.deleteItem(trip.id, personal.id, intruder.id)).toBeNull(); + it('PACK-SVC-103: a non-viewer cannot delete a restricted item', async () => { + const { trip, personal, intruder } = await restrictedTrip(); + expect(await svc.deleteItem(trip.id, personal.id, intruder.id)).toBeNull(); expect(testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE id = ?').get(personal.id)).toEqual({ n: 1 }); }); - it('PACK-SVC-104: a non-viewer cannot clone a restricted item into their own list', () => { - const { trip, personal, intruder } = restrictedTrip(); - expect(svc.cloneItem(trip.id, personal.id, intruder.id)).toBeNull(); + it('PACK-SVC-104: a non-viewer cannot clone a restricted item into their own list', async () => { + const { trip, personal, intruder } = await restrictedTrip(); + expect(await svc.cloneItem(trip.id, personal.id, intruder.id)).toBeNull(); }); - it('PACK-SVC-105b: a visible item the caller does not own still reports forbidden, not missing', () => { - const { trip, common, intruder } = restrictedTrip(); - expect((svc.setItemSharing(trip.id, common.id, intruder.id, 'personal', []) as any).forbidden).toBe(true); + it('PACK-SVC-105b: a visible item the caller does not own still reports forbidden, not missing', async () => { + const { trip, common, intruder } = await restrictedTrip(); + expect((await svc.setItemSharing(trip.id, common.id, intruder.id, 'personal', []) as any).forbidden).toBe(true); }); - it('PACK-SVC-105: a non-owner cannot re-share a restricted item they cannot see', () => { - const { trip, personal, intruder } = restrictedTrip(); - expect(svc.setItemSharing(trip.id, personal.id, intruder.id, 'common', [])).toBeNull(); + it('PACK-SVC-105: a non-owner cannot re-share a restricted item they cannot see', async () => { + const { trip, personal, intruder } = await restrictedTrip(); + expect(await svc.setItemSharing(trip.id, personal.id, intruder.id, 'common', [])).toBeNull(); expect(testDb.prepare('SELECT is_private FROM packing_items WHERE id = ?').get(personal.id)).toEqual({ is_private: 1 }); }); // Missing actor must deny rather than fall through unfiltered. - it('PACK-SVC-106: an update with no actor at all is refused', () => { - const { trip, personal } = restrictedTrip(); - expect(svc.updateItem(trip.id, personal.id, { name: 'pwned' }, ['name'])).toBeNull(); - expect(svc.deleteItem(trip.id, personal.id)).toBeNull(); + it('PACK-SVC-106: an update with no actor at all is refused', async () => { + const { trip, personal } = await restrictedTrip(); + expect(await svc.updateItem(trip.id, personal.id, { name: 'pwned' }, ['name'])).toBeNull(); + expect(await svc.deleteItem(trip.id, personal.id)).toBeNull(); }); - it('PACK-SVC-107: the owner and the recipients keep working', () => { - const { trip, personal, shared, common, owner, friend, intruder } = restrictedTrip(); - expect(svc.updateItem(trip.id, personal.id, { name: 'Diary v2' }, ['name'], undefined, owner.id)).toBeTruthy(); - expect(svc.updateItem(trip.id, shared.id, { checked: 1 }, ['checked'], undefined, friend.id)).toBeTruthy(); - expect(svc.updateItem(trip.id, common.id, { checked: 1 }, ['checked'], undefined, intruder.id)).toBeTruthy(); - expect(svc.cloneItem(trip.id, common.id, intruder.id)).toBeTruthy(); - expect(svc.deleteItem(trip.id, personal.id, owner.id)).toBeTruthy(); + it('PACK-SVC-107: the owner and the recipients keep working', async () => { + const { trip, personal, shared, common, owner, friend, intruder } = await restrictedTrip(); + expect(await svc.updateItem(trip.id, personal.id, { name: 'Diary v2' }, ['name'], undefined, owner.id)).toBeTruthy(); + expect(await svc.updateItem(trip.id, shared.id, { checked: 1 }, ['checked'], undefined, friend.id)).toBeTruthy(); + expect(await svc.updateItem(trip.id, common.id, { checked: 1 }, ['checked'], undefined, intruder.id)).toBeTruthy(); + expect(await svc.cloneItem(trip.id, common.id, intruder.id)).toBeTruthy(); + expect(await svc.deleteItem(trip.id, personal.id, owner.id)).toBeTruthy(); }); - it('PACK-SVC-108: a template captures only the Common list and the actor\'s own items', () => { - const { trip, intruder } = restrictedTrip(); - const templateId = (svc.saveAsTemplate(trip.id, intruder.id, 'Snapshot') as { id: number }).id; + it('PACK-SVC-108: a template captures only the Common list and the actor\'s own items', async () => { + const { trip, intruder } = await restrictedTrip(); + const templateId = (await svc.saveAsTemplate(trip.id, intruder.id, 'Snapshot') as { id: number }).id; const rows = testDb.prepare(` SELECT i.name FROM packing_template_items i JOIN packing_template_categories c ON c.id = i.category_id diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index dd2c1b309..8412a6ade 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -73,6 +73,7 @@ beforeAll(async () => { permissionsStub, new QueryHelpersService(new DatabaseService(testDb)), photoCacheStub, + await createTestUnitOfWork(testDb), ); }); @@ -91,10 +92,10 @@ function shareRow(tripId: number | string) { } /** Owner + trip + a share link with all five flags on unless overridden. */ -function seedSharedTrip(flags: Record = {}) { +async function seedSharedTrip(flags: Record = {}) { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const { token } = svc.createOrUpdate(String(trip.id), user.id, { + const { token } = await svc.createOrUpdate(String(trip.id), user.id, { share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true, ...flags, }); @@ -104,15 +105,15 @@ function seedSharedTrip(flags: Record = {}) { // ── verifyTripAccess / canManage ────────────────────────────────────────────── describe('verifyTripAccess and canManage', () => { - it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => { + it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id); - expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined(); - expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy(); + expect((await svc.verifyTripAccess(String(trip.id), owner.id))?.id).toBe(trip.id); + expect(await svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined(); + expect(await svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy(); }); it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', async () => { @@ -130,11 +131,11 @@ describe('verifyTripAccess and canManage', () => { // ── createOrUpdate ──────────────────────────────────────────────────────────── describe('createOrUpdate', () => { - it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => { + it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const before = Date.now(); - const result = svc.createOrUpdate(String(trip.id), user.id, {}); + const result = await svc.createOrUpdate(String(trip.id), user.id, {}); expect(result.created).toBe(true); expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url const row = shareRow(trip.id); @@ -148,10 +149,10 @@ describe('createOrUpdate', () => { expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000); }); - it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => { + it('SHARE-SVC-004: explicit flags override the defaults in both directions', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.createOrUpdate(String(trip.id), user.id, { + await svc.createOrUpdate(String(trip.id), user.id, { share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true, }); const row = shareRow(trip.id); @@ -159,24 +160,24 @@ describe('createOrUpdate', () => { .toEqual([0, 0, 1, 1, 1]); }); - it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => { + it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const first = svc.createOrUpdate(String(trip.id), user.id, {}); - const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true }); + const first = await svc.createOrUpdate(String(trip.id), user.id, {}); + const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true }); expect(second).toEqual({ token: first.token, created: false }); expect(shareRow(trip.id).share_budget).toBe(1); }); - it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', () => { + it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); + await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); // Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it // onto the 90-day clock. testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); const before = Date.now(); - svc.createOrUpdate(String(trip.id), user.id, {}); + await svc.createOrUpdate(String(trip.id), user.id, {}); const row = shareRow(trip.id); // Omitted share_packing fell back to its default (off) — destructuring // defaults apply on every call, not only on create. @@ -191,17 +192,17 @@ describe('createOrUpdate', () => { // ── get / remove ────────────────────────────────────────────────────────────── describe('get and remove', () => { - it('SHARE-SVC-007: get returns null when no link exists', () => { + it('SHARE-SVC-007: get returns null when no link exists', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - expect(svc.get(String(trip.id))).toBeNull(); + expect(await svc.get(String(trip.id))).toBeNull(); }); - it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => { + it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); - const info = svc.get(String(trip.id)); + const { token } = await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); + const info = await svc.get(String(trip.id)); expect(info).toEqual({ token, created_at: expect.any(String), @@ -213,13 +214,13 @@ describe('get and remove', () => { }); }); - it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => { + it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - svc.createOrUpdate(String(trip.id), user.id, {}); - svc.remove(String(trip.id)); + await svc.createOrUpdate(String(trip.id), user.id, {}); + await svc.remove(String(trip.id)); expect(shareRow(trip.id)).toBeUndefined(); - expect(() => svc.remove(String(trip.id))).not.toThrow(); + await expect(svc.remove(String(trip.id))).resolves.toBeUndefined(); }); }); @@ -231,7 +232,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); expect(await svc.getSharedTripData(token)).toBeNull(); testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); @@ -239,7 +240,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-012: returns null when the trip row is gone', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); // The token normally cascades away with its trip; orphan it deliberately // to pin the defensive `if (!trip) return null` branch. testDb.exec('PRAGMA foreign_keys = OFF'); @@ -249,7 +250,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2025-06-01' }); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); @@ -275,7 +276,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-014: COALESCEs assignment times over place times', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id); @@ -290,7 +291,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id); const p1 = createPlace(testDb, trip.id, { name: 'First' }); const p2 = createPlace(testDb, trip.id, { name: 'Second' }); @@ -305,7 +306,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id); const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id); @@ -319,7 +320,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => { - const { user, trip, token } = seedSharedTrip(); + const { user, trip, token } = await seedSharedTrip(); testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id); testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id); const packing = (await (await svc.getSharedTripData(token))!).packing; @@ -327,19 +328,19 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => { - const { user, trip, token } = seedSharedTrip(); + const { user, trip, token } = await seedSharedTrip(); testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id); testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id); const collab = (await (await svc.getSharedTripData(token))!).collab; expect(collab).toHaveLength(1); expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username })); - svc.createOrUpdate(String(trip.id), user.id, { share_collab: false }); + await svc.createOrUpdate(String(trip.id), user.id, { share_collab: false }); expect((await (await svc.getSharedTripData(token))!).collab).toEqual([]); }); it('SHARE-SVC-019: withholds each section when its flag is off', async () => { - const { trip, token } = seedSharedTrip({ + const { trip, token } = await seedSharedTrip({ share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false, }); const day = createDay(testDb, trip.id); @@ -361,7 +362,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => { - const { user, trip, token } = seedSharedTrip(); + const { user, trip, token } = await seedSharedTrip(); expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('EUR'); testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id); expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('USD'); @@ -370,7 +371,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const proxied = createPlace(testDb, trip.id, { name: 'Proxied' }); const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' }); createPlace(testDb, trip.id, { name: 'Bare' }); @@ -385,7 +386,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => { - const { user, token } = seedSharedTrip(); + const { user, token } = await seedSharedTrip(); expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe(''); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('instance-key'); @@ -394,7 +395,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state) VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number) @@ -408,7 +409,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); for (let i = 0; i < 5; i++) { testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`); @@ -418,7 +419,7 @@ describe('getSharedTripData', () => { }); it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const stay = (): number => testDb.prepare(` @@ -440,7 +441,7 @@ describe('getSharedTripData', () => { describe('getSharedTripData redaction (#2320)', () => { it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) @@ -459,7 +460,7 @@ describe('getSharedTripData redaction (#2320)', () => { }); it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const metadata = JSON.stringify({ airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123', @@ -489,7 +490,7 @@ describe('getSharedTripData redaction (#2320)', () => { }); it('SHARE-SVC-034: a booking carries its ordered stops', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) @@ -506,7 +507,7 @@ describe('getSharedTripData redaction (#2320)', () => { }); it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url); @@ -524,7 +525,7 @@ describe('getSharedTripData redaction (#2320)', () => { }); it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue', @@ -547,7 +548,7 @@ describe('getSharedTripData redaction (#2320)', () => { }); it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) @@ -560,7 +561,7 @@ describe('getSharedTripData redaction (#2320)', () => { expect(JSON.stringify(data)).not.toContain('HOTEL-777'); }); - it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => { + it('SHARE-SVC-038: metadata that is not an object comes back as nothing', async () => { expect(publicReservationMetadata(null)).toBeNull(); expect(publicReservationMetadata('not json')).toBeNull(); expect(publicReservationMetadata('[1,2]')).toBeNull(); @@ -575,14 +576,14 @@ describe('getSharedTripData redaction (#2320)', () => { describe('getSharedPlacePhotoKey', () => { it('SHARE-SVC-022: returns null for an unknown or expired token', async () => { expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull(); - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); expect(serveKey).not.toHaveBeenCalled(); }); it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => { - const { trip, token } = seedSharedTrip({ share_map: false }); + const { trip, token } = await seedSharedTrip({ share_map: false }); const place = createPlace(testDb, trip.id); testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); @@ -590,13 +591,13 @@ describe('getSharedPlacePhotoKey', () => { }); it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', async () => { - const { token } = seedSharedTrip(); + const { token } = await seedSharedTrip(); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); expect(serveKey).not.toHaveBeenCalled(); }); it('SHARE-SVC-025: resolves via serveKey when the encoded placeId matches the stored URL', async () => { - const { trip, token } = seedSharedTrip(); + const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id); // Wikimedia pseudo-IDs contain characters that must round-trip encoded. const placeId = 'coords:48.8,2.3'; diff --git a/server/tests/unit/nest/trek-photos.repository.test.ts b/server/tests/unit/nest/trek-photos.repository.test.ts index 11bf8dabf..dba42beb5 100644 --- a/server/tests/unit/nest/trek-photos.repository.test.ts +++ b/server/tests/unit/nest/trek-photos.repository.test.ts @@ -43,8 +43,8 @@ afterAll(() => testDb.close()); // A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb // leaves trek_photos alone, so a shared path would hand every test the same row. let seq = 0; -function makePhoto(): number { - return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null); +async function makePhoto(): Promise { + return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null); } function read(id: number) { @@ -54,41 +54,41 @@ function read(id: number) { } describe('TrekPhotosRepository.recordCaptureMetadata', () => { - it('TREKPHOTO-001: stores the capture time and coordinates', () => { - const id = makePhoto(); - repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + it('TREKPHOTO-001: stores the capture time and coordinates', async () => { + const id = await makePhoto(); + await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); - it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => { - const id = makePhoto(); - repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + it('TREKPHOTO-002: a later, emptier answer does not erase what is known', async () => { + const id = await makePhoto(); + await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); // The album listing knows the date but not the place. - repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null }); + await repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null }); expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); - it('TREKPHOTO-003: fills only the half that was still missing', () => { - const id = makePhoto(); - repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' }); - repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 }); + it('TREKPHOTO-003: fills only the half that was still missing', async () => { + const id = await makePhoto(); + await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' }); + await repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 }); expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); - it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => { - const id = makePhoto(); - repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null }); + it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', async () => { + const id = await makePhoto(); + await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null }); expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null }); }); - it('TREKPHOTO-005: an answer with nothing in it touches no row', () => { - const id = makePhoto(); - repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); - repo.recordCaptureMetadata(id, {}); + it('TREKPHOTO-005: an answer with nothing in it touches no row', async () => { + const id = await makePhoto(); + await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); + await repo.recordCaptureMetadata(id, {}); expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 }); }); 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 0992ef258..4bc8cf152 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -116,7 +116,7 @@ beforeAll(async () => { const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) => new TripReadModelService( database, roster, daysSvc, accommodationsSvc, budgetSvc, - new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), + new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)), new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)), new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)), placesSvc, diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index b85d4ef50..56e6b60e9 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -152,7 +152,7 @@ beforeAll(async () => { membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)); readModelSvc = new TripReadModelService( dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc, - new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), + new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)), new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)), new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)), placesSvc, diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index c6fd861c2..19a86b6d2 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -51,32 +51,39 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import type { ImmichService } from '../../../src/nest/memories/immich.service'; import type { SynologyService } from '../../../src/nest/memories/synology.service'; import { notificationsStub } from '../../helpers/notifications'; +import { createTestUnitOfWork } from '../../helpers/test-uow'; // The album-sync paths are the providers' half and have their own suites; these // cases never reach them, so stubs keep the graph small. const dbs = new DatabaseService(testDb); -const svc = new UnifiedMemoriesService( - dbs, - new TrekPhotosRepository(dbs), - {} as ImmichService, - {} as SynologyService, - new MemoriesAccessService(dbs), - notificationsStub(), - new AddonsService(dbs), -); +let svc: UnifiedMemoriesService; -// Legacy free-function names bound to the service, so the moved cases read as before. -const listTripPhotos = svc.listTripPhotos.bind(svc); -const listTripAlbumLinks = svc.listTripAlbumLinks.bind(svc); -const addTripPhotos = svc.addTripPhotos.bind(svc); -const setTripPhotoSharing = svc.setTripPhotoSharing.bind(svc); -const removeTripPhoto = svc.removeTripPhoto.bind(svc); -const createTripAlbumLink = svc.createTripAlbumLink.bind(svc); -const removeAlbumLink = svc.removeAlbumLink.bind(svc); +// Legacy free-function names forwarding to the service, so the moved cases read +// as before. Typed forwarders rather than `.bind` aliases: a bound alias is +// typed `any`, which hides a missing `await` from tsc and from all three lint +// rules now that these methods are async. +type Svc = UnifiedMemoriesService; +const listTripPhotos = (...a: Parameters) => svc.listTripPhotos(...a); +const listTripAlbumLinks = (...a: Parameters) => svc.listTripAlbumLinks(...a); +const addTripPhotos = (...a: Parameters) => svc.addTripPhotos(...a); +const setTripPhotoSharing = (...a: Parameters) => svc.setTripPhotoSharing(...a); +const removeTripPhoto = (...a: Parameters) => svc.removeTripPhoto(...a); +const createTripAlbumLink = (...a: Parameters) => svc.createTripAlbumLink(...a); +const removeAlbumLink = (...a: Parameters) => svc.removeAlbumLink(...a); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + svc = new UnifiedMemoriesService( + dbs, + new TrekPhotosRepository(dbs), + {} as ImmichService, + {} as SynologyService, + new MemoriesAccessService(dbs), + notificationsStub(), + new AddonsService(dbs), + await createTestUnitOfWork(testDb), + ); }); beforeEach(() => { @@ -200,7 +207,7 @@ describe('addTripPhotos', () => { describe('setTripPhotoSharing', () => { it('MEM-UNIFIED-008: returns 404 when user cannot access trip', async () => { - const result = await setTripPhotoSharing('9999', 1, 'immich', 'asset-1', true); + const result = await setTripPhotoSharing('9999', 1, 1, true); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); @@ -209,8 +216,8 @@ describe('setTripPhotoSharing', () => { // ── removeTripPhoto ─────────────────────────────────────────────────────────── describe('removeTripPhoto', () => { - it('MEM-UNIFIED-009: returns 404 when user cannot access trip', () => { - const result = removeTripPhoto('9999', 1, 'immich', 'asset-1'); + it('MEM-UNIFIED-009: returns 404 when user cannot access trip', async () => { + const result = await removeTripPhoto('9999', 1, 1); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); @@ -242,8 +249,8 @@ describe('createTripAlbumLink', () => { // ── removeAlbumLink ─────────────────────────────────────────────────────────── describe('removeAlbumLink', () => { - it('MEM-UNIFIED-012: returns 404 when user cannot access trip', () => { - const result = removeAlbumLink('9999', '1', 1); + it('MEM-UNIFIED-012: returns 404 when user cannot access trip', async () => { + const result = await removeAlbumLink('9999', '1', 1); expect(result.success).toBe(false); expect((result as any).error.status).toBe(404); }); diff --git a/server/tests/unit/packing/packing.rpc.test.ts b/server/tests/unit/packing/packing.rpc.test.ts index 5630cc150..3a6b21151 100644 --- a/server/tests/unit/packing/packing.rpc.test.ts +++ b/server/tests/unit/packing/packing.rpc.test.ts @@ -32,7 +32,7 @@ type Item = { id: number; name?: string; is_private?: number; owner_id?: number */ function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: Item | null } = {}) { const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; - const packing = new PackingService({} as never, {} as never, realtime, notificationsStub()); + const packing = new PackingService({} as never, {} as never, realtime, notificationsStub(), {} as never); const data = { listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]), // The bodies here are the boring fixture; the item writes are typed as Item so a diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 87b3a1a68..60adc2203 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -68,7 +68,7 @@ const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/go let packing: PackingService; let places: PlacesService; beforeAll(async () => { - packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub()); + packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection)); places = new PlacesService( dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), @@ -145,25 +145,25 @@ describe('PlacesService.update — optimistic concurrency', () => { }); describe('updateItem (packing) — optimistic concurrency', () => { - it('migration added updated_at and createItem stamps it', () => { + it('migration added updated_at and createItem stamps it', async () => { const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[]; expect(cols.map(c => c.name)).toContain('updated_at'); const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null }; + const item = await packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null }; expect(item.updated_at).toBeTruthy(); }); - it('returns a conflict when the packing token is stale', () => { + it('returns a conflict when the packing token is stale', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const item = packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string }; + const item = await packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string }; - const stale = packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id); + const stale = await packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id); expect(isUpdateConflict(stale)).toBe(true); - const fresh = packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id); + const fresh = await packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id); expect(isUpdateConflict(fresh)).toBe(false); expect((fresh as { name: string }).name).toBe('Edited'); }); diff --git a/server/tests/unit/services/trekPhotoMedia.test.ts b/server/tests/unit/services/trekPhotoMedia.test.ts index f1efdd4e9..984333dd6 100644 --- a/server/tests/unit/services/trekPhotoMedia.test.ts +++ b/server/tests/unit/services/trekPhotoMedia.test.ts @@ -54,22 +54,22 @@ describe('trek_photos media_type', () => { expect(cols).toContain('duration_ms'); }); - it('a local photo defaults to image', () => { - const id = getOrCreateLocalTrekPhoto('journey/a.jpg'); - expect(resolveTrekPhoto(id)!.media_type).toBe('image'); + it('a local photo defaults to image', async () => { + const id = await getOrCreateLocalTrekPhoto('journey/a.jpg'); + expect((await resolveTrekPhoto(id))!.media_type).toBe('image'); }); - it('a local video stores media_type=video + duration', () => { - const id = getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200); - const row = resolveTrekPhoto(id)!; + it('a local video stores media_type=video + duration', async () => { + const id = await getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200); + const row = (await resolveTrekPhoto(id))!; expect(row.media_type).toBe('video'); expect(row.duration_ms).toBe(4200); expect(row.thumbnail_path).toBe('journey/poster.jpg'); }); - it('a provider photo can be registered as video', () => { + it('a provider photo can be registered as video', async () => { const { user } = createUser(testDb); - const id = getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video'); - expect(resolveTrekPhoto(id)!.media_type).toBe('video'); + const id = await getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video'); + expect((await resolveTrekPhoto(id))!.media_type).toBe('video'); }); });