From 6b755314d8f96b1ff9c3462110cc8a3e84f9e14d Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 22 Sep 2026 01:49:18 +0200 Subject: [PATCH] refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork --- server/src/nest/collab/collab.controller.ts | 82 +- server/src/nest/collab/collab.mcp.ts | 58 +- server/src/nest/collab/collab.rpc.ts | 14 +- server/src/nest/collab/collab.service.ts | 79 +- .../collections/collections.controller.ts | 64 +- .../src/nest/collections/collections.mcp.ts | 52 +- .../src/nest/collections/collections.rpc.ts | 11 +- .../nest/collections/collections.service.ts | 448 +++++----- server/src/nest/maps/maps.controller.ts | 6 +- server/src/nest/maps/maps.mcp.ts | 2 +- server/src/nest/maps/maps.service.ts | 12 +- .../place-enrichment.service.ts | 4 +- .../trip-read-model.service.ts | 2 +- server/src/nest/trips/trips.mcp.ts | 4 +- server/tests/helpers/mcp-test-controllers.ts | 4 +- server/tests/helpers/plugin-host.ts | 4 +- .../unit/mcp/tools-tags-maps-weather.test.ts | 10 +- .../tests/unit/nest/collab.controller.test.ts | 129 ++- server/tests/unit/nest/collab.service.test.ts | 210 ++--- .../unit/nest/collections.controller.test.ts | 144 +-- .../unit/nest/collections.service.test.ts | 824 +++++++++--------- server/tests/unit/nest/maps.amap.test.ts | 2 +- .../tests/unit/nest/maps.controller.test.ts | 30 +- server/tests/unit/nest/maps.service.test.ts | 32 +- .../unit/nest/trip-read-model.service.test.ts | 2 +- server/tests/unit/nest/trips.service.test.ts | 2 +- 26 files changed, 1115 insertions(+), 1116 deletions(-) diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index 88ea36699..c2807ee64 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -110,15 +110,15 @@ export class CollabController { private readonly storage: StorageService, ) {} - private requireTrip(tripId: string, user: User) { - const trip = this.collab.verifyTripAccess(tripId, user.id); + private async requireTrip(tripId: string, user: User) { + const trip = await this.collab.verifyTripAccess(tripId, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } return trip; } - private async requireEdit(trip: NonNullable>, user: User): Promise { + private async requireEdit(trip: NonNullable>>, user: User): Promise { if (!(await this.collab.canEdit(trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } @@ -127,15 +127,15 @@ export class CollabController { // ── Notes ─────────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('notes') - listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { notes: this.collab.listNotes(tripId) }; + async listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { notes: await this.collab.listNotes(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('notes') - createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) { - const note = this.collab.createNote(tripId, user.id, { + async createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) { + const note = await this.collab.createNote(tripId, user.id, { title: body.title, content: body.content, category: body.category, @@ -143,15 +143,15 @@ export class CollabController { website: body.website, }); this.collab.broadcast(tripId, 'collab:note:created', { note }, socketId); - this.collab.notifyCollab(tripId, user); + await this.collab.notifyCollab(tripId, user); return { note }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('notes/:id') - updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) { - const note = this.collab.updateNote(tripId, id, { + async updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) { + const note = await this.collab.updateNote(tripId, id, { title: body.title, content: body.content, category: body.category, @@ -187,7 +187,7 @@ export class CollabController { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } }; try { - const trip = this.requireTrip(tripId, user); + const trip = await this.requireTrip(tripId, user); if (!(await this.collab.canUploadFiles(trip, user))) { throw new HttpException({ error: 'No permission to upload files' }, 403); } @@ -201,13 +201,13 @@ export class CollabController { // Commit the spooled upload to its final storage location (atomic // same-volume rename) before the DB row references the final path. await this.storage.put('files', file.filename, { tmpPath: file.path }); - const result = this.collab.addNoteFile(tripId, id, file); + const result = await this.collab.addNoteFile(tripId, id, file); if (!result) { // Already committed, so the spool file is gone; drop the final object. await this.storage.delete('files', file.filename).catch(() => {}); throw new HttpException({ error: 'Note not found' }, 404); } - this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(tripId, id) }, socketId); + this.collab.broadcast(tripId, 'collab:note:updated', { note: await this.collab.getFormattedNoteById(tripId, id) }, socketId); return result; } @@ -218,22 +218,22 @@ export class CollabController { if (!(await this.collab.deleteNoteFile(tripId, id, fileId))) { throw new HttpException({ error: 'File not found' }, 404); } - this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(tripId, id) }, socketId); + this.collab.broadcast(tripId, 'collab:note:updated', { note: await this.collab.getFormattedNoteById(tripId, id) }, socketId); return { success: true }; } // ── Shared links ──────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('links') - listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { links: this.collab.listLinks(tripId) }; + async listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { links: await this.collab.listLinks(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('links') - createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) { - const link = this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) }); + async createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) { + const link = await this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) }); this.collab.broadcast(tripId, 'collab:link:created', { link }, socketId); return { link }; } @@ -241,8 +241,8 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('links/:id') - updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) { - const link = this.collab.updateLink(tripId, id, { + async updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) { + const link = await this.collab.updateLink(tripId, id, { title: body.title, url: body.url, pinned: body.pinned === undefined ? undefined : Boolean(body.pinned), @@ -255,8 +255,8 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('links/:id') - deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.collab.deleteLink(tripId, id)) throw new HttpException({ error: 'Link not found' }, 404); + async deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.collab.deleteLink(tripId, id))) throw new HttpException({ error: 'Link not found' }, 404); this.collab.broadcast(tripId, 'collab:link:deleted', { linkId: Number(id) }, socketId); return { success: true }; } @@ -264,15 +264,15 @@ export class CollabController { // ── Polls ─────────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('polls') - listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { polls: this.collab.listPolls(tripId) }; + async listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { polls: await this.collab.listPolls(tripId) }; } @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('polls') - createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) { - const poll = this.collab.createPoll(tripId, user.id, { + async createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) { + const poll = await this.collab.createPoll(tripId, user.id, { question: body.question, options: body.options, multiple: body.multiple, @@ -287,8 +287,8 @@ export class CollabController { @RequirePermission('collab_edit') @Post('polls/:id/vote') @HttpCode(200) - votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) { - const result = this.collab.votePoll(tripId, id, user.id, body.option_index); + async votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.collab.votePoll(tripId, id, user.id, body.option_index); if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404); if (result.error === 'closed') throw new HttpException({ error: 'Poll is closed' }, 400); if (result.error === 'invalid_index') throw new HttpException({ error: 'Invalid option index' }, 400); @@ -299,8 +299,8 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('polls/:id/close') - closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - const poll = this.collab.closePoll(tripId, id); + async closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + const poll = await this.collab.closePoll(tripId, id); if (!poll) { throw new HttpException({ error: 'Poll not found' }, 404); } @@ -311,8 +311,8 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('polls/:id') - deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - if (!this.collab.deletePoll(tripId, id)) { + async deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.collab.deletePoll(tripId, id))) { throw new HttpException({ error: 'Poll not found' }, 404); } this.collab.broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, socketId); @@ -322,8 +322,8 @@ export class CollabController { // ── Messages ──────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('messages') - listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) { - return { messages: this.collab.listMessages(tripId, before) }; + async listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) { + return { messages: await this.collab.listMessages(tripId, before) }; } // No guard decorators, deliberately, like every other upload route in this @@ -344,7 +344,7 @@ export class CollabController { const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } }); let trip; try { - trip = this.requireTrip(tripId, user); + trip = await this.requireTrip(tripId, user); await this.requireEdit(trip, user); } catch (err) { cleanupSpool(); @@ -376,7 +376,7 @@ export class CollabController { throw err; } const replyTo = body.reply_to === undefined || body.reply_to === '' || body.reply_to === null ? null : Number(body.reply_to); - const result = this.collab.createMessage( + const result = await this.collab.createMessage( tripId, user.id, text, @@ -389,7 +389,7 @@ export class CollabController { } this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId); const preview = text || 'sent an image'; - this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview); + await this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview); return { message: result.message }; } @@ -397,8 +397,8 @@ export class CollabController { @RequirePermission('collab_edit') @Post('messages/:id/react') @HttpCode(200) - react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) { - const result = this.collab.reactMessage(id, tripId, user.id, body.emoji); + async react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.collab.reactMessage(id, tripId, user.id, body.emoji); if (!result.found) { throw new HttpException({ error: 'Message not found' }, 404); } @@ -409,8 +409,8 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('messages/:id') - deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { - const result = this.collab.deleteMessage(tripId, id, user.id); + async deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + const result = await this.collab.deleteMessage(tripId, id, user.id); if (result.error === 'not_found') throw new HttpException({ error: 'Message not found' }, 404); if (result.error === 'not_owner') throw new HttpException({ error: 'You can only delete your own messages' }, 403); this.collab.broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || user.username }, socketId); diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index 6d7a12402..7ff590292 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -95,9 +95,9 @@ export class CollabMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned }); + const note = await this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned }); this.guards.safeBroadcast(tripId, 'collab:note:created', { note }); return ok({ note }); } @@ -126,9 +126,9 @@ export class CollabMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned }); + const note = await this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned }); if (!note) return errorResult('Note not found.'); this.guards.safeBroadcast(tripId, 'collab:note:updated', { note }); return ok({ note }); @@ -147,7 +147,7 @@ export class CollabMcp { }) async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = await this.collab.deleteNote(tripId, noteId); if (!deleted) return errorResult('Note not found.'); @@ -168,8 +168,8 @@ export class CollabMcp { access: { group: 'collab', mode: 'read' }, }) async listCollabPolls({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const polls = this.collab.listPolls(tripId); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const polls = await this.collab.listPolls(tripId); return ok({ polls }); } @@ -194,9 +194,9 @@ export class CollabMcp { ctx: McpContext, ) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); + const poll = await this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); this.guards.safeBroadcast(tripId, 'collab:poll:created', { poll }); return ok({ poll }); } @@ -215,9 +215,9 @@ export class CollabMcp { }) async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); + const result = await this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll }); return ok({ poll: result.poll }); @@ -236,9 +236,9 @@ export class CollabMcp { }) async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const poll = this.collab.closePoll(tripId, pollId); + const poll = await this.collab.closePoll(tripId, pollId); if (!poll) return errorResult('Poll not found.'); this.guards.safeBroadcast(tripId, 'collab:poll:closed', { poll }); return ok({ poll }); @@ -257,9 +257,9 @@ export class CollabMcp { }) async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const deleted = this.collab.deletePoll(tripId, pollId); + const deleted = await this.collab.deletePoll(tripId, pollId); if (!deleted) return errorResult('Poll not found.'); this.guards.safeBroadcast(tripId, 'collab:poll:deleted', { pollId }); return ok({ success: true }); @@ -277,8 +277,8 @@ export class CollabMcp { access: { group: 'collab', mode: 'read' }, }) async listCollabMessages({ tripId, before }: { tripId: number; before?: number }, ctx: McpContext) { - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - const messages = this.collab.listMessages(tripId, before); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + const messages = await this.collab.listMessages(tripId, before); return ok({ messages }); } @@ -296,9 +296,9 @@ export class CollabMcp { }) async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); + const result = await this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:message:created', { message: result.message }); return ok({ message: result.message }); @@ -317,9 +317,9 @@ export class CollabMcp { }) async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const result = this.collab.deleteMessage(tripId, messageId, ctx.userId); + const result = await this.collab.deleteMessage(tripId, messageId, ctx.userId); if (result.error) return errorResult(result.error); this.guards.safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username }); return ok({ success: true }); @@ -339,9 +339,9 @@ export class CollabMcp { }) async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) { if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); - const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); + const result = await this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); if (!result.found) return errorResult('Message not found.'); this.guards.safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions }); return ok({ reactions: result.reactions }); @@ -359,8 +359,8 @@ export class CollabMcp { }) async tripCollabNotesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); - const notes = this.collab.listNotes(id); + if (id === null || !(await this.collab.verifyTripAccess(id, ctx.userId))) return accessDenied(uri.href); + const notes = await this.collab.listNotes(id); return jsonContent(uri.href, notes); } @@ -374,8 +374,8 @@ export class CollabMcp { }) async tripCollabPollsResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); - const polls = this.collab.listPolls(id); + if (id === null || !(await this.collab.verifyTripAccess(id, ctx.userId))) return accessDenied(uri.href); + const polls = await this.collab.listPolls(id); return jsonContent(uri.href, polls); } @@ -389,8 +389,8 @@ export class CollabMcp { }) async tripCollabMessagesResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) { const id = parseId(tripId); - if (id === null || !this.collab.verifyTripAccess(id, ctx.userId)) return accessDenied(uri.href); - const messages = this.collab.listMessages(id); + if (id === null || !(await this.collab.verifyTripAccess(id, ctx.userId))) return accessDenied(uri.href); + const messages = await this.collab.listMessages(id); return jsonContent(uri.href, messages); } } diff --git a/server/src/nest/collab/collab.rpc.ts b/server/src/nest/collab/collab.rpc.ts index 0e7b6cfd3..75eef0262 100644 --- a/server/src/nest/collab/collab.rpc.ts +++ b/server/src/nest/collab/collab.rpc.ts @@ -33,7 +33,7 @@ export class CollabRpc { listNotes(params: Record, ctx: PluginRpcContext): unknown { return this.guards.tripRead(params, ctx, async () => { await this.requireCollabAddon(); - return this.collab.listNotes(num(params.tripId, 'tripId')) as unknown[]; + return (await this.collab.listNotes(num(params.tripId, 'tripId'))) as unknown[]; }); } @@ -41,7 +41,7 @@ export class CollabRpc { listPolls(params: Record, ctx: PluginRpcContext): unknown { return this.guards.tripRead(params, ctx, async () => { await this.requireCollabAddon(); - return this.collab.listPolls(num(params.tripId, 'tripId')) as unknown[]; + return (await this.collab.listPolls(num(params.tripId, 'tripId'))) as unknown[]; }); } @@ -50,7 +50,7 @@ export class CollabRpc { return this.guards.tripRead(params, ctx, async () => { await this.requireCollabAddon(); const before = params.before != null ? num(params.before, 'before') : undefined; - return this.collab.listMessages(num(params.tripId, 'tripId'), before) as unknown[]; + return (await this.collab.listMessages(num(params.tripId, 'tripId'), before)) as unknown[]; }); } @@ -62,7 +62,7 @@ export class CollabRpc { if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required'); await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); await this.requireCollabAddon(); - const note = this.collab.createNote(String(tripId), actor, input as never); + const note = await this.collab.createNote(String(tripId), actor, input as never); this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined); return note; } @@ -76,7 +76,7 @@ export class CollabRpc { if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options'); await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); await this.requireCollabAddon(); - const poll = this.collab.createPoll(String(tripId), actor, input as never); + const poll = await this.collab.createPoll(String(tripId), actor, input as never); this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined); return poll; } @@ -87,7 +87,7 @@ export class CollabRpc { const actor = this.guards.requireActor(ctx, 'collab poll'); await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); await this.requireCollabAddon(); - const result = this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex')); + const result = await this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex')); // The service reports its own validation failures rather than throwing. if (result.error) throw new BadParams(result.error); this.realtime.broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined); @@ -104,7 +104,7 @@ export class CollabRpc { await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION); await this.requireCollabAddon(); const replyTo = typeof params.replyTo === 'number' ? params.replyTo : null; - const result = this.collab.createMessage(String(tripId), actor, params.text, replyTo); + const result = await this.collab.createMessage(String(tripId), actor, params.text, replyTo); if (result.error) throw new BadParams(result.error); this.realtime.broadcast(tripId, 'collab:message:created', { message: result.message }, undefined); return result.message; diff --git a/server/src/nest/collab/collab.service.ts b/server/src/nest/collab/collab.service.ts index 65ad1ff53..7aa6c5907 100644 --- a/server/src/nest/collab/collab.service.ts +++ b/server/src/nest/collab/collab.service.ts @@ -11,6 +11,7 @@ import type { CollabNote, CollabPoll, CollabMessage, TripFile, User } from '../. import { NotificationsService } from '../notifications/notifications.service'; import { StorageService } from '../storage/storage.service'; import { RateLimitService } from '../common/rate-limit.service'; +import { UnitOfWork } from '../database/unit-of-work'; type Trip = TripAccess; @@ -111,7 +112,8 @@ function scrapeOpenGraph(html: string): Omit { * strings). Trip access, the 'collab_edit' / 'file_upload' permissions and the * WebSocket broadcast keep their legacy call paths. Post-migration hardening * on top of the 1:1 move: the multi-statement writes (deleteNote, votePoll) - * run in db.transaction(), getFormattedNoteById is trip-scoped and null-safe, + * run through UnitOfWork.transactional(), getFormattedNoteById is trip-scoped + * and null-safe, * votePoll rejects non-integer indexes, and linkPreview absorbs malformed URLs * instead of throwing. * All consumers are in-container since the trip fold (TripsService and @@ -127,6 +129,7 @@ export class CollabService { private readonly notifications: NotificationsService, private readonly storage: StorageService, private readonly rateLimit: RateLimitService, + private readonly uow: UnitOfWork, ) {} /** @@ -143,7 +146,7 @@ export class CollabService { /** Preview fetches currently in flight, so simultaneous askers share one request. */ private readonly inFlight = new Map>(); - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -163,7 +166,7 @@ export class CollabService { /* Reactions */ /* ------------------------------------------------------------------ */ - private loadReactions(messageId: number | string): ReactionRow[] { + private async loadReactions(messageId: number | string): Promise { return this.db.all(` SELECT r.emoji, r.user_id, u.username FROM collab_message_reactions r @@ -181,7 +184,7 @@ export class CollabService { return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length })); } - reactMessage(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } { + async reactMessage(messageId: number | string, tripId: number | string, userId: number, emoji: string): Promise<{ found: boolean; reactions: GroupedReaction[] }> { const msg = this.db.get('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?', messageId, tripId); if (!msg) return { found: false, reactions: [] }; @@ -192,14 +195,14 @@ export class CollabService { this.db.run('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)', messageId, userId, emoji); } - return { found: true, reactions: this.groupReactions(this.loadReactions(messageId)) }; + return { found: true, reactions: this.groupReactions(await this.loadReactions(messageId)) }; } /* ------------------------------------------------------------------ */ /* Notes */ /* ------------------------------------------------------------------ */ - private formatNote(note: CollabNote) { + private async formatNote(note: CollabNote) { const attachments = this.db.all('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?', note.id); return { ...note, @@ -208,7 +211,7 @@ export class CollabService { }; } - listNotes(tripId: string | number) { + async listNotes(tripId: string | number) { const notes = this.db.all(` SELECT n.*, u.username, u.avatar FROM collab_notes n @@ -217,10 +220,10 @@ export class CollabService { ORDER BY n.pinned DESC, n.updated_at DESC `, tripId); - return notes.map(note => this.formatNote(note)); + return Promise.all(notes.map(note => this.formatNote(note))); } - createNote(tripId: string | number, userId: number, data: { title: string; content?: string | null; category?: string | null; color?: string | null; website?: string | null; pinned?: boolean }) { + async createNote(tripId: string | number, userId: number, data: { title: string; content?: string | null; category?: string | null; color?: string | null; website?: string | null; pinned?: boolean }) { const pinned = data.pinned ? 1 : 0; const result = this.db.run(` INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) @@ -234,7 +237,7 @@ export class CollabService { return this.formatNote(note); } - updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string | null; category?: string | null; color?: string | null; pinned?: number | boolean; website?: string | null }): ReturnType | null { + async updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string | null; category?: string | null; color?: string | null; pinned?: number | boolean; website?: string | null }): Promise> | null> { const existing = this.db.get('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId); if (!existing) return null; @@ -277,7 +280,7 @@ export class CollabService { for (const f of noteFiles) { await this.storage.delete('files', path.basename(f.filename)).catch(() => { /* ignore */ }); } - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM trip_files WHERE note_id = ?', noteId); this.db.run('DELETE FROM collab_notes WHERE id = ?', noteId); }); @@ -288,7 +291,7 @@ export class CollabService { /* Note files */ /* ------------------------------------------------------------------ */ - addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null { + async addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): Promise<{ file: TripFile & { url: string } } | null> { const note = this.db.get('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?', noteId, tripId); if (!note) return null; @@ -301,7 +304,7 @@ export class CollabService { return { file: { ...saved, url: `/api/trips/${tripId}/files/${saved.id}/download` } }; } - getFormattedNoteById(tripId: string | number, noteId: string | number) { + async getFormattedNoteById(tripId: string | number, noteId: string | number) { const note = this.db.get('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? AND n.trip_id = ?', noteId, tripId); if (!note) return null; return this.formatNote(note); @@ -325,7 +328,7 @@ export class CollabService { /* Polls */ /* ------------------------------------------------------------------ */ - private getPollWithVotes(pollId: number | bigint | string) { + private async getPollWithVotes(pollId: number | bigint | string) { const poll = this.db.get(` SELECT p.*, u.username, u.avatar FROM collab_polls p @@ -365,15 +368,15 @@ export class CollabService { }; } - listPolls(tripId: string | number) { + async listPolls(tripId: string | number) { const rows = this.db.all<{ id: number }>(` SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC `, tripId); - return rows.map(row => this.getPollWithVotes(row.id)).filter(Boolean); + return (await Promise.all(rows.map(row => this.getPollWithVotes(row.id)))).filter(Boolean); } - createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) { + async createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) { const isMultiple = data.multiple || data.multiple_choice; const result = this.db.run(` @@ -384,7 +387,7 @@ export class CollabService { return this.getPollWithVotes(result.lastInsertRowid); } - votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType } { + async votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): Promise<{ error?: string; poll?: Awaited> }> { const poll = this.db.get('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); if (!poll) return { error: 'not_found' }; if (poll.closed) return { error: 'closed' }; @@ -402,7 +405,7 @@ export class CollabService { if (existingVote) { this.db.run('DELETE FROM collab_poll_votes WHERE id = ?', existingVote.id); } else { - this.db.transaction(() => { + await this.uow.transactional(async () => { if (!poll.multiple) { this.db.run('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?', pollId, userId); } @@ -410,10 +413,10 @@ export class CollabService { }); } - return { poll: this.getPollWithVotes(pollId) }; + return { poll: await this.getPollWithVotes(pollId) }; } - closePoll(tripId: string | number, pollId: string | number): ReturnType | null { + async closePoll(tripId: string | number, pollId: string | number): Promise> | null> { const poll = this.db.get('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); if (!poll) return null; @@ -421,7 +424,7 @@ export class CollabService { return this.getPollWithVotes(pollId); } - deletePoll(tripId: string | number, pollId: string | number): boolean { + async deletePoll(tripId: string | number, pollId: string | number): Promise { const poll = this.db.get('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?', pollId, tripId); if (!poll) return false; @@ -433,7 +436,7 @@ export class CollabService { /* Shared links */ /* ------------------------------------------------------------------ */ - listLinks(tripId: string | number) { + async listLinks(tripId: string | number) { return this.db.all( `SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.trip_id = ? ORDER BY l.pinned DESC, l.created_at DESC`, @@ -441,7 +444,7 @@ export class CollabService { ); } - createLink(tripId: string | number, userId: number, data: { title: string; url: string; pinned?: boolean }) { + async createLink(tripId: string | number, userId: number, data: { title: string; url: string; pinned?: boolean }) { const result = this.db.run( 'INSERT INTO collab_links (trip_id,user_id,title,url,pinned) VALUES (?,?,?,?,?)', tripId, userId, data.title.trim(), data.url.trim(), data.pinned ? 1 : 0, @@ -449,7 +452,7 @@ export class CollabService { return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', result.lastInsertRowid); } - updateLink(tripId: string | number, linkId: string | number, data: { title?: string; url?: string; pinned?: boolean | number }) { + async updateLink(tripId: string | number, linkId: string | number, data: { title?: string; url?: string; pinned?: boolean | number }) { const existing = this.db.get('SELECT * FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId); if (!existing) return null; this.db.run( @@ -463,7 +466,7 @@ export class CollabService { return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', linkId); } - deleteLink(tripId: string | number, linkId: string | number): boolean { + async deleteLink(tripId: string | number, linkId: string | number): Promise { return this.db.run('DELETE FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId).changes > 0; } @@ -471,7 +474,7 @@ export class CollabService { /* Messages */ /* ------------------------------------------------------------------ */ - private formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) { + private async formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) { const attachments = msg.id && msg.trip_id ? this.db.all( `SELECT id, trip_id, message_id, filename, original_name, file_size, mime_type @@ -496,12 +499,12 @@ export class CollabService { }; } - countMessages(tripId: string | number): number { + async countMessages(tripId: string | number): Promise { const row = this.db.get<{ cnt: number }>('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?', tripId)!; return row.cnt; } - listMessages(tripId: string | number, before?: string | number) { + async listMessages(tripId: string | number, before?: string | number) { const query = ` SELECT m.*, u.username, u.avatar, CASE WHEN rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text, @@ -542,16 +545,16 @@ export class CollabService { } } - return messages.map(m => this.formatMessage(m, this.groupReactions(reactionsByMsg[m.id] || []))); + return Promise.all(messages.map(m => this.formatMessage(m, this.groupReactions(reactionsByMsg[m.id] || [])))); } - createMessage( + async createMessage( tripId: string | number, userId: number, text: string, replyTo?: number | null, files: Array<{ filename: string; originalname: string; size: number; mimetype: string }> = [], - ): { error?: string; message?: ReturnType } { + ): Promise<{ error?: string; message?: Awaited> }> { if (replyTo) { // A soft-deleted message is gone as far as anyone replying is concerned: // its row survives only so the placeholder can be drawn where it was. @@ -564,7 +567,7 @@ export class CollabService { // One transaction: the caller has already committed the image bytes to // storage, so a message row that lands without its attachment rows would // leave those bytes with nothing pointing at them and nothing to sweep them. - const result = this.db.transaction(() => { + const result = await this.uow.transactional(async () => { const inserted = this.db.run(` INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?) `, tripId, userId, text.trim(), replyTo || null); @@ -590,16 +593,16 @@ export class CollabService { WHERE m.id = ? `, result.lastInsertRowid)!; - return { message: this.formatMessage(message) }; + return { message: await this.formatMessage(message) }; } - deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } { + async deleteMessage(tripId: string | number, messageId: string | number, userId: number): Promise<{ error?: string; username?: string }> { const message = this.db.get('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?', messageId, tripId); if (!message) return { error: 'not_found' }; if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' }; const attachments = this.db.all<{ filename: string }>('SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId); - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId); this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId); }); @@ -643,6 +646,8 @@ export class CollabService { return { ...fallback, rateLimited: true }; } + // Memoised in-flight fetch, not a missing await: the promise is stored so the + // concurrent askers above can join it, and this frame awaits it below. const task = this.fetchPreview(url, fallback); this.inFlight.set(url, task); try { @@ -725,7 +730,7 @@ export class CollabService { } /** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */ - notifyCollab(tripId: string, actor: User, preview?: string): void { + async notifyCollab(tripId: string, actor: User, preview?: string): Promise { // Injected, not a lazy import of the old notifications bridge. The laziness bought // nothing the module graph does not already give — NotificationsModule // reaches nothing in this direction — and it hid the edge while handing the diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index f5aa17230..d15500685 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -99,8 +99,8 @@ export class CollectionsController { @Post('reorder') @HttpCode(200) - reorder(@CurrentUser() user: User, @Body() body: CollectionReorderDto) { - this.collections.reorderCollections(user.id, body.orderedIds); + async reorder(@CurrentUser() user: User, @Body() body: CollectionReorderDto) { + await this.collections.reorderCollections(user.id, body.orderedIds); return { success: true }; } @@ -260,8 +260,8 @@ export class CollectionsController { } @Delete('labels/:lid') - deleteLabel(@CurrentUser() user: User, @Param('lid') lid: string, @Headers('x-socket-id') socketId?: string) { - this.collections.deleteLabel(user.id, Number(lid), socketId); + async deleteLabel(@CurrentUser() user: User, @Param('lid') lid: string, @Headers('x-socket-id') socketId?: string) { + await this.collections.deleteLabel(user.id, Number(lid), socketId); return { success: true }; } @@ -307,12 +307,12 @@ export class CollectionsController { // ── Fusion invitations ────────────────────────────────────────────────────── @Post('invite') @HttpCode(200) - invite(@CurrentUser() user: User, @Body() body: CollectionInviteDto) { - this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible (no enumeration) - if (!this.collections.isOwner(user.id, body.collection_id)) { + async invite(@CurrentUser() user: User, @Body() body: CollectionInviteDto) { + await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible (no enumeration) + if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can invite' }, 403); } - const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role); + const result = await this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -321,8 +321,8 @@ export class CollectionsController { @Post('invite/accept') @HttpCode(200) - acceptInvite(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { - const result = this.collections.acceptInvite(user.id, body.collection_id, socketId); + async acceptInvite(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.collections.acceptInvite(user.id, body.collection_id, socketId); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -331,59 +331,59 @@ export class CollectionsController { @Post('invite/decline') @HttpCode(200) - declineInvite(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { - this.collections.declineInvite(user.id, body.collection_id, socketId); + async declineInvite(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { + await this.collections.declineInvite(user.id, body.collection_id, socketId); return { success: true }; } @Post('invite/cancel') @HttpCode(200) - cancelInvite(@CurrentUser() user: User, @Body() body: CollectionInviteCancelDto) { - this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible - if (!this.collections.isOwner(user.id, body.collection_id)) { + async cancelInvite(@CurrentUser() user: User, @Body() body: CollectionInviteCancelDto) { + await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible + if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can cancel invites' }, 403); } - this.collections.cancelInvite(body.collection_id, user.id, body.user_id); + await this.collections.cancelInvite(body.collection_id, user.id, body.user_id); return { success: true }; } @Post('leave') @HttpCode(200) - leave(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { - this.collections.leaveCollection(user.id, body.collection_id, socketId); + async leave(@CurrentUser() user: User, @Body() body: CollectionInviteActionDto, @Headers('x-socket-id') socketId?: string) { + await this.collections.leaveCollection(user.id, body.collection_id, socketId); return { success: true }; } @Post('members/remove') @HttpCode(200) - removeMember(@CurrentUser() user: User, @Body() body: CollectionRemoveMemberDto) { - this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible - if (!this.collections.isOwner(user.id, body.collection_id)) { + async removeMember(@CurrentUser() user: User, @Body() body: CollectionRemoveMemberDto) { + await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible + if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can remove members' }, 403); } - this.collections.removeMember(user.id, body.collection_id, body.user_id); + await this.collections.removeMember(user.id, body.collection_id, body.user_id); return { success: true }; } @Post('members/role') @HttpCode(200) - setMemberRole(@CurrentUser() user: User, @Body() body: CollectionSetMemberRoleDto) { - this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible - if (!this.collections.isOwner(user.id, body.collection_id)) { + async setMemberRole(@CurrentUser() user: User, @Body() body: CollectionSetMemberRoleDto) { + await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible + if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can change member roles' }, 403); } - this.collections.setMemberRole(user.id, body.collection_id, body.user_id, body.role); + await this.collections.setMemberRole(user.id, body.collection_id, body.user_id, body.role); return { success: true }; } // ── /:id (declared last so static prefixes win) ───────────────────────────── @Get(':id/available-users') - availableUsers(@CurrentUser() user: User, @Param('id') id: string) { - this.collections.assertAccess(user.id, Number(id)); // 404 if not visible (no enumeration) - if (!this.collections.isOwner(user.id, Number(id))) { + async availableUsers(@CurrentUser() user: User, @Param('id') id: string) { + await this.collections.assertAccess(user.id, Number(id)); // 404 if not visible (no enumeration) + if (!(await this.collections.isOwner(user.id, Number(id)))) { throw new HttpException({ error: 'Only the owner can manage members' }, 403); } - return { users: this.collections.availableUsers(user.id, Number(id)) }; + return { users: await this.collections.availableUsers(user.id, Number(id)) }; } @Post(':id/cover') @@ -458,8 +458,8 @@ export class CollectionsController { } @Delete(':id') - remove(@CurrentUser() user: User, @Param('id') id: string) { - this.collections.deleteCollection(user.id, Number(id)); + async remove(@CurrentUser() user: User, @Param('id') id: string) { + await this.collections.deleteCollection(user.id, Number(id)); return { success: true }; } } diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index 3f68b426e..af89dec5d 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -77,7 +77,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'read' }, }) async listCollections(_args: Record, ctx: McpContext) { - try { return ok(this.collections.listCollections(ctx.userId)); } catch (err) { return fail(err); } + try { return ok(await this.collections.listCollections(ctx.userId)); } catch (err) { return fail(err); } } @Tool({ @@ -89,7 +89,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'read' }, }) async getCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { - try { return ok(this.collections.getCollection(ctx.userId, collectionId)); } catch (err) { return fail(err); } + try { return ok(await this.collections.getCollection(ctx.userId, collectionId)); } catch (err) { return fail(err); } } @Tool({ @@ -104,11 +104,11 @@ export class CollectionsMcp { try { // Owner-only, mirroring the REST gate — availableUsers() itself does no // access check, so without this any token could enumerate the user list. - this.collections.assertAccess(ctx.userId, collectionId); - if (!this.collections.isOwner(ctx.userId, collectionId)) { + await this.collections.assertAccess(ctx.userId, collectionId); + if (!(await this.collections.isOwner(ctx.userId, collectionId))) { return { content: [{ type: 'text' as const, text: 'Only the collection owner can view invitable users.' }], isError: true }; } - return ok({ users: this.collections.availableUsers(ctx.userId, collectionId) }); + return ok({ users: await this.collections.availableUsers(ctx.userId, collectionId) }); } catch (err) { return fail(err); } } @@ -130,7 +130,7 @@ export class CollectionsMcp { query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }, ctx: McpContext, ) { - try { return ok(this.collections.findMembership(ctx.userId, query)); } catch (err) { return fail(err); } + try { return ok(await this.collections.findMembership(ctx.userId, query)); } catch (err) { return fail(err); } } // ── Collections CRUD ───────────────────────────────────────────────── @@ -145,7 +145,7 @@ export class CollectionsMcp { }) async createCollection(body: CollectionCreateRequest, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ collection: this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); } + try { return ok({ collection: await this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); } } @Tool({ @@ -158,7 +158,7 @@ export class CollectionsMcp { }) async updateCollection({ collectionId, ...body }: { collectionId: number } & CollectionUpdateRequest, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ collection: this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); } + try { return ok({ collection: await this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); } } @Tool({ @@ -171,7 +171,7 @@ export class CollectionsMcp { }) async deleteCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -184,7 +184,7 @@ export class CollectionsMcp { }) async reorderCollections({ orderedIds }: { orderedIds: number[] }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } } // ── Places ──────────────────────────────────────────────────────────── @@ -199,7 +199,7 @@ export class CollectionsMcp { }) async savePlaceToCollection(body: CollectionSavePlaceRequest, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok(this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); } + try { return ok(await this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); } } @Tool({ @@ -220,7 +220,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok(this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } + try { return ok(await this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } } @Tool({ @@ -246,7 +246,7 @@ export class CollectionsMcp { }) async setCollectionPlaceStatus({ placeId, status }: { placeId: number; status: CollectionStatus }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ place: this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); } + try { return ok({ place: await this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); } } @Tool({ @@ -262,7 +262,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok(this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); } + try { return ok(await this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); } } @Tool({ @@ -278,7 +278,7 @@ export class CollectionsMcp { }) async rateCollectionPlace({ placeId, rating }: { placeId: number; rating?: number | null }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ place: this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } + try { return ok({ place: await this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } } @Tool({ @@ -319,7 +319,7 @@ export class CollectionsMcp { }) async createCollectionLabel({ collection_id, name, color }: CollectionLabelCreateRequest, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ label: this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); } + try { return ok({ label: await this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); } } @Tool({ @@ -332,7 +332,7 @@ export class CollectionsMcp { }) async updateCollectionLabel({ labelId, ...body }: { labelId: number } & CollectionLabelUpdateRequest, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok({ label: this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); } + try { return ok({ label: await this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); } } @Tool({ @@ -345,7 +345,7 @@ export class CollectionsMcp { }) async deleteCollectionLabel({ labelId }: { labelId: number }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -361,7 +361,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { return ok(this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } + try { return ok(await this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } } // ── Sharing ─────────────────────────────────────────────────────────── @@ -380,7 +380,7 @@ export class CollectionsMcp { // of two where an unexpected throw escaped to the SDK instead of isError. try { const me = this.db.get<{ username: string; email: string }>('SELECT username, email FROM users WHERE id = ?', ctx.userId); - const res = this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role); + const res = await this.collections.sendInvite(collection_id, ctx.userId, me?.username ?? '', me?.email ?? '', user_id, role); if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; return ok({ success: true }); } catch (err) { return fail(err); } @@ -403,7 +403,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -419,7 +419,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -435,7 +435,7 @@ export class CollectionsMcp { ctx: McpContext, ) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -450,7 +450,7 @@ export class CollectionsMcp { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; // try/catch added with the post-fold quirk pass (see inviteToCollection). try { - const res = this.collections.acceptInvite(ctx.userId, collectionId, undefined); + const res = await this.collections.acceptInvite(ctx.userId, collectionId, undefined); if (res.error) return { content: [{ type: 'text' as const, text: res.error }], isError: true }; return ok({ success: true }); } catch (err) { return fail(err); } @@ -466,7 +466,7 @@ export class CollectionsMcp { }) async declineCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } } @Tool({ @@ -479,6 +479,6 @@ export class CollectionsMcp { }) async leaveCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { const demo = await this.denyDemo(ctx.userId); if (demo) return demo; - try { this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } + try { await this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } } } diff --git a/server/src/nest/collections/collections.rpc.ts b/server/src/nest/collections/collections.rpc.ts index 292e89a1a..2c25bc50a 100644 --- a/server/src/nest/collections/collections.rpc.ts +++ b/server/src/nest/collections/collections.rpc.ts @@ -87,9 +87,9 @@ export class CollectionsRpc { const userId = this.requireCollectionsUser(ctx, 'writes'); const placeId = num(params.placeId, 'placeId'); await this.requireCollectionsAddon(); - // deletePlace is async (it deletes the underlying storage object): await it so a - // refusal actually reaches the plugin as RESOURCE_FORBIDDEN/BAD_PARAMS instead of - // being dropped as an unhandled rejection while this returns {deleted: true} anyway. + // Awaited so a refusal actually reaches the plugin as RESOURCE_FORBIDDEN/BAD_PARAMS + // instead of being dropped as an unhandled rejection while this returns + // {deleted: true} anyway. await this.mapCollectionError(() => this.collections.deletePlace(userId, placeId, undefined)); return { deleted: true }; } @@ -108,9 +108,8 @@ export class CollectionsRpc { /** * The service's status-tagged errors, mapped onto the RPC error taxonomy. * - * Async-aware: `deletePlace` is async (it deletes the underlying storage object), - * while `create`/`update`/`savePlace`/`copyToTrip` stay sync. Awaiting inside always - * works for both — a sync throw from `fn()` is caught by this function's own + * Async-aware: every service method it wraps is async. Awaiting inside works for + * a sync callback too — a sync throw from `fn()` is caught by this function's own * try/catch same as an awaited rejection — so every caller goes through one path. */ private async mapCollectionError(fn: () => T | Promise): Promise { diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index ef2c98c48..eb1dadc0c 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -49,6 +49,7 @@ import type { } from '@trek/shared'; import { NotificationsService } from '../notifications/notifications.service'; import { collectionFileToGpx, gpxToCollectionFile, type ExportedCollectionFile } from './collection-gpx.helpers'; +import { UnitOfWork } from '../database/unit-of-work'; /** Links are stored as a JSON TEXT column; parse on read, stringify on write. */ function parseLinks(raw: unknown): CollectionLink[] | undefined { @@ -104,6 +105,7 @@ export class CollectionsService { private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, private readonly storage: StorageService, + private readonly uow: UnitOfWork, ) {} /** @@ -126,7 +128,7 @@ export class CollectionsService { // accepted member. Every read/write goes through assertAccess. // ------------------------------------------------------------------------- - accessibleCollectionIds(userId: number): number[] { + async accessibleCollectionIds(userId: number): Promise { const rows = this.db.all<{ id: number }>(` SELECT id FROM collections WHERE owner_id = ? UNION @@ -135,7 +137,7 @@ export class CollectionsService { return rows.map(r => r.id); } - private isVisible(userId: number, collectionId: number): boolean { + private async isVisible(userId: number, collectionId: number): Promise { const row = this.db.get(` SELECT 1 FROM collections WHERE id = ? AND owner_id = ? UNION @@ -145,19 +147,19 @@ export class CollectionsService { return !!row; } - assertAccess(userId: number, collectionId: number): void { - if (!this.isVisible(userId, collectionId)) httpError(404, 'Collection not found'); + async assertAccess(userId: number, collectionId: number): Promise { + if (!(await this.isVisible(userId, collectionId))) httpError(404, 'Collection not found'); } - isOwner(userId: number, collectionId: number): boolean { + async isOwner(userId: number, collectionId: number): Promise { const row = this.db.get('SELECT 1 FROM collections WHERE id = ? AND owner_id = ?', collectionId, userId); return !!row; } /** The viewer's effective permission on a list: owner (full), or their accepted * member role, or null when they have no access. */ - roleOf(userId: number, collectionId: number): EffectiveRole { - if (this.isOwner(userId, collectionId)) return 'owner'; + async roleOf(userId: number, collectionId: number): Promise { + if (await this.isOwner(userId, collectionId)) return 'owner'; const row = this.db.get<{ role: string }>( "SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", collectionId, userId, @@ -168,20 +170,20 @@ export class CollectionsService { /** Add/edit a place — owner, admin or editor. 404 hides lists you can't see, * 403 for a read-only (viewer) member. */ - assertCanEdit(userId: number, collectionId: number): void { - const r = this.roleOf(userId, collectionId); + async assertCanEdit(userId: number, collectionId: number): Promise { + const r = await this.roleOf(userId, collectionId); if (r === null) httpError(404, 'Collection not found'); if (r === 'viewer') httpError(403, 'You have read-only access to this list'); } /** Delete a place — owner or admin only. */ - assertCanDelete(userId: number, collectionId: number): void { - const r = this.roleOf(userId, collectionId); + async assertCanDelete(userId: number, collectionId: number): Promise { + const r = await this.roleOf(userId, collectionId); if (r === null) httpError(404, 'Collection not found'); if (r !== 'owner' && r !== 'admin') httpError(403, 'Only an admin can delete places from this list'); } - private ownerOf(collectionId: number): number { + private async ownerOf(collectionId: number): Promise { const row = this.db.get<{ owner_id: number }>('SELECT owner_id FROM collections WHERE id = ?', collectionId); if (!row) httpError(404, 'Collection not found'); return row.owner_id; @@ -191,7 +193,7 @@ export class CollectionsService { // Hydration helpers // ------------------------------------------------------------------------- - private loadTagsByCollectionPlaceIds(placeIds: number[]): Record { + private async loadTagsByCollectionPlaceIds(placeIds: number[]): Promise> { const out: Record = {}; if (placeIds.length === 0) return out; const placeholders = placeIds.map(() => '?').join(','); @@ -209,7 +211,7 @@ export class CollectionsService { } /** A list's own label definitions, in display order. */ - private loadLabelsByCollection(collectionId: number): CollectionLabel[] { + private async loadLabelsByCollection(collectionId: number): Promise { return this.db.all( 'SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id = ? ORDER BY sort_order, id', collectionId, @@ -217,7 +219,7 @@ export class CollectionsService { } /** Assigned label ids per place, batched (mirrors loadTagsByCollectionPlaceIds). */ - private loadLabelIdsByPlaceIds(placeIds: number[]): Record { + private async loadLabelIdsByPlaceIds(placeIds: number[]): Promise> { const out: Record = {}; if (placeIds.length === 0) return out; const placeholders = placeIds.map(() => '?').join(','); @@ -233,7 +235,7 @@ export class CollectionsService { } /** Per-voter rating rows (#1435), batched (mirrors loadTagsByCollectionPlaceIds). */ - private loadRatingsByCollectionPlaceIds(placeIds: number[]): Record { + private async loadRatingsByCollectionPlaceIds(placeIds: number[]): Promise> { const out: Record = {}; if (placeIds.length === 0) return out; const rows = this.db.all<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }>(` @@ -250,11 +252,11 @@ export class CollectionsService { return out; } - private hydratePlaces(rows: PlaceRow[]): CollectionPlace[] { + private async hydratePlaces(rows: PlaceRow[]): Promise { const ids = rows.map(r => r.id); - const tagsByPlace = this.loadTagsByCollectionPlaceIds(ids); - const labelsByPlace = this.loadLabelIdsByPlaceIds(ids); - const ratingsByPlace = this.loadRatingsByCollectionPlaceIds(ids); + const tagsByPlace = await this.loadTagsByCollectionPlaceIds(ids); + const labelsByPlace = await this.loadLabelIdsByPlaceIds(ids); + const ratingsByPlace = await this.loadRatingsByCollectionPlaceIds(ids); return rows.map(r => { const { category_name, category_color, category_icon, ...rest } = r; const ratings = ratingsByPlace[r.id] || []; @@ -273,7 +275,7 @@ export class CollectionsService { }); } - private getPlaceById(placeId: number): CollectionPlace { + private async getPlaceById(placeId: number): Promise { const row = this.db.get(` SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon FROM collection_places cp @@ -281,16 +283,16 @@ export class CollectionsService { WHERE cp.id = ? `, placeId); if (!row) httpError(404, 'Place not found'); - return this.hydratePlaces([row])[0]; + return (await this.hydratePlaces([row]))[0]; } - private collectionIdOfPlace(placeId: number): number { + private async collectionIdOfPlace(placeId: number): Promise { const row = this.db.get<{ collection_id: number }>('SELECT collection_id FROM collection_places WHERE id = ?', placeId); if (!row) httpError(404, 'Place not found'); return row.collection_id; } - private buildMembers(collectionId: number): CollectionMember[] { + private async buildMembers(collectionId: number): Promise { const owner = this.db.get>(` SELECT u.id AS user_id, u.username, u.email, u.avatar FROM collections col JOIN users u ON u.id = col.owner_id @@ -308,24 +310,24 @@ export class CollectionsService { return result; } - private getCollectionRow(id: number): Collection { + private async getCollectionRow(id: number): Promise { const col = this.db.get('SELECT * FROM collections WHERE id = ?', id); if (!col) httpError(404, 'Collection not found'); const placeCount = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?', id)!.n; - return { ...col, links: parseLinks(col.links), place_count: placeCount, members: this.buildMembers(id) }; + return { ...col, links: parseLinks(col.links), place_count: placeCount, members: await this.buildMembers(id) }; } // ------------------------------------------------------------------------- // Lists CRUD // ------------------------------------------------------------------------- - listCollections(userId: number): CollectionListResponse { - const ids = this.accessibleCollectionIds(userId); - const collections: Collection[] = ids - .map(id => { - const col = this.getCollectionRow(id); + async listCollections(userId: number): Promise { + const ids = await this.accessibleCollectionIds(userId); + const collections: Collection[] = (await Promise.all(ids + .map(async id => { + const col = await this.getCollectionRow(id); return { ...col, is_owner: col.owner_id === userId }; - }) + }))) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id); const incomingInvites = this.db.all<{ collection_id: number; name: string; from_id: number; from_username: string }>(` @@ -340,9 +342,9 @@ export class CollectionsService { return { collections, incomingInvites }; } - getCollection(userId: number, id: number): CollectionDetailResponse { - this.assertAccess(userId, id); - const collection = this.getCollectionRow(id); + async getCollection(userId: number, id: number): Promise { + await this.assertAccess(userId, id); + const collection = await this.getCollectionRow(id); const rows = this.db.all(` SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon FROM collection_places cp @@ -351,8 +353,8 @@ export class CollectionsService { ORDER BY cp.sort_order, cp.created_at `, id); return { - collection: { ...collection, is_owner: collection.owner_id === userId, labels: this.loadLabelsByCollection(id) }, - places: this.hydratePlaces(rows), + collection: { ...collection, is_owner: collection.owner_id === userId, labels: await this.loadLabelsByCollection(id) }, + places: await this.hydratePlaces(rows), }; } @@ -373,10 +375,10 @@ export class CollectionsService { * and a viewer who can read all of this on screen loses nothing by having it * as a file; what a viewer must not do is write, which no export does. */ - exportCollection(userId: number, id: number): ExportedCollectionFile { - this.assertAccess(userId, id); - const collection = this.getCollectionRow(id); - const labels = this.loadLabelsByCollection(id); + async exportCollection(userId: number, id: number): Promise { + await this.assertAccess(userId, id); + const collection = await this.getCollectionRow(id); + const labels = await this.loadLabelsByCollection(id); const labelNameById = new Map(labels.map(l => [l.id, l.name])); const rows = this.db.all(` @@ -386,7 +388,7 @@ export class CollectionsService { WHERE cp.collection_id = ? ORDER BY cp.sort_order, cp.created_at `, id); - const labelIdsByPlace = this.loadLabelIdsByPlaceIds(rows.map(r => r.id)); + const labelIdsByPlace = await this.loadLabelIdsByPlaceIds(rows.map(r => r.id)); const places: CollectionFilePlace[] = rows.map(row => ({ name: row.name, @@ -429,8 +431,8 @@ export class CollectionsService { * returns. What may leave the instance is decided once, there, and a GPX can * only ever carry less of it. Same access rule too, since it is the same read. */ - exportCollectionGpx(userId: number, id: number): CollectionGpxExport { - return collectionFileToGpx(this.exportCollection(userId, id)); + async exportCollectionGpx(userId: number, id: number): Promise { + return collectionFileToGpx(await this.exportCollection(userId, id)); } /** @@ -456,25 +458,25 @@ export class CollectionsService { * carries no duplicate count. Adding to a list that already exists is * importIntoCollection below. */ - importCollection(userId: number, body: CollectionImportRequest): CollectionImportResult { + async importCollection(userId: number, body: CollectionImportRequest): Promise { const file = body.file; const name = (body.name ?? file.name).trim().slice(0, 120) || file.name; - const result = this.db.transaction(() => { - const collection = this.createCollection(userId, { + const result = await this.uow.transactional(async () => { + const collection = await this.createCollection(userId, { name, description: file.description ?? null, color: file.color ?? undefined, icon: file.icon ?? undefined, }); - const labels = this.labelIdsForFile(collection.id, file.labels); - const counts = this.writeFilePlaces(collection.id, userId, file, labels.byName, { skipDuplicates: false }); + const labels = await this.labelIdsForFile(collection.id, file.labels); + const counts = await this.writeFilePlaces(collection.id, userId, file, labels.byName, { skipDuplicates: false }); return { collectionId: collection.id, ...counts }; }); - const collection = this.getCollectionRow(result.collectionId); + const collection = await this.getCollectionRow(result.collectionId); return { - collection: { ...collection, is_owner: true, labels: this.loadLabelsByCollection(result.collectionId) }, + collection: { ...collection, is_owner: true, labels: await this.loadLabelsByCollection(result.collectionId) }, imported: result.imported, skipped: result.skipped, }; @@ -493,26 +495,26 @@ export class CollectionsService { * Editing rights, not ownership: whoever may add a place here may add a file * of them, and everyone on the list sees the result at once. */ - importIntoCollection( + async importIntoCollection( userId: number, id: number, body: CollectionImportIntoRequest, socketId?: string, - ): CollectionImportResult { - this.assertCanEdit(userId, id); + ): Promise { + await this.assertCanEdit(userId, id); const file = body.file; - const result = this.db.transaction(() => { - const labels = this.labelIdsForFile(id, file.labels); - const counts = this.writeFilePlaces(id, userId, file, labels.byName, { skipDuplicates: true }); + const result = await this.uow.transactional(async () => { + const labels = await this.labelIdsForFile(id, file.labels); + const counts = await this.writeFilePlaces(id, userId, file, labels.byName, { skipDuplicates: true }); return { ...counts, labelsCreated: labels.created }; }); // A file whose places were all already there can still have brought a label // with it, and that is a change the other members should see. if (result.imported > 0 || result.labelsCreated > 0) { - this.notifyCollectionUsers(id, socketId, 'collections:updated'); + await this.notifyCollectionUsers(id, socketId, 'collections:updated'); } - const collection = this.getCollectionRow(id); + const collection = await this.getCollectionRow(id); return { - collection: { ...collection, is_owner: collection.owner_id === userId, labels: this.loadLabelsByCollection(id) }, + collection: { ...collection, is_owner: collection.owner_id === userId, labels: await this.loadLabelsByCollection(id) }, imported: result.imported, skipped: result.skipped, duplicates: result.duplicates, @@ -524,9 +526,9 @@ export class CollectionsService { * name, plus the ones it does not, created in the file's order. A file never * renames or recolours a label that is already there. */ - private labelIdsForFile( + private async labelIdsForFile( collectionId: number, labels: CollectionFileLabel[] | undefined, - ): { byName: Map; created: number } { + ): Promise<{ byName: Map; created: number }> { const byName = new Map(); for (const row of this.db.all<{ id: number; name: string }>('SELECT id, name FROM collection_labels WHERE collection_id = ?', collectionId)) { byName.set(row.name.trim().toLowerCase(), row.id); @@ -538,7 +540,7 @@ export class CollectionsService { for (const label of (labels ?? []).slice(0, MAX_COLLECTION_FILE_LABELS)) { const key = label.name.trim().toLowerCase(); if (!key || byName.has(key)) continue; - byName.set(key, this.insertImportedLabel(collectionId, label, sortOrder)); + byName.set(key, await this.insertImportedLabel(collectionId, label, sortOrder)); sortOrder += 1; created += 1; } @@ -558,20 +560,20 @@ export class CollectionsService { * New places are appended after the ones already there rather than renumbered * from zero, so a manual order survives an import. */ - private writeFilePlaces( + private async writeFilePlaces( collectionId: number, savedBy: number, file: CollectionFile, labelIdByName: Map, opts: { skipDuplicates: boolean }, - ): { imported: number; skipped: number; duplicates: number } { + ): Promise<{ imported: number; skipped: number; duplicates: number }> { // The palette is instance-wide and read-only here: a file names a category, // it does not get to create one. const categoryIdByName = new Map(); for (const c of this.db.all<{ id: number; name: string }>('SELECT id, name FROM categories')) { categoryIdByName.set(c.name.trim().toLowerCase(), c.id); } - const ownerId = this.ownerOf(collectionId); + const ownerId = await this.ownerOf(collectionId); const firstOrder = this.db.get<{ m: number }>( 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_places WHERE collection_id = ?', collectionId, )!.m + 1; @@ -594,7 +596,7 @@ export class CollectionsService { const place = parsed.data; // Rows written earlier in this same run count as already there, so a file // that lists a place twice adds it once. - if (opts.skipDuplicates && this.findDuplicateCollectionPlace(collectionId, { + if (opts.skipDuplicates && await this.findDuplicateCollectionPlace(collectionId, { name: place.name, lat: place.lat ?? null, lng: place.lng ?? null, @@ -630,7 +632,7 @@ export class CollectionsService { * The caller de-duplicates by name, and one notification for the whole * import is sent by the caller rather than one per label. */ - private insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): number { + private async insertImportedLabel(collectionId: number, label: CollectionFileLabel, sortOrder: number): Promise { const res = this.db.run( 'INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)', collectionId, label.name.trim(), label.color ?? '#6366f1', sortOrder, @@ -638,7 +640,7 @@ export class CollectionsService { return Number(res.lastInsertRowid); } - createCollection(userId: number, body: CollectionCreateRequest): Collection { + async createCollection(userId: number, body: CollectionCreateRequest): Promise { const max = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?', userId)!.m; const result = this.db.run(` INSERT INTO collections (owner_id, name, description, color, icon, cover_image, links, sort_order) @@ -653,12 +655,12 @@ export class CollectionsService { serializeLinks(body.links), max + 1, ); - const col = this.getCollectionRow(Number(result.lastInsertRowid)); + const col = await this.getCollectionRow(Number(result.lastInsertRowid)); return { ...col, is_owner: true }; } - updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Collection { - this.assertCanEdit(userId, id); + async updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Promise { + await this.assertCanEdit(userId, id); const updates: string[] = []; const params: (string | number | null)[] = []; if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); } @@ -673,25 +675,25 @@ export class CollectionsService { params.push(id); this.db.run(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`, ...params); } - this.notifyCollectionUsers(id, socketId, 'collections:updated'); - const col = this.getCollectionRow(id); + await this.notifyCollectionUsers(id, socketId, 'collections:updated'); + const col = await this.getCollectionRow(id); return { ...col, is_owner: col.owner_id === userId }; } /** Set (or clear) a list's cover image, reclaiming the previous file. */ async setCollectionCover(userId: number, id: number, coverUrl: string | null, socketId?: string): Promise { - this.assertCanEdit(userId, id); + await this.assertCanEdit(userId, id); const prev = this.db.get<{ cover_image: string | null }>('SELECT cover_image FROM collections WHERE id = ?', id)?.cover_image ?? null; this.db.run('UPDATE collections SET cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', coverUrl, id); if (prev && prev !== coverUrl) await this.deleteOldCollectionCover(prev); - this.notifyCollectionUsers(id, socketId, 'collections:updated'); - const col = this.getCollectionRow(id); + await this.notifyCollectionUsers(id, socketId, 'collections:updated'); + const col = await this.getCollectionRow(id); return { ...col, is_owner: col.owner_id === userId }; } - deleteCollection(userId: number, id: number): void { - this.assertAccess(userId, id); - if (!this.isOwner(userId, id)) httpError(403, 'Only the owner can delete this list'); + async deleteCollection(userId: number, id: number): Promise { + await this.assertAccess(userId, id); + if (!(await this.isOwner(userId, id))) httpError(403, 'Only the owner can delete this list'); // Snapshot recipients BEFORE the cascade wipes collection_members. const accepted = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", id).map(r => r.user_id); @@ -704,10 +706,10 @@ export class CollectionsService { .forEach(uid => this.realtime.broadcastToUser(uid, { type: 'collections:deleted', collectionId: id })); } - reorderCollections(userId: number, orderedIds: number[]): void { - const visible = new Set(this.accessibleCollectionIds(userId)); + async reorderCollections(userId: number, orderedIds: number[]): Promise { + const visible = new Set(await this.accessibleCollectionIds(userId)); const stmt = this.db.prepare('UPDATE collections SET sort_order = ? WHERE id = ?'); - this.db.transaction(() => { + await this.uow.transactional(async () => { orderedIds.forEach((cid, index) => { if (visible.has(cid)) stmt.run(index, cid); }); @@ -733,10 +735,10 @@ export class CollectionsService { * PlacesService.findMatchingPlaceId: provider id, then name, then coordinates * and only when there is no name. */ - private findDuplicateCollectionPlace( + private async findDuplicateCollectionPlace( collectionId: number, candidate: PlaceMatchCandidate, - ): { id: number; name: string } | null { + ): Promise<{ id: number; name: string } | null> { for (const strategy of placeMatchStrategies(candidate)) { let hit: { id: number; name: string } | undefined; if (strategy.by === 'externalId') { @@ -775,10 +777,10 @@ export class CollectionsService { * read straight back out: the tag read-back ships `tags.user_id`, so an * unfiltered id answers who owns a tag the caller cannot otherwise see. */ - private attachTags(collectionPlaceId: number, tagIds: number[] | undefined): void { + private async attachTags(collectionPlaceId: number, tagIds: number[] | undefined): Promise { if (!tagIds || tagIds.length === 0) return; const unique = [...new Set(tagIds)]; - const eligible = this.collectionMemberIds(this.collectionIdOfPlace(collectionPlaceId)); + const eligible = await this.collectionMemberIds(await this.collectionIdOfPlace(collectionPlaceId)); const owned = this.db.all<{ id: number; user_id: number }>( `SELECT id, user_id FROM tags WHERE id IN (${unique.map(() => '?').join(',')})`, ...unique, @@ -788,8 +790,8 @@ export class CollectionsService { } /** Owner + accepted members — the users whose votes may live in this list. */ - private collectionMemberIds(collectionId: number): Set { - const ids = new Set([this.ownerOf(collectionId)]); + private async collectionMemberIds(collectionId: number): Promise> { + const ids = new Set([await this.ownerOf(collectionId)]); const rows = this.db.all<{ user_id: number }>("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'", collectionId); rows.forEach(r => ids.add(r.user_id)); return ids; @@ -800,8 +802,8 @@ export class CollectionsService { * place. Only votes by members of the target collection come along (the saver * is always a member) — other trip members' opinions stay in the trip. */ - private copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): void { - const eligible = this.collectionMemberIds(collectionId); + private async copyTripRatings(sourcePlaceId: number, collectionPlaceId: number, collectionId: number): Promise { + const eligible = await this.collectionMemberIds(collectionId); const rows = this.db.all<{ user_id: number; rating: number }>('SELECT user_id, rating FROM place_ratings WHERE place_id = ?', sourcePlaceId); const ins = this.db.prepare('INSERT OR IGNORE INTO collection_place_ratings (collection_place_id, user_id, rating) VALUES (?, ?, ?)'); for (const r of rows) { @@ -809,21 +811,21 @@ export class CollectionsService { } } - savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): CollectionSaveResult { - this.assertCanEdit(userId, body.collection_id); + async savePlace(userId: number, body: CollectionSavePlaceRequest, socketId?: string): Promise { + await this.assertCanEdit(userId, body.collection_id); if (!body.force) { - const dup = this.findDuplicateCollectionPlace(body.collection_id, { + const dup = await this.findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng, google_place_id: body.google_place_id, google_ftid: body.google_ftid, osm_id: body.osm_id, }); if (dup) return { duplicate: true, duplicateOf: dup }; } - const ownerId = this.ownerOf(body.collection_id); + const ownerId = await this.ownerOf(body.collection_id); // Insert + tags + ratings-copy are one logical write — atomic since the // post-fold quirk pass (the relocation carried them un-transacted). - const placeId = this.db.transaction(() => { + const placeId = await this.uow.transactional(async () => { const result = this.db.run(` INSERT INTO collection_places ( collection_id, owner_id, saved_by, name, description, lat, lng, address, @@ -841,7 +843,7 @@ export class CollectionsService { ); const id = Number(result.lastInsertRowid); - this.attachTags(id, body.tag_ids); + await this.attachTags(id, body.tag_ids); // Carry trip ratings ONLY when the caller can actually see the source place. // source_place_id/source_trip_id are raw client input, so verify trip access + // that the place lives in that trip before reading place_ratings — otherwise a @@ -852,18 +854,18 @@ export class CollectionsService { this.db.canAccessTrip(body.source_trip_id, userId) && this.db.get('SELECT 1 FROM places WHERE id = ? AND trip_id = ?', body.source_place_id, body.source_trip_id) ) { - this.copyTripRatings(body.source_place_id, id, body.collection_id); + await this.copyTripRatings(body.source_place_id, id, body.collection_id); } return id; }); - this.notifyCollectionUsers(body.collection_id, socketId, 'collections:updated'); - return { place: this.getPlaceById(placeId) }; + await this.notifyCollectionUsers(body.collection_id, socketId, 'collections:updated'); + return { place: await this.getPlaceById(placeId) }; } - saveFromTripPlace( + async saveFromTripPlace( userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean, socketId?: string, - ): CollectionSaveResult { - this.assertCanEdit(userId, collectionId); + ): Promise { + await this.assertCanEdit(userId, collectionId); if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); const place = this.db.get>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); @@ -900,8 +902,8 @@ export class CollectionsService { * * `scheduled` is false for places no day holds. Those are what a trip leaves behind and * what this import exists for, so the dialog pre-selects them. */ - importablePlaces(userId: number, collectionId: number, tripId: number): CollectionImportablesResponse { - this.assertCanEdit(userId, collectionId); + async importablePlaces(userId: number, collectionId: number, tripId: number): Promise { + await this.assertCanEdit(userId, collectionId); if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); // One row per place: a place can sit on several days, so the day columns resolve to the @@ -925,27 +927,29 @@ export class CollectionsService { ORDER BY p.name COLLATE NOCASE `, tripId); - return { - places: rows.map(r => ({ + const places: CollectionImportablesResponse['places'] = []; + for (const r of rows) { + places.push({ ...r, // Asked with the same candidate the save will use, or the picker marks a // place as new and the save then refuses it as a duplicate. - already_in_list: this.findDuplicateCollectionPlace(collectionId, r) != null, + already_in_list: (await this.findDuplicateCollectionPlace(collectionId, r)) != null, scheduled: r.day_number != null, - })), - }; + }); + } + return { places }; } /** Bulk copy of several trip places into a list in one shot — one access check, * one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's * field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */ - saveFromTripPlaces( + async saveFromTripPlaces( userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean, socketId?: string, - ): { copied: number; skipped: { id: number; name: string }[] } { - this.assertCanEdit(userId, collectionId); + ): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { + await this.assertCanEdit(userId, collectionId); if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); - const ownerId = this.ownerOf(collectionId); + const ownerId = await this.ownerOf(collectionId); const insert = this.db.prepare(` INSERT INTO collection_places ( collection_id, owner_id, saved_by, name, description, lat, lng, address, @@ -956,7 +960,7 @@ export class CollectionsService { let copied = 0; const skipped: { id: number; name: string }[] = []; // The whole batch is one logical write — atomic since the post-fold quirk pass. - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const placeId of placeIds) { const p = this.db.get>('SELECT * FROM places WHERE id = ? AND trip_id = ?', placeId, tripId); if (!p) continue; @@ -972,7 +976,7 @@ export class CollectionsService { google_ftid: (p.google_ftid as string | null) ?? null, osm_id: (p.osm_id as string | null) ?? null, }; - if (!force && this.findDuplicateCollectionPlace(collectionId, candidate)) { + if (!force && await this.findDuplicateCollectionPlace(collectionId, candidate)) { skipped.push({ id: placeId, name }); continue; } @@ -984,17 +988,17 @@ export class CollectionsService { (p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null, tripId, placeId, ); - this.copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId); + await this.copyTripRatings(placeId, Number(res.lastInsertRowid), collectionId); copied++; } }); - if (copied > 0) this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + if (copied > 0) await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return { copied, skipped }; } async updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string): Promise { - const currentCollection = this.collectionIdOfPlace(placeId); - this.assertCanEdit(userId, currentCollection); + const currentCollection = await this.collectionIdOfPlace(placeId); + await this.assertCanEdit(userId, currentCollection); // Capture the previous thumbnail so a replaced/cleared custom upload (#1136) // can be reclaimed once nothing references it any more. @@ -1017,15 +1021,15 @@ export class CollectionsService { let movedTo: number | null = null; if (body.collection_id !== undefined && body.collection_id !== currentCollection) { - this.assertCanEdit(userId, body.collection_id); + await this.assertCanEdit(userId, body.collection_id); updates.push('collection_id = ?'); params.push(body.collection_id); - updates.push('owner_id = ?'); params.push(this.ownerOf(body.collection_id)); + updates.push('owner_id = ?'); params.push(await this.ownerOf(body.collection_id)); movedTo = body.collection_id; } // Field update + tag rewrite + label rewrite are one logical write — atomic // since the post-fold quirk pass. - this.db.transaction(() => { + await this.uow.transactional(async () => { if (updates.length > 0) { updates.push("updated_at = CURRENT_TIMESTAMP"); params.push(placeId); @@ -1034,29 +1038,29 @@ export class CollectionsService { if (body.tag_ids !== undefined) { this.db.run('DELETE FROM collection_place_tags WHERE collection_place_id = ?', placeId); - this.attachTags(placeId, body.tag_ids); + await this.attachTags(placeId, body.tag_ids); } // Labels are collection-scoped: a move invalidates the source list's labels; // a provided label_ids set replaces them against the (target) collection. if (movedTo) this.db.run('DELETE FROM collection_place_labels WHERE collection_place_id = ?', placeId); - if (body.label_ids !== undefined) this.setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids); + if (body.label_ids !== undefined) await this.setPlaceLabels(placeId, movedTo ?? currentCollection, body.label_ids); }); if (body.image_url !== undefined && prevImage !== (body.image_url ?? null)) { await reclaimPlaceImage(this.storage, prevImage); } - this.notifyCollectionUsers(currentCollection, socketId, 'collections:updated'); - if (movedTo) this.notifyCollectionUsers(movedTo, socketId, 'collections:updated'); + await this.notifyCollectionUsers(currentCollection, socketId, 'collections:updated'); + if (movedTo) await this.notifyCollectionUsers(movedTo, socketId, 'collections:updated'); return this.getPlaceById(placeId); } - setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): CollectionPlace { - const collectionId = this.collectionIdOfPlace(placeId); - this.assertCanEdit(userId, collectionId); + async setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string): Promise { + const collectionId = await this.collectionIdOfPlace(placeId); + await this.assertCanEdit(userId, collectionId); this.db.run("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", status, placeId); - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return this.getPlaceById(placeId); } @@ -1065,9 +1069,9 @@ export class CollectionsService { * Gated on assertAccess, not assertCanEdit — a vote is the member's personal * opinion, so read-only viewers get to cast one too. */ - setRating(userId: number, placeId: number, rating: number | null, socketId?: string): CollectionPlace { - const collectionId = this.collectionIdOfPlace(placeId); - this.assertAccess(userId, collectionId); + async setRating(userId: number, placeId: number, rating: number | null, socketId?: string): Promise { + const collectionId = await this.collectionIdOfPlace(placeId); + await this.assertAccess(userId, collectionId); if (rating === null) { this.db.run('DELETE FROM collection_place_ratings WHERE collection_place_id = ? AND user_id = ?', placeId, userId); } else { @@ -1076,17 +1080,17 @@ export class CollectionsService { ON CONFLICT(collection_place_id, user_id) DO UPDATE SET rating = excluded.rating `, placeId, userId, rating); } - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return this.getPlaceById(placeId); } async deletePlace(userId: number, placeId: number, socketId?: string): Promise { - const collectionId = this.collectionIdOfPlace(placeId); - this.assertCanDelete(userId, collectionId); + const collectionId = await this.collectionIdOfPlace(placeId); + await this.assertCanDelete(userId, collectionId); const image = this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', placeId)?.image_url ?? null; this.db.run('DELETE FROM collection_places WHERE id = ?', placeId); // CASCADE drops tags. NO photo-cache reclaim. await reclaimPlaceImage(this.storage, image); - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); } async deletePlacesMany(userId: number, ids: number[], socketId?: string): Promise { @@ -1098,19 +1102,19 @@ export class CollectionsService { const touched = new Set(); const images: (string | null)[] = []; for (const id of ids) { - const collectionId = this.collectionIdOfPlace(id); - this.assertCanDelete(userId, collectionId); + const collectionId = await this.collectionIdOfPlace(id); + await this.assertCanDelete(userId, collectionId); images.push(this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', id)?.image_url ?? null); touched.add(collectionId); } - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const id of ids) { this.db.run('DELETE FROM collection_places WHERE id = ?', id); deleted.push(id); } }); for (const image of images) await reclaimPlaceImage(this.storage, image); - touched.forEach(cid => this.notifyCollectionUsers(cid, socketId, 'collections:updated')); + for (const cid of touched) await this.notifyCollectionUsers(cid, socketId, 'collections:updated'); return deleted; } @@ -1122,15 +1126,15 @@ export class CollectionsService { * cannot leave half the batch applied. Rows that already carry the status are * counted as untouched rather than rewritten, which keeps updated_at honest. */ - setStatusMany(userId: number, ids: number[], status: CollectionStatus, socketId?: string): { updated: number } { + async setStatusMany(userId: number, ids: number[], status: CollectionStatus, socketId?: string): Promise<{ updated: number }> { const touched = new Set(); for (const id of ids) { - const collectionId = this.collectionIdOfPlace(id); - this.assertCanEdit(userId, collectionId); + const collectionId = await this.collectionIdOfPlace(id); + await this.assertCanEdit(userId, collectionId); touched.add(collectionId); } let updated = 0; - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const id of ids) { const res = this.db.run( "UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status IS NOT ?", @@ -1139,7 +1143,7 @@ export class CollectionsService { updated += res.changes; } }); - if (updated > 0) touched.forEach(cid => this.notifyCollectionUsers(cid, socketId, 'collections:updated')); + if (updated > 0) for (const cid of touched) await this.notifyCollectionUsers(cid, socketId, 'collections:updated'); return { updated }; } @@ -1153,13 +1157,13 @@ export class CollectionsService { * alone rather than refused — a shared list you cannot edit should not stop * you marking your own. */ - setStatusFromTrip( + async setStatusFromTrip( userId: number, tripId: number, placeIds: number[], status: CollectionStatus, socketId?: string, - ): { updated: number; places: number } { + ): Promise<{ updated: number; places: number }> { if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); const sources = placeIds.length @@ -1170,23 +1174,24 @@ export class CollectionsService { : []; if (sources.length === 0) return { updated: 0, places: 0 }; - const editable = this.accessibleCollectionIds(userId).filter(cid => { - const role = this.roleOf(userId, cid); - return role !== null && role !== 'viewer'; - }); + const editable: number[] = []; + for (const cid of await this.accessibleCollectionIds(userId)) { + const role = await this.roleOf(userId, cid); + if (role !== null && role !== 'viewer') editable.push(cid); + } if (editable.length === 0) return { updated: 0, places: 0 }; const matched = new Set(); const placesWithMatch = new Set(); for (const src of sources) { - for (const row of this.matchingCollectionPlaces(editable, tripId, src)) { + for (const row of await this.matchingCollectionPlaces(editable, tripId, src)) { matched.add(row.id); placesWithMatch.add(src.id); } } if (matched.size === 0) return { updated: 0, places: 0 }; - const { updated } = this.setStatusMany(userId, [...matched], status, socketId); + const { updated } = await this.setStatusMany(userId, [...matched], status, socketId); return { updated, places: placesWithMatch.size }; } @@ -1197,11 +1202,11 @@ export class CollectionsService { * inferring anything. A bare name match is left out here for the same reason * as there: every "Starbucks" in the library would answer to it. */ - private matchingCollectionPlaces( + private async matchingCollectionPlaces( collectionIds: number[], tripId: number, place: { id: number; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }, - ): Array<{ id: number }> { + ): Promise> { const conditions: string[] = ['(cp.source_trip_id = ? AND cp.source_place_id = ?)']; const params: (string | number)[] = [...collectionIds, tripId, place.id]; if (place.google_place_id) { conditions.push('cp.google_place_id = ?'); params.push(place.google_place_id); } @@ -1219,12 +1224,12 @@ export class CollectionsService { /** Set (or clear) a saved place's custom thumbnail, reclaiming the previous upload. */ async setPlaceImage(userId: number, placeId: number, imageUrl: string | null, socketId?: string): Promise { - const collectionId = this.collectionIdOfPlace(placeId); - this.assertCanEdit(userId, collectionId); + const collectionId = await this.collectionIdOfPlace(placeId); + await this.assertCanEdit(userId, collectionId); const prev = this.db.get<{ image_url: string | null }>('SELECT image_url FROM collection_places WHERE id = ?', placeId)?.image_url ?? null; this.db.run('UPDATE collection_places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', imageUrl, placeId); if (prev !== imageUrl) await reclaimPlaceImage(this.storage, prev); - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return this.getPlaceById(placeId); } @@ -1260,7 +1265,7 @@ export class CollectionsService { FROM collection_places WHERE id = ? `, pid); if (!row) httpError(404, 'Place not found'); - this.assertAccess(userId, row.collection_id); + await this.assertAccess(userId, row.collection_id); sources.push(row); } @@ -1288,7 +1293,7 @@ export class CollectionsService { let copied = 0; const skipped: { id: number; name: string }[] = []; // The whole copy is one logical write — atomic since the post-fold quirk pass. - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const s of sources) { if (!body.force && isPlaceDuplicate({ name: s.name, lat: s.lat, lng: s.lng, @@ -1321,11 +1326,11 @@ export class CollectionsService { // Library-wide membership lookup (inspector indicator) // ------------------------------------------------------------------------- - findMembership( + async findMembership( userId: number, query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }, - ): CollectionMembership { - const ids = this.accessibleCollectionIds(userId); + ): Promise { + const ids = await this.accessibleCollectionIds(userId); if (ids.length === 0) return { saved: false, lists: [] }; const placeholders = ids.map(() => '?').join(','); @@ -1351,30 +1356,29 @@ export class CollectionsService { WHERE cp.collection_id IN (${placeholders}) AND (${conditions.join(' OR ')}) `, ...params); - return { - saved: rows.length > 0, - lists: rows.map(r => { - const role = this.roleOf(userId, r.collection_id); - return { - collection_id: r.collection_id, - name: r.name, - place_id: r.place_id, - status: r.status ?? 'idea', - can_edit: role !== null && role !== 'viewer', - }; - }), - }; + const lists: CollectionMembership['lists'] = []; + for (const r of rows) { + const role = await this.roleOf(userId, r.collection_id); + lists.push({ + collection_id: r.collection_id, + name: r.name, + place_id: r.place_id, + status: r.status ?? 'idea', + can_edit: role !== null && role !== 'viewer', + }); + } + return { saved: rows.length > 0, lists }; } // ------------------------------------------------------------------------- // WebSocket notify // ------------------------------------------------------------------------- - notifyCollectionUsers( + async notifyCollectionUsers( collectionId: number, excludeSid: string | undefined, event: 'collections:updated' | 'collections:accepted' | 'collections:declined' | 'collections:left' = 'collections:updated', - ): void { + ): Promise { const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM collections WHERE id = ?', collectionId); if (!owner) return; const userIds = [owner.owner_id]; @@ -1389,27 +1393,27 @@ export class CollectionsService { // member. // ------------------------------------------------------------------------- - private collectionIdOfLabel(labelId: number): number { + private async collectionIdOfLabel(labelId: number): Promise { const row = this.db.get<{ collection_id: number }>('SELECT collection_id FROM collection_labels WHERE id = ?', labelId); if (!row) httpError(404, 'Label not found'); return row.collection_id; } - private getLabelById(labelId: number): CollectionLabel { + private async getLabelById(labelId: number): Promise { return this.db.get('SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id = ?', labelId) as CollectionLabel; } /** Replace a place's label assignments, keeping only labels of `collectionId`. */ - private setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): void { + private async setPlaceLabels(placeId: number, collectionId: number, labelIds: number[]): Promise { this.db.run('DELETE FROM collection_place_labels WHERE collection_place_id = ?', placeId); if (labelIds.length === 0) return; - const valid = new Set(this.loadLabelsByCollection(collectionId).map(l => l.id)); + const valid = new Set((await this.loadLabelsByCollection(collectionId)).map(l => l.id)); const stmt = this.db.prepare('INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?, ?)'); for (const id of labelIds) if (valid.has(id)) stmt.run(placeId, id); } - createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): CollectionLabel { - this.assertCanEdit(userId, collectionId); + async createLabel(userId: number, collectionId: number, name: string, color?: string, socketId?: string): Promise { + await this.assertCanEdit(userId, collectionId); const trimmed = name.trim(); if (!trimmed) httpError(400, 'Label name is required'); const count = this.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id = ?', collectionId)!.n; @@ -1420,13 +1424,13 @@ export class CollectionsService { const nextSort = this.db.get<{ m: number }>('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collection_labels WHERE collection_id = ?', collectionId)!.m + 1; const res = this.db.run('INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?, ?, ?, ?)', collectionId, trimmed, color ?? '#6366f1', nextSort); - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return this.getLabelById(Number(res.lastInsertRowid)); } - updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): CollectionLabel { - const collectionId = this.collectionIdOfLabel(labelId); - this.assertCanEdit(userId, collectionId); + async updateLabel(userId: number, labelId: number, body: { name?: string; color?: string; sort_order?: number }, socketId?: string): Promise { + const collectionId = await this.collectionIdOfLabel(labelId); + await this.assertCanEdit(userId, collectionId); const updates: string[] = []; const params: (string | number)[] = []; if (body.name !== undefined) { @@ -1443,24 +1447,24 @@ export class CollectionsService { params.push(labelId); this.db.run(`UPDATE collection_labels SET ${updates.join(', ')} WHERE id = ?`, ...params); } - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); return this.getLabelById(labelId); } - deleteLabel(userId: number, labelId: number, socketId?: string): void { - const collectionId = this.collectionIdOfLabel(labelId); - this.assertCanEdit(userId, collectionId); + async deleteLabel(userId: number, labelId: number, socketId?: string): Promise { + const collectionId = await this.collectionIdOfLabel(labelId); + await this.assertCanEdit(userId, collectionId); this.db.run('DELETE FROM collection_labels WHERE id = ?', labelId); // CASCADE clears place assignments - this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated'); } /** Bulk add (or remove) one or more labels across a selection of places. * Places are grouped by list so each list is permission-checked once, and only * labels that belong to that list are applied. */ - assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): { changed: number } { + async assignLabels(userId: number, labelIds: number[], placeIds: number[], remove: boolean, socketId?: string): Promise<{ changed: number }> { const byCollection = new Map(); for (const pid of placeIds) { - const cid = this.collectionIdOfPlace(pid); + const cid = await this.collectionIdOfPlace(pid); if (!byCollection.has(cid)) byCollection.set(cid, []); byCollection.get(cid)!.push(pid); } @@ -1469,12 +1473,12 @@ export class CollectionsService { // the write loop, so a later list's 403 left earlier lists modified), and // the writes then run in one transaction. Broadcasts still skip lists where // no provided label applied. - for (const cid of byCollection.keys()) this.assertCanEdit(userId, cid); + for (const cid of byCollection.keys()) await this.assertCanEdit(userId, cid); let changed = 0; const notified: number[] = []; - this.db.transaction(() => { + await this.uow.transactional(async () => { for (const [cid, pids] of byCollection) { - const valid = new Set(this.loadLabelsByCollection(cid).map(l => l.id)); + const valid = new Set((await this.loadLabelsByCollection(cid)).map(l => l.id)); const applicable = labelIds.filter(id => valid.has(id)); if (applicable.length === 0) continue; if (remove) { @@ -1487,7 +1491,7 @@ export class CollectionsService { notified.push(cid); } }); - for (const cid of notified) this.notifyCollectionUsers(cid, socketId, 'collections:updated'); + for (const cid of notified) await this.notifyCollectionUsers(cid, socketId, 'collections:updated'); return { changed }; } @@ -1495,11 +1499,11 @@ export class CollectionsService { // Fusion invitations (mirror vacayService, dropping the one-fusion guards) // ------------------------------------------------------------------------- - sendInvite( + async sendInvite( collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, role: 'viewer' | 'editor' | 'admin' = 'editor', - ): { error?: string; status?: number } { - if (!this.isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 }; + ): Promise<{ error?: string; status?: number }> { + if (!(await this.isOwner(inviterId, collectionId))) return { error: 'Not allowed', status: 403 }; if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; const targetUser = this.db.get('SELECT id, username FROM users WHERE id = ?', targetUserId); @@ -1524,51 +1528,51 @@ export class CollectionsService { return {}; } - acceptInvite(userId: number, collectionId: number, socketId: string | undefined): { error?: string; status?: number } { + async acceptInvite(userId: number, collectionId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> { const invite = this.db.get<{ id: number }>("SELECT id FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, userId); if (!invite) return { error: 'No pending invite', status: 404 }; this.db.run("UPDATE collection_members SET status = 'accepted' WHERE id = ?", invite.id); - this.notifyCollectionUsers(collectionId, socketId, 'collections:accepted'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:accepted'); return {}; } - declineInvite(userId: number, collectionId: number, socketId: string | undefined): void { + async declineInvite(userId: number, collectionId: number, socketId: string | undefined): Promise { this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, userId); - this.notifyCollectionUsers(collectionId, socketId, 'collections:declined'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:declined'); } - cancelInvite(collectionId: number, ownerId: number, targetUserId: number): void { - if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + async cancelInvite(collectionId: number, ownerId: number, targetUserId: number): Promise { + if (!(await this.isOwner(ownerId, collectionId))) httpError(403, 'Not allowed'); this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'", collectionId, targetUserId); this.realtime.broadcastToUser(targetUserId, { type: 'collections:cancelled', collectionId }); } - leaveCollection(userId: number, collectionId: number, socketId: string | undefined): void { - if (this.isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list'); + async leaveCollection(userId: number, collectionId: number, socketId: string | undefined): Promise { + if (await this.isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list'); this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", collectionId, userId); - this.notifyCollectionUsers(collectionId, socketId, 'collections:left'); + await this.notifyCollectionUsers(collectionId, socketId, 'collections:left'); } /** Owner removes an already-accepted member (a "kick"). */ - removeMember(ownerId: number, collectionId: number, targetUserId: number): void { - if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + async removeMember(ownerId: number, collectionId: number, targetUserId: number): Promise { + if (!(await this.isOwner(ownerId, collectionId))) httpError(403, 'Not allowed'); if (targetUserId === ownerId) httpError(400, 'Owner cannot be removed'); const res = this.db.run("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", collectionId, targetUserId); if (res.changes === 0) httpError(404, 'Member not found'); - this.notifyCollectionUsers(collectionId, undefined, 'collections:left'); // refresh remaining members + await this.notifyCollectionUsers(collectionId, undefined, 'collections:left'); // refresh remaining members this.realtime.broadcastToUser(targetUserId, { type: 'collections:removed', collectionId }); // bounce the removed user } /** Owner changes an accepted member's permission role (viewer/editor/admin). */ - setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): void { - if (!this.isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + async setMemberRole(ownerId: number, collectionId: number, targetUserId: number, role: 'viewer' | 'editor' | 'admin'): Promise { + if (!(await this.isOwner(ownerId, collectionId))) httpError(403, 'Not allowed'); const res = this.db.run("UPDATE collection_members SET role = ? WHERE collection_id = ? AND user_id = ? AND status = 'accepted'", role, collectionId, targetUserId); if (res.changes === 0) httpError(404, 'Member not found'); - this.notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live + await this.notifyCollectionUsers(collectionId, undefined, 'collections:updated'); // re-gate the member live this.realtime.broadcastToUser(targetUserId, { type: 'collections:updated', collectionId }); } - availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] { + async availableUsers(ownerId: number, collectionId: number): Promise<{ id: number; username: string }[]> { return this.db.all<{ id: number; username: string }>(` SELECT u.id, u.username FROM users u WHERE u.id != ? @@ -1578,8 +1582,8 @@ export class CollectionsService { `, ownerId, collectionId); } - findMembershipForUser(userId: number, collectionId: number): { is_member: boolean; is_owner: boolean; status: string | null } { - if (this.isOwner(userId, collectionId)) return { is_member: true, is_owner: true, status: 'accepted' }; + async findMembershipForUser(userId: number, collectionId: number): Promise<{ is_member: boolean; is_owner: boolean; status: string | null }> { + if (await this.isOwner(userId, collectionId)) return { is_member: true, is_owner: true, status: 'accepted' }; const row = this.db.get<{ status: string }>('SELECT status FROM collection_members WHERE collection_id = ? AND user_id = ?', collectionId, userId); return { is_member: row?.status === 'accepted', is_owner: false, status: row?.status ?? null }; } diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index c574f8f7a..70405d292 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -142,7 +142,7 @@ export class MapsController { @CurrentUser() user: User, @Body() body: MapsAutocompleteDto, ): Promise { - if (this.maps.autocompleteDisabled()) { + if (await this.maps.autocompleteDisabled()) { return { suggestions: [], source: 'disabled' }; } try { @@ -165,7 +165,7 @@ export class MapsController { // breaking the lookup. @Query('sessionToken') sessionToken?: string, ): Promise { - if (this.maps.detailsDisabled()) { + if (await this.maps.detailsDisabled()) { return { place: null, disabled: true }; } try { @@ -187,7 +187,7 @@ export class MapsController { @Query('name') name?: string, ): Promise { // Kill-switch only applies to Google Places fetches — Wikimedia (coords:) stays allowed. - if (!placeId.startsWith('coords:') && this.maps.photosDisabled()) { + if (!placeId.startsWith('coords:') && (await this.maps.photosDisabled())) { return { photoUrl: null }; } // A place with no photo resolves to the same { photoUrl: null } body. It is an diff --git a/server/src/nest/maps/maps.mcp.ts b/server/src/nest/maps/maps.mcp.ts index 156e1d441..dc9d17f78 100644 --- a/server/src/nest/maps/maps.mcp.ts +++ b/server/src/nest/maps/maps.mcp.ts @@ -36,7 +36,7 @@ export class MapsMcp { // assistant reaching the provider anyway would bill them from the one // surface the switch does not cover. REST answers { place: null, disabled } // rather than an error, so this does too, with a line the model can act on. - if (this.maps.detailsDisabled()) { + if (await this.maps.detailsDisabled()) { return ok({ details: null, disabled: true, diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 9c12b71d1..398309ecd 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -696,7 +696,7 @@ export class MapsService { * oldest entry is the one evicted when it fills up. */ private readonly brandLogoCache = new Map(); - private isSettingDisabled(key: string): boolean { + private async isSettingDisabled(key: string): Promise { const row = this.database.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', key, @@ -747,15 +747,15 @@ export class MapsService { } } - autocompleteDisabled(): boolean { + autocompleteDisabled(): Promise { return this.isSettingDisabled('places_autocomplete_enabled'); } - detailsDisabled(): boolean { + detailsDisabled(): Promise { return this.isSettingDisabled('places_details_enabled'); } - photosDisabled(): boolean { + photosDisabled(): Promise { return this.isSettingDisabled('places_photos_enabled'); } @@ -1023,7 +1023,7 @@ export class MapsService { * is read on the hot path of every search, and a hand-edited settings row must * not take place search down. */ - placesProviderChoice(): PlacesProviderChoice { + async placesProviderChoice(): Promise { const row = this.database.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', PLACES_PROVIDER_SETTING, @@ -1047,7 +1047,7 @@ export class MapsService { * the database reads it always did. */ async keyedProvider(userId: number): Promise { - const choice = this.placesProviderChoice(); + const choice = await this.placesProviderChoice(); if (choice === 'openstreetmap') return null; if (choice !== 'amap') { diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index 43469a42f..2e306020c 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -449,7 +449,7 @@ export class PlaceEnrichmentService { details: Record | null, ): Promise { const apiKey = await this.maps.getMapsKey(userId); - const wantsGoogle = !!apiKey && !this.maps.photosDisabled() && isGooglePlaceId(placeId); + const wantsGoogle = !!apiKey && !(await this.maps.photosDisabled()) && isGooglePlaceId(placeId); const { wikidata, wikipedia } = identity; @@ -694,7 +694,7 @@ export class PlaceEnrichmentService { if (fromSite) return fromSite; const apiKey = await this.maps.getMapsKey(userId); - if (apiKey && !this.maps.detailsDisabled() && isGooglePlaceId(placeId)) { + if (apiKey && !(await this.maps.detailsDisabled()) && isGooglePlaceId(placeId)) { const summary = await this.maps.fetchEditorialSummary(placeId, apiKey, req.lang); if (summary) { return { 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 d487b92d0..325cf54ff 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 @@ -80,7 +80,7 @@ export class TripReadModelService { }; const reservations = this.reservations.list(tripId); - const collab_notes = this.collab.listNotes(tripId); + const collab_notes = await this.collab.listNotes(tripId); return { trip, diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index fe25362b6..63c7e1eca 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -284,8 +284,8 @@ export class TripsMcp { let pollCount = 0; let messageCount = 0; if (canReadCollab) { - if (collabFeatures?.polls) pollCount = this.collab.listPolls(tripId).length; - if (collabFeatures?.chat) messageCount = this.collab.countMessages(tripId); + if (collabFeatures?.polls) pollCount = (await this.collab.listPolls(tripId)).length; + if (collabFeatures?.chat) messageCount = await this.collab.countMessages(tripId); } const notice = ctx.getDeprecationNotice ? ctx.getDeprecationNotice() : null; // The core bucket (trip metadata, members WITH email, days with place diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 5a6ce4bd5..77ffc5b92 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -151,7 +151,7 @@ export async function createMcpTestRegistry(): Promise { 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 collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService()); + 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. const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage); @@ -238,7 +238,7 @@ export async function createMcpTestRegistry(): Promise { new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)), new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)), - new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService), + new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, await createTestUnitOfWork(dbService.connection)), dbService, authService, addonsService), new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards), new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService), new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection))), addonsService, authService, captureBackfill), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 7086593a4..7d79fb9a5 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -96,13 +96,13 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection)); const packing = new PackingService(dbs, permissions, realtime, notificationsStub()); const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage); - const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService()); + const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); const vacay = new VacayService(dbs, realtime, notificationsStub()); const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection)); const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage); const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage); const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)); - const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage); + const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage, await createTestUnitOfWork(dbs.connection)); const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection)); const dayNotes = new DayNotesService(dbs, permissions, realtime); const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey); diff --git a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts index 440f22f94..0c6e1fce7 100644 --- a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts +++ b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts @@ -86,7 +86,7 @@ vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({ clamped: false, } as never); // Off by default, so the existing cases exercise the lookup rather than the gate. -vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(false); +vi.spyOn(MapsService.prototype, 'detailsDisabled').mockResolvedValue(false); vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: 'Paris', address: 'France' }); vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({ lat: 48.8566, @@ -799,12 +799,12 @@ describe('Tool: get_airport', () => { describe('Tool: get_place_details (admin kill switch)', () => { afterEach(() => { - vi.mocked(MapsService.prototype.detailsDisabled).mockReturnValue(false); + vi.mocked(MapsService.prototype.detailsDisabled).mockResolvedValue(false); }); it('fetches nothing when an admin has turned Place Details off', async () => { const { user } = createUser(testDb); - vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(true); + vi.spyOn(MapsService.prototype, 'detailsDisabled').mockResolvedValue(true); vi.mocked(MapsService.prototype.getPlaceDetails).mockClear(); vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear(); @@ -823,7 +823,7 @@ describe('Tool: get_place_details (admin kill switch)', () => { it('the switch also stops the expensive expanded path', async () => { const { user } = createUser(testDb); - vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(true); + vi.spyOn(MapsService.prototype, 'detailsDisabled').mockResolvedValue(true); vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear(); await withHarness(user.id, async (h) => { @@ -838,7 +838,7 @@ describe('Tool: get_place_details (admin kill switch)', () => { it('leaves the lookup alone while the switch is on', async () => { const { user } = createUser(testDb); - vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(false); + vi.spyOn(MapsService.prototype, 'detailsDisabled').mockResolvedValue(false); vi.mocked(MapsService.prototype.getPlaceDetails).mockClear(); await withHarness(user.id, async (h) => { diff --git a/server/tests/unit/nest/collab.controller.test.ts b/server/tests/unit/nest/collab.controller.test.ts index 596eae402..8037e5ac7 100644 --- a/server/tests/unit/nest/collab.controller.test.ts +++ b/server/tests/unit/nest/collab.controller.test.ts @@ -15,9 +15,9 @@ const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as function svc(o: Partial = {}): CollabService { return { - verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }), - canEdit: vi.fn().mockReturnValue(true), - canUploadFiles: vi.fn().mockReturnValue(true), + verifyTripAccess: vi.fn().mockResolvedValue({ user_id: 1 }), + canEdit: vi.fn().mockResolvedValue(true), + canUploadFiles: vi.fn().mockResolvedValue(true), broadcast: vi.fn(), notifyCollab: vi.fn(), ...o, @@ -29,15 +29,6 @@ const storageStub = { delete: vi.fn().mockResolvedValue(undefined), } as unknown as StorageService; -function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { - expect(err).toBeInstanceOf(HttpException); - const e = err as HttpException; - return { status: e.getStatus(), body: e.getResponse() }; - } - throw new Error('expected throw'); -} - async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -51,27 +42,27 @@ beforeEach(() => vi.clearAllMocks()); describe('CollabController (parity with the legacy /api/trips/:tripId/collab route)', () => { describe('notes', () => { - it('GET lists', () => { - const s = svc({ listNotes: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] }); + it('GET lists', async () => { + const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial); + expect(await new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] }); }); - it('POST creates + broadcasts + notifies (empty title now 400s in the Zod pipe)', () => { + it('POST creates + broadcasts + notifies (empty title now 400s in the Zod pipe)', async () => { const createNote = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const notifyCollab = vi.fn(); const s = svc({ createNote, broadcast, notifyCollab } as Partial); - expect(new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({ note: { id: 9 } }); + expect(await new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({ note: { id: 9 } }); expect(createNote).toHaveBeenCalledWith('5', 1, { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:created', { note: { id: 9 } }, 'sock'); expect(notifyCollab).toHaveBeenCalledWith('5', user); }); - it('PUT 404 when missing, else updates + broadcasts', () => { - expect(thrown(() => new CollabController(svc({ updateNote: vi.fn().mockReturnValue(null) } as Partial), storageStub).updateNote(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Note not found' } }); + it('PUT 404 when missing, else updates + broadcasts', async () => { + expect(await thrownAsync(() => new CollabController(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial), storageStub).updateNote(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Note not found' } }); const broadcast = vi.fn(); - const s = svc({ updateNote: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial); - expect(new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ note: { id: 9 } }); + const s = svc({ updateNote: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial); + expect(await new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ note: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock'); }); @@ -87,11 +78,11 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou describe('note files', () => { const file = { filename: 'a.pdf' } as Express.Multer.File; it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => { - expect(await thrownAsync(() => new CollabController(svc({ canUploadFiles: vi.fn().mockReturnValue(false) }), storageStub).addNoteFile(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); + expect(await thrownAsync(() => new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); expect(await thrownAsync(() => new CollabController(svc(), storageStub).addNoteFile(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } }); - expect(await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockReturnValue(null) } as Partial), storageStub).addNoteFile(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Note not found' } }); + expect(await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), storageStub).addNoteFile(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Note not found' } }); const broadcast = vi.fn(); - const s = svc({ addNoteFile: vi.fn().mockReturnValue({ file: { id: 3 } }), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial); + const s = svc({ addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }), getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial); expect(await new CollabController(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({ file: { id: 3 } }); expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock'); @@ -103,53 +94,53 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou const spool = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`); fs.writeFileSync(spool, 'x'); const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File; - expect(await thrownAsync(() => new CollabController(svc({ canUploadFiles: vi.fn().mockReturnValue(false) }), storageStub).addNoteFile(user, '5', '9', spooled))).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); + expect(await thrownAsync(() => new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', spooled))).toEqual({ status: 403, body: { error: 'No permission to upload files' } }); expect(fs.existsSync(spool)).toBe(false); const spool2 = path.join(os.tmpdir(), `trek-collab-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`); fs.writeFileSync(spool2, 'x'); - expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(null) }), storageStub).addNoteFile(user, '5', '9', { filename: 'b.pdf', path: spool2 } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(user, '5', '9', { filename: 'b.pdf', path: spool2 } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Trip not found' } }); expect(fs.existsSync(spool2)).toBe(false); // Past the commit the spool file is gone, so the final object is what has // to go instead. - await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockReturnValue(null) } as Partial), storageStub).addNoteFile(user, '5', '9', file)); + await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial), storageStub).addNoteFile(user, '5', '9', file)); expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf'); }); it('DELETE file 404 when missing, else success', async () => { expect(await thrownAsync(() => new CollabController(svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial), storageStub).deleteNoteFile(user, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); - const s = svc({ deleteNoteFile: vi.fn().mockResolvedValue(true), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast: vi.fn() } as Partial); + const s = svc({ deleteNoteFile: vi.fn().mockResolvedValue(true), getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast: vi.fn() } as Partial); expect(await new CollabController(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true }); }); }); describe('polls', () => { - it('POST creates (missing question / <2 options now 400 in the Zod pipe)', () => { - const s = svc({ createPoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial); - expect(new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ poll: { id: 7 } }); + it('POST creates (missing question / <2 options now 400 in the Zod pipe)', async () => { + const s = svc({ createPoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial); + expect(await new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ poll: { id: 7 } }); }); - it('vote maps not_found/closed/invalid_index, else broadcasts the poll', () => { - expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 404, body: { error: 'Poll not found' } }); - expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'closed' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 400, body: { error: 'Poll is closed' } }); - expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'invalid_index' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 9 }))).toEqual({ status: 400, body: { error: 'Invalid option index' } }); + it('vote maps not_found/closed/invalid_index, else broadcasts the poll', async () => { + expect(await thrownAsync(() => new CollabController(svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 404, body: { error: 'Poll not found' } }); + expect(await thrownAsync(() => new CollabController(svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 400, body: { error: 'Poll is closed' } }); + expect(await thrownAsync(() => new CollabController(svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial), storageStub).votePoll(user, '5', '7', { option_index: 9 }))).toEqual({ status: 400, body: { error: 'Invalid option index' } }); const broadcast = vi.fn(); - const s = svc({ votePoll: vi.fn().mockReturnValue({ poll: { id: 7 } }), broadcast } as Partial); - expect(new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({ poll: { id: 7 } }); + const s = svc({ votePoll: vi.fn().mockResolvedValue({ poll: { id: 7 } }), broadcast } as Partial); + expect(await new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({ poll: { id: 7 } }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock'); }); - it('close 404 when missing, else broadcasts', () => { - expect(thrown(() => new CollabController(svc({ closePoll: vi.fn().mockReturnValue(null) } as Partial), storageStub).closePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } }); - const s = svc({ closePoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial); - expect(new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } }); + it('close 404 when missing, else broadcasts', async () => { + expect(await thrownAsync(() => new CollabController(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial), storageStub).closePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } }); + const s = svc({ closePoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial); + expect(await new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } }); }); - it('delete 404 when missing, else success', () => { - expect(thrown(() => new CollabController(svc({ deletePoll: vi.fn().mockReturnValue(false) } as Partial), storageStub).deletePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } }); - const s = svc({ deletePoll: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial); - expect(new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true }); + it('delete 404 when missing, else success', async () => { + expect(await thrownAsync(() => new CollabController(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial), storageStub).deletePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } }); + const s = svc({ deletePoll: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial); + expect(await new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true }); }); }); @@ -162,11 +153,11 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou .toEqual({ status: 400, body: { error: 'Message text is required' } }); expect(await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, []))) .toEqual({ status: 400, body: { error: 'Message text or image is required' } }); - expect(await thrownAsync(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial), storageStub).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined))) + expect(await thrownAsync(() => new CollabController(svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial), storageStub).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined))) .toEqual({ status: 400, body: { error: 'Reply target message not found' } }); const broadcast = vi.fn(); const notifyCollab = vi.fn(); - const s = svc({ createMessage: vi.fn().mockReturnValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial); + const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial); expect(await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({ message: { id: 3 } }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:created', { message: { id: 3 } }, 'sock'); expect(notifyCollab).toHaveBeenCalledWith('5', user, 'hello'); @@ -175,26 +166,26 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('refuses a caller who cannot reach the trip or cannot write, from inside the handler', async () => { // The decorators had to go because of the multipart body, so these two // refusals are the only thing standing in front of the route now. - expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(null) } as Partial), storageStub).createMessage(user, '5', { text: 'hi' }, undefined))) + expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial), storageStub).createMessage(user, '5', { text: 'hi' }, undefined))) .toEqual({ status: 404, body: { error: 'Trip not found' } }); - expect(await thrownAsync(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) } as Partial), storageStub).createMessage(user, '5', { text: 'hi' }, undefined))) + expect(await thrownAsync(() => new CollabController(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial), storageStub).createMessage(user, '5', { text: 'hi' }, undefined))) .toEqual({ status: 403, body: { error: 'No permission' } }); }); - it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', () => { - expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial), storageStub).react(user, '5', '3', { emoji: '👍' }))).toEqual({ status: 404, body: { error: 'Message not found' } }); + it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => { + expect(await thrownAsync(() => new CollabController(svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial), storageStub).react(user, '5', '3', { emoji: '👍' }))).toEqual({ status: 404, body: { error: 'Message not found' } }); const broadcast = vi.fn(); - const s = svc({ reactMessage: vi.fn().mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast } as Partial); - expect(new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] }); + const s = svc({ reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast } as Partial); + expect(await new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:reacted', { messageId: 3, reactions: [{ emoji: '👍', count: 1 }] }, 'sock'); }); - it('delete maps not_found (404) / not_owner (403), else success with username', () => { - expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial), storageStub).deleteMessage(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Message not found' } }); - expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_owner' }) } as Partial), storageStub).deleteMessage(user, '5', '3'))).toEqual({ status: 403, body: { error: 'You can only delete your own messages' } }); + it('delete maps not_found (404) / not_owner (403), else success with username', async () => { + expect(await thrownAsync(() => new CollabController(svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial), storageStub).deleteMessage(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Message not found' } }); + expect(await thrownAsync(() => new CollabController(svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial), storageStub).deleteMessage(user, '5', '3'))).toEqual({ status: 403, body: { error: 'You can only delete your own messages' } }); const broadcast = vi.fn(); - const s = svc({ deleteMessage: vi.fn().mockReturnValue({ username: 'bob' }), broadcast } as Partial); - expect(new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true }); + const s = svc({ deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }), broadcast } as Partial); + expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true }); expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock'); }); }); @@ -234,7 +225,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('drops the stored images again when the reply target turns out to be gone', async () => { const del = vi.fn().mockResolvedValue(undefined); - const s = svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial); + const s = svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial); expect(await thrownAsync(() => new CollabController(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never) .createMessage(user, '5', { text: 'x', reply_to: 99 }, [img('a.png')]))) @@ -244,7 +235,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou it('refuses an image from someone who may write but may not upload', async () => { const put = vi.fn(); - const s = svc({ canUploadFiles: vi.fn().mockReturnValue(false) } as Partial); + const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial); expect(await thrownAsync(() => new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [img('a.png')]))) .toEqual({ status: 403, body: { error: 'No permission to upload files' } }); expect(put).not.toHaveBeenCalled(); @@ -258,11 +249,11 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou return outcome; }; - it('takes an ordinary attachment', () => { + it('takes an ordinary attachment', async () => { expect(run('itinerary.pdf', 'application/pdf').ok).toBe(true); }); - it('refuses the spellings the download route would serve as a document', () => { + it('refuses the spellings the download route would serve as a document', async () => { expect(run('pwn.html', 'text/html').err).toBeInstanceOf(Error); expect(run('pwn.txt', 'image/svg+xml').err).toBeInstanceOf(Error); expect(run('pwn.txt', 'application/javascript').err).toBeInstanceOf(Error); @@ -276,16 +267,16 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou return outcome; }; - it('accepts a real image', () => { + it('accepts a real image', async () => { expect(run('holiday.jpg', 'image/jpeg').ok).toBe(true); expect(run('map.PNG', 'image/png').ok).toBe(true); }); - it('refuses a type it does not serve', () => { + it('refuses a type it does not serve', async () => { expect(run('notes.pdf', 'application/pdf').err).toBeInstanceOf(Error); }); - it('refuses a name whose extension disagrees with the type it claims', () => { + it('refuses a name whose extension disagrees with the type it claims', async () => { // The mimetype is the header the client wrote. The stored name keeps the // extension of the name the client sent, and the download route decides // what to serve from that extension and sends it inline, so believing the @@ -301,15 +292,15 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou describe('link preview guard chain', () => { const guardsOn = (target: object): unknown[] => (Reflect.getMetadata('__guards__', target) as unknown[]) ?? []; - it('resolves the trip and refuses a caller who cannot reach it', () => { + it('resolves the trip and refuses a caller who cannot reach it', async () => { expect(guardsOn(CollabController.prototype.linkPreview)).toContain(TripAccessGuard); }); - it('sits behind the same authentication as the rest of the controller', () => { + it('sits behind the same authentication as the rest of the controller', async () => { expect(guardsOn(CollabController)).toContain(JwtAuthGuard); }); - it('demands no write permission, matching the other read routes', () => { + it('demands no write permission, matching the other read routes', async () => { // It is requested while rendering a message or a note, never while writing // one. Requiring collab_edit would strip previews from a trip whose owner // narrowed that right, for people who may still read the chat. @@ -320,7 +311,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, CollabController.prototype.createNote)).toBe('collab_edit'); }); - it('the multipart write routes carry no guard decorators, and check inside instead', () => { + it('the multipart write routes carry no guard decorators, and check inside instead', async () => { // Guards run before the interceptor, so a refusal goes out while the client // is still streaming the body and the socket dies as ECONNRESET rather than // carrying the error envelope. Both of these therefore look unguarded here diff --git a/server/tests/unit/nest/collab.service.test.ts b/server/tests/unit/nest/collab.service.test.ts index ab235941b..7452f7650 100644 --- a/server/tests/unit/nest/collab.service.test.ts +++ b/server/tests/unit/nest/collab.service.test.ts @@ -94,11 +94,11 @@ const collabFx = makeStorageFixture('files/'); const rateLimit = new RateLimitService(); let svc: CollabService; beforeAll(async () => { - svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, rateLimit); + svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, rateLimit, await createTestUnitOfWork(testDb)); }); /** A CollabService with its own preview cache and budget, for the tests that fill either. */ -const freshSvc = async () => new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); +const freshSvc = async () => new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService(), await createTestUnitOfWork(testDb)); beforeAll(() => { createTables(testDb); @@ -132,15 +132,15 @@ function setup() { // ── avatarUrl ───────────────────────────────────────────────────────────────── describe('avatarUrl', () => { - it('COLLAB-SVC-001: returns null when avatar is null', () => { + it('COLLAB-SVC-001: returns null when avatar is null', async () => { expect(avatarUrl({ avatar: null })).toBeNull(); }); - it('COLLAB-SVC-002: returns upload path when avatar is set', () => { + it('COLLAB-SVC-002: returns upload path when avatar is set', async () => { expect(avatarUrl({ avatar: 'abc.jpg' })).toBe('/uploads/avatars/abc.jpg'); }); - it('COLLAB-SVC-003: returns null when avatar is empty string', () => { + it('COLLAB-SVC-003: returns null when avatar is empty string', async () => { expect(avatarUrl({ avatar: '' })).toBeNull(); }); }); @@ -148,53 +148,53 @@ describe('avatarUrl', () => { // ── votePoll ────────────────────────────────────────────────────────────────── describe('votePoll', () => { - it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => { + it('COLLAB-SVC-004: returns error "closed" when poll is closed', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - svc.closePoll(trip.id, poll!.id); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + await svc.closePoll(trip.id, poll!.id); - const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); + const result = await svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.error).toBe('closed'); }); - it('COLLAB-SVC-005: returns error "invalid_index" for negative index', () => { + it('COLLAB-SVC-005: returns error "invalid_index" for negative index', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - const result = svc.votePoll(trip.id, poll!.id, user1.id, -1); + const result = await svc.votePoll(trip.id, poll!.id, user1.id, -1); expect(result.error).toBe('invalid_index'); }); - it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', () => { + it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - const result = svc.votePoll(trip.id, poll!.id, user1.id, 5); + const result = await svc.votePoll(trip.id, poll!.id, user1.id, 5); expect(result.error).toBe('invalid_index'); }); - it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', () => { + it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', async () => { const { user1, trip } = setup(); - const result = svc.votePoll(trip.id, 9999, user1.id, 0); + const result = await svc.votePoll(trip.id, 9999, user1.id, 0); expect(result.error).toBe('not_found'); }); - it('COLLAB-SVC-008: successfully votes and returns poll with voters', () => { + it('COLLAB-SVC-008: successfully votes and returns poll with voters', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); - const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); + const result = await svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.error).toBeUndefined(); expect(result.poll).toBeDefined(); expect(result.poll!.options[0].voters).toHaveLength(1); }); - it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => { + it('COLLAB-SVC-009: toggles vote off when voted again on same option', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] }); - svc.votePoll(trip.id, poll!.id, user1.id, 0); - const result = svc.votePoll(trip.id, poll!.id, user1.id, 0); + await svc.votePoll(trip.id, poll!.id, user1.id, 0); + const result = await svc.votePoll(trip.id, poll!.id, user1.id, 0); expect(result.poll!.options[0].voters).toHaveLength(0); }); }); @@ -202,23 +202,23 @@ describe('votePoll', () => { // ── listMessages with before cursor ────────────────────────────────────────── describe('listMessages', () => { - it('COLLAB-SVC-010: returns all messages when no before cursor', () => { + it('COLLAB-SVC-010: returns all messages when no before cursor', async () => { const { user1, trip } = setup(); - svc.createMessage(trip.id, user1.id, 'Hello'); - svc.createMessage(trip.id, user1.id, 'World'); + await svc.createMessage(trip.id, user1.id, 'Hello'); + await svc.createMessage(trip.id, user1.id, 'World'); - const msgs = svc.listMessages(trip.id); + const msgs = await svc.listMessages(trip.id); expect(msgs).toHaveLength(2); }); - it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => { + it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', async () => { const { user1, trip } = setup(); - svc.createMessage(trip.id, user1.id, 'First'); - svc.createMessage(trip.id, user1.id, 'Second'); - const r3 = svc.createMessage(trip.id, user1.id, 'Third'); + await svc.createMessage(trip.id, user1.id, 'First'); + await svc.createMessage(trip.id, user1.id, 'Second'); + const r3 = await svc.createMessage(trip.id, user1.id, 'Third'); const id3 = r3.message!.id; - const msgs = svc.listMessages(trip.id, id3); + const msgs = await svc.listMessages(trip.id, id3); expect(msgs.length).toBe(2); const texts = msgs.map(m => m.text); expect(texts).toContain('First'); @@ -226,63 +226,63 @@ describe('listMessages', () => { expect(texts).not.toContain('Third'); }); - it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => { + it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', async () => { const { user1, trip } = setup(); - svc.createMessage(trip.id, user1.id, 'A'); - svc.createMessage(trip.id, user1.id, 'B'); - svc.createMessage(trip.id, user1.id, 'C'); + await svc.createMessage(trip.id, user1.id, 'A'); + await svc.createMessage(trip.id, user1.id, 'B'); + await svc.createMessage(trip.id, user1.id, 'C'); - const msgs = svc.listMessages(trip.id); + const msgs = await svc.listMessages(trip.id); expect(msgs[0].text).toBe('A'); expect(msgs[2].text).toBe('C'); }); - it('COLLAB-SVC-013a: blanks the text of a deleted message but keeps the flag', () => { + it('COLLAB-SVC-013a: blanks the text of a deleted message but keeps the flag', async () => { const { user1, trip } = setup(); - const r = svc.createMessage(trip.id, user1.id, 'Secret plans'); - svc.deleteMessage(trip.id, r.message!.id, user1.id); + const r = await svc.createMessage(trip.id, user1.id, 'Secret plans'); + await svc.deleteMessage(trip.id, r.message!.id, user1.id); - const msgs = svc.listMessages(trip.id); + const msgs = await svc.listMessages(trip.id); expect(msgs).toHaveLength(1); expect(msgs[0].text).toBe(''); expect(msgs[0].deleted).toBe(1); }); - it('COLLAB-SVC-013b: blanks reply_text when the quoted message was deleted', () => { + it('COLLAB-SVC-013b: blanks reply_text when the quoted message was deleted', async () => { const { user1, trip } = setup(); - const original = svc.createMessage(trip.id, user1.id, 'Original secret'); - svc.createMessage(trip.id, user1.id, 'Quoting it', original.message!.id); - svc.deleteMessage(trip.id, original.message!.id, user1.id); + const original = await svc.createMessage(trip.id, user1.id, 'Original secret'); + await svc.createMessage(trip.id, user1.id, 'Quoting it', original.message!.id); + await svc.deleteMessage(trip.id, original.message!.id, user1.id); - const msgs = svc.listMessages(trip.id); + const msgs = await svc.listMessages(trip.id); const reply = msgs.find(m => m.text === 'Quoting it')!; expect(reply.reply_text).toBe(''); }); - it('COLLAB-SVC-013c: a deleted message cannot be quoted, and the create path blanks too', () => { + it('COLLAB-SVC-013c: a deleted message cannot be quoted, and the create path blanks too', async () => { const { user1, trip } = setup(); - const original = svc.createMessage(trip.id, user1.id, 'Original secret'); - svc.deleteMessage(trip.id, original.message!.id, user1.id); + const original = await svc.createMessage(trip.id, user1.id, 'Original secret'); + await svc.deleteMessage(trip.id, original.message!.id, user1.id); // Replying to something that is no longer there is refused outright. - expect(svc.createMessage(trip.id, user1.id, 'Too late', original.message!.id)).toEqual({ error: 'reply_not_found' }); + expect(await svc.createMessage(trip.id, user1.id, 'Too late', original.message!.id)).toEqual({ error: 'reply_not_found' }); // And the row the create path returns carries the same blanking listMessages // does, for a message quoted before the original was deleted. - const second = svc.createMessage(trip.id, user1.id, 'Another secret'); - const quoting = svc.createMessage(trip.id, user1.id, 'Quoting it', second.message!.id); + const second = await svc.createMessage(trip.id, user1.id, 'Another secret'); + const quoting = await svc.createMessage(trip.id, user1.id, 'Quoting it', second.message!.id); expect(quoting.message!.reply_text).toBe('Another secret'); - svc.deleteMessage(trip.id, second.message!.id, user1.id); - expect(svc.listMessages(trip.id).find(m => m.text === 'Quoting it')!.reply_text).toBe(''); + await svc.deleteMessage(trip.id, second.message!.id, user1.id); + expect((await svc.listMessages(trip.id)).find(m => m.text === 'Quoting it')!.reply_text).toBe(''); }); - it('COLLAB-SVC-013: includes reactions grouped by emoji', () => { + it('COLLAB-SVC-013: includes reactions grouped by emoji', async () => { const { user1, trip } = setup(); - const r = svc.createMessage(trip.id, user1.id, 'React me'); + const r = await svc.createMessage(trip.id, user1.id, 'React me'); const msgId = r.message!.id; testDb.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(msgId, user1.id, '👍'); - const msgs = svc.listMessages(trip.id); + const msgs = await svc.listMessages(trip.id); expect(msgs[0].reactions).toBeDefined(); expect(msgs[0].reactions).toHaveLength(1); expect(msgs[0].reactions[0].emoji).toBe('👍'); @@ -292,16 +292,16 @@ describe('listMessages', () => { // ── createMessage with invalid replyTo ─────────────────────────────────────── describe('createMessage', () => { - it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => { + it('COLLAB-SVC-014: returns error when replyTo message does not exist', async () => { const { user1, trip } = setup(); - const result = svc.createMessage(trip.id, user1.id, 'Reply to nothing', 9999); + const result = await svc.createMessage(trip.id, user1.id, 'Reply to nothing', 9999); expect(result.error).toBe('reply_not_found'); }); - it('COLLAB-SVC-015: creates message with valid replyTo', () => { + it('COLLAB-SVC-015: creates message with valid replyTo', async () => { const { user1, trip } = setup(); - const r1 = svc.createMessage(trip.id, user1.id, 'Original'); - const r2 = svc.createMessage(trip.id, user1.id, 'Reply', r1.message!.id); + const r1 = await svc.createMessage(trip.id, user1.id, 'Original'); + const r2 = await svc.createMessage(trip.id, user1.id, 'Reply', r1.message!.id); expect(r2.error).toBeUndefined(); expect(r2.message!.reply_to).toBe(r1.message!.id); }); @@ -310,25 +310,25 @@ describe('createMessage', () => { // ── deleteMessage ownership check ───────────────────────────────────────────── describe('deleteMessage', () => { - it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => { + it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', async () => { const { user1, user2, trip } = setup(); - const r = svc.createMessage(trip.id, user1.id, 'My message'); + const r = await svc.createMessage(trip.id, user1.id, 'My message'); - const result = svc.deleteMessage(trip.id, r.message!.id, user2.id); + const result = await svc.deleteMessage(trip.id, r.message!.id, user2.id); expect(result.error).toBe('not_owner'); }); - it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', () => { + it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', async () => { const { user1, trip } = setup(); - const result = svc.deleteMessage(trip.id, 9999, user1.id); + const result = await svc.deleteMessage(trip.id, 9999, user1.id); expect(result.error).toBe('not_found'); }); - it('COLLAB-SVC-018: marks message as deleted when owner deletes it', () => { + it('COLLAB-SVC-018: marks message as deleted when owner deletes it', async () => { const { user1, trip } = setup(); - const r = svc.createMessage(trip.id, user1.id, 'Delete me'); + const r = await svc.createMessage(trip.id, user1.id, 'Delete me'); - const result = svc.deleteMessage(trip.id, r.message!.id, user1.id); + const result = await svc.deleteMessage(trip.id, r.message!.id, user1.id); expect(result.error).toBeUndefined(); const row = testDb.prepare('SELECT deleted FROM collab_messages WHERE id = ?').get(r.message!.id) as any; @@ -339,11 +339,11 @@ describe('deleteMessage', () => { // ── updateNote partial fields ───────────────────────────────────────────────── describe('updateNote', () => { - it('COLLAB-SVC-019: updates only title when other fields are undefined', () => { + it('COLLAB-SVC-019: updates only title when other fields are undefined', async () => { const { user1, trip } = setup(); - const note = svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' }); + const note = await svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' }); - svc.updateNote(trip.id, note.id, { title: 'Updated' }); + await svc.updateNote(trip.id, note.id, { title: 'Updated' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.title).toBe('Updated'); @@ -351,47 +351,47 @@ describe('updateNote', () => { expect(updated.website).toBe('https://example.com'); // unchanged }); - it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => { + it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', async () => { const { user1, trip } = setup(); - const note = svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old content' }); + const note = await svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old content' }); - svc.updateNote(trip.id, note.id, { content: '' }); + await svc.updateNote(trip.id, note.id, { content: '' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.content).toBe(''); }); - it('COLLAB-SVC-021: updates website when website is defined', () => { + it('COLLAB-SVC-021: updates website when website is defined', async () => { const { user1, trip } = setup(); - const note = svc.createNote(trip.id, user1.id, { title: 'T' }); + const note = await svc.createNote(trip.id, user1.id, { title: 'T' }); - svc.updateNote(trip.id, note.id, { website: 'https://new.example.com' }); + await svc.updateNote(trip.id, note.id, { website: 'https://new.example.com' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.website).toBe('https://new.example.com'); }); - it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => { + it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', async () => { const { user1, trip } = setup(); - const note = svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' }); + const note = await svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' }); - svc.updateNote(trip.id, note.id, { website: '' }); + await svc.updateNote(trip.id, note.id, { website: '' }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.website).toBe(''); }); - it('COLLAB-SVC-023: returns null when note does not exist', () => { + it('COLLAB-SVC-023: returns null when note does not exist', async () => { const { trip } = setup(); - const result = svc.updateNote(trip.id, 9999, { title: 'Ghost' }); + const result = await svc.updateNote(trip.id, 9999, { title: 'Ghost' }); expect(result).toBeNull(); }); - it('COLLAB-SVC-024: updates pinned flag', () => { + it('COLLAB-SVC-024: updates pinned flag', async () => { const { user1, trip } = setup(); - const note = svc.createNote(trip.id, user1.id, { title: 'T', pinned: false }); + const note = await svc.createNote(trip.id, user1.id, { title: 'T', pinned: false }); - svc.updateNote(trip.id, note.id, { pinned: true }); + await svc.updateNote(trip.id, note.id, { pinned: true }); const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any; expect(updated.pinned).toBe(1); @@ -689,9 +689,9 @@ describe('hardening', () => { it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', async () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); - const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - failing.votePoll(trip.id, poll!.id, user1.id, 0); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); + const poll = await failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + await failing.votePoll(trip.id, poll!.id, user1.id, 0); const realRun = dbs.run.bind(dbs); const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => { @@ -700,7 +700,7 @@ describe('hardening', () => { }); // Single-choice switch: DELETE prior votes, then the INSERT fails — the // transaction must roll the DELETE back too. - expect(() => failing.votePoll(trip.id, poll!.id, user1.id, 1)).toThrow('boom'); + await expect(failing.votePoll(trip.id, poll!.id, user1.id, 1)).rejects.toThrow('boom'); spy.mockRestore(); const votes = testDb.prepare('SELECT option_index FROM collab_poll_votes WHERE poll_id = ?').all(poll!.id) as { option_index: number }[]; @@ -710,8 +710,8 @@ describe('hardening', () => { it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', async () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); - const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService()); - const note = failing.createNote(trip.id, user1.id, { title: 'With file' }); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), collabFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); + const note = await failing.createNote(trip.id, user1.id, { title: 'With file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'files/a.pdf', 'a.pdf'); @@ -731,8 +731,8 @@ describe('hardening', () => { const { user1, trip } = setup(); const dbs = new DatabaseService(testDb); const failingStorage = { delete: vi.fn().mockRejectedValue(new Error('EACCES')) }; - const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService, new RateLimitService()); - const note = failing.createNote(trip.id, user1.id, { title: 'Sticky file' }); + const failing = new CollabService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new RealtimeService(), notificationsStub(), failingStorage as unknown as import('../../../src/nest/storage/storage.service').StorageService, new RateLimitService(), await createTestUnitOfWork(dbs.connection)); + const note = await failing.createNote(trip.id, user1.id, { title: 'Sticky file' }); testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)') .run(trip.id, note.id, 'stuck.pdf', 'stuck.pdf'); const fileId = (testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').get(note.id) as { id: number }).id; @@ -746,22 +746,22 @@ describe('hardening', () => { expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_notes WHERE id = ?').get(note.id)).toEqual({ c: 0 }); }); - it('COLLAB-SVC-036: getFormattedNoteById is trip-scoped and null-safe', () => { + it('COLLAB-SVC-036: getFormattedNoteById is trip-scoped and null-safe', async () => { const { user1, trip } = setup(); const otherTrip = createTrip(testDb, user1.id); - const note = svc.createNote(trip.id, user1.id, { title: 'Scoped' }); + const note = await svc.createNote(trip.id, user1.id, { title: 'Scoped' }); - expect(svc.getFormattedNoteById(trip.id, note.id)!.title).toBe('Scoped'); - expect(svc.getFormattedNoteById(otherTrip.id, note.id)).toBeNull(); - expect(svc.getFormattedNoteById(trip.id, 9999)).toBeNull(); + expect((await svc.getFormattedNoteById(trip.id, note.id))!.title).toBe('Scoped'); + expect(await svc.getFormattedNoteById(otherTrip.id, note.id)).toBeNull(); + expect(await svc.getFormattedNoteById(trip.id, 9999)).toBeNull(); }); - it('COLLAB-SVC-037: votePoll rejects a non-integer option index with "invalid_index"', () => { + it('COLLAB-SVC-037: votePoll rejects a non-integer option index with "invalid_index"', async () => { const { user1, trip } = setup(); - const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); + const poll = await svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] }); - expect(svc.votePoll(trip.id, poll!.id, user1.id, '0' as unknown as number).error).toBe('invalid_index'); - expect(svc.votePoll(trip.id, poll!.id, user1.id, 0.5).error).toBe('invalid_index'); + expect((await svc.votePoll(trip.id, poll!.id, user1.id, '0' as unknown as number)).error).toBe('invalid_index'); + expect((await svc.votePoll(trip.id, poll!.id, user1.id, 0.5)).error).toBe('invalid_index'); expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_poll_votes WHERE poll_id = ?').get(poll!.id)).toEqual({ c: 0 }); }); diff --git a/server/tests/unit/nest/collections.controller.test.ts b/server/tests/unit/nest/collections.controller.test.ts index 0b3375dc0..c456037c8 100644 --- a/server/tests/unit/nest/collections.controller.test.ts +++ b/server/tests/unit/nest/collections.controller.test.ts @@ -15,34 +15,34 @@ const user = { id: 1, username: 'owner', role: 'user', email: 'u@example.test' } /** Service mock — access granted + owner by default; each method a spy. */ function makeService(overrides: Partial> = {}): CollectionsService { return { - listCollections: vi.fn().mockReturnValue({ collections: [], incomingInvites: [] }), - getCollection: vi.fn().mockReturnValue({ collection: { id: 3 }, places: [] }), - createCollection: vi.fn().mockReturnValue({ id: 7 }), - updateCollection: vi.fn().mockReturnValue({ id: 3 }), - setCollectionCover: vi.fn().mockReturnValue({ id: 3 }), + listCollections: vi.fn().mockResolvedValue({ collections: [], incomingInvites: [] }), + getCollection: vi.fn().mockResolvedValue({ collection: { id: 3 }, places: [] }), + createCollection: vi.fn().mockResolvedValue({ id: 7 }), + updateCollection: vi.fn().mockResolvedValue({ id: 3 }), + setCollectionCover: vi.fn().mockResolvedValue({ id: 3 }), deleteCollection: vi.fn(), reorderCollections: vi.fn(), - savePlace: vi.fn().mockReturnValue({ place: { id: 9 } }), - saveFromTripPlace: vi.fn().mockReturnValue({ place: { id: 9 } }), - updatePlace: vi.fn().mockReturnValue({ id: 9 }), - setStatus: vi.fn().mockReturnValue({ id: 9 }), + savePlace: vi.fn().mockResolvedValue({ place: { id: 9 } }), + saveFromTripPlace: vi.fn().mockResolvedValue({ place: { id: 9 } }), + updatePlace: vi.fn().mockResolvedValue({ id: 9 }), + setStatus: vi.fn().mockResolvedValue({ id: 9 }), deletePlace: vi.fn(), - deletePlacesMany: vi.fn().mockReturnValue([1, 2]), - copyToTrip: vi.fn().mockReturnValue({ copied: 1, skipped: [] }), - findMembership: vi.fn().mockReturnValue({ inLists: [] }), + deletePlacesMany: vi.fn().mockResolvedValue([1, 2]), + copyToTrip: vi.fn().mockResolvedValue({ copied: 1, skipped: [] }), + findMembership: vi.fn().mockResolvedValue({ inLists: [] }), assertAccess: vi.fn(), - isOwner: vi.fn().mockReturnValue(true), - sendInvite: vi.fn().mockReturnValue({ success: true }), - acceptInvite: vi.fn().mockReturnValue({ success: true }), + isOwner: vi.fn().mockResolvedValue(true), + sendInvite: vi.fn().mockResolvedValue({ success: true }), + acceptInvite: vi.fn().mockResolvedValue({ success: true }), declineInvite: vi.fn(), cancelInvite: vi.fn(), leaveCollection: vi.fn(), removeMember: vi.fn(), - availableUsers: vi.fn().mockReturnValue([{ id: 2 }]), - createLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Berlin' }), - updateLabel: vi.fn().mockReturnValue({ id: 5, collection_id: 3, name: 'Hamburg' }), + availableUsers: vi.fn().mockResolvedValue([{ id: 2 }]), + createLabel: vi.fn().mockResolvedValue({ id: 5, collection_id: 3, name: 'Berlin' }), + updateLabel: vi.fn().mockResolvedValue({ id: 5, collection_id: 3, name: 'Hamburg' }), deleteLabel: vi.fn(), - assignLabels: vi.fn().mockReturnValue({ changed: 2 }), + assignLabels: vi.fn().mockResolvedValue({ changed: 2 }), ...overrides, } as unknown as CollectionsService; } @@ -70,15 +70,15 @@ async function thrownAsync(fn: () => Promise): Promise<{ status: number describe('CollectionsController', () => { afterEach(() => { delete process.env.DEMO_MODE; }); - it('lists / creates / reads / deletes lists (pass-throughs)', () => { + it('lists / creates / reads / deletes lists (pass-throughs)', async () => { const svc = makeService(); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); - expect(c.list(user)).toEqual({ collections: [], incomingInvites: [] }); - expect(c.create(user, { name: 'Italy' } as never)).toEqual({ id: 7 }); - expect(c.get(user, '3')).toEqual({ collection: { id: 3 }, places: [] }); - expect(c.update(user, '3', { name: 'x' } as never, 'sock')).toEqual({ id: 3 }); + expect(await c.list(user)).toEqual({ collections: [], incomingInvites: [] }); + expect(await c.create(user, { name: 'Italy' } as never)).toEqual({ id: 7 }); + expect(await c.get(user, '3')).toEqual({ collection: { id: 3 }, places: [] }); + expect(await c.update(user, '3', { name: 'x' } as never, 'sock')).toEqual({ id: 3 }); expect(svc.updateCollection).toHaveBeenCalledWith(1, 3, { name: 'x' }, 'sock'); - expect(c.remove(user, '3')).toEqual({ success: true }); + expect(await c.remove(user, '3')).toEqual({ success: true }); expect(svc.deleteCollection).toHaveBeenCalledWith(1, 3); }); @@ -86,9 +86,9 @@ describe('CollectionsController', () => { // The legacy 'orderedIds must be an array of numbers' 400 is gone: // collectionReorderRequestSchema types the array, so the pipe rejects a // bad payload before the handler runs. - it('reorders a valid array', () => { + it('reorders a valid array', async () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).reorder(user, { orderedIds: [3, 1, 2] } as never)).toEqual({ success: true }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).reorder(user, { orderedIds: [3, 1, 2] } as never)).toEqual({ success: true }); expect(svc.reorderCollections).toHaveBeenCalledWith(1, [3, 1, 2]); }); }); @@ -97,17 +97,17 @@ describe('CollectionsController', () => { it('savePlace / updatePlace / setStatus / deletePlace forward the socket id', async () => { const svc = makeService(); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); - c.savePlace(user, { collection_id: 3, name: 'A' } as never, 'sid'); + await c.savePlace(user, { collection_id: 3, name: 'A' } as never, 'sid'); expect(svc.savePlace).toHaveBeenCalledWith(1, { collection_id: 3, name: 'A' }, 'sid'); await c.updatePlace(user, '9', { name: 'B' } as never, 'sid'); expect(svc.updatePlace).toHaveBeenCalledWith(1, 9, { name: 'B' }, 'sid'); - c.setStatus(user, '9', { status: 'want' } as never, 'sid'); + await c.setStatus(user, '9', { status: 'want' } as never, 'sid'); expect(svc.setStatus).toHaveBeenCalledWith(1, 9, 'want', 'sid'); expect(await c.deletePlace(user, '9', 'sid')).toEqual({ success: true }); expect(svc.deletePlace).toHaveBeenCalledWith(1, 9, 'sid'); - c.saveFromTrip(user, { collection_id: 3, source_trip_id: 5, source_place_id: 8 } as never, 'sid'); + await c.saveFromTrip(user, { collection_id: 3, source_trip_id: 5, source_place_id: 8 } as never, 'sid'); expect(svc.saveFromTripPlace).toHaveBeenCalledWith(1, 3, 5, 8, undefined, 'sid'); - expect(c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never)).toEqual({ copied: 1, skipped: [] }); + expect(await c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never)).toEqual({ copied: 1, skipped: [] }); }); // The legacy 'ids must be an array of numbers' 400 is gone: @@ -121,72 +121,72 @@ describe('CollectionsController', () => { }); describe('membership lookup', () => { - it('parses lat/lng when present', () => { + it('parses lat/lng when present', async () => { const svc = makeService(); - new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user, 'gpid', undefined, 'Rome', '41.9', '12.5'); + await new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user, 'gpid', undefined, 'Rome', '41.9', '12.5'); expect(svc.findMembership).toHaveBeenCalledWith(1, { google_place_id: 'gpid', google_ftid: undefined, name: 'Rome', lat: 41.9, lng: 12.5 }); }); - it('leaves lat/lng undefined when absent', () => { + it('leaves lat/lng undefined when absent', async () => { const svc = makeService(); - new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user); + await new CollectionsController(svc, new RuntimeEnvService(), storageStub).membership(user); expect(svc.findMembership).toHaveBeenCalledWith(1, { google_place_id: undefined, google_ftid: undefined, name: undefined, lat: undefined, lng: undefined }); }); }); describe('invites (owner-gated)', () => { - it('403 when a non-owner invites', () => { - const svc = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) + it('403 when a non-owner invites', async () => { + const svc = makeService({ isOwner: vi.fn().mockResolvedValue(false) }); + expect(await thrownAsync(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can invite' } }); }); - it('surfaces a sendInvite error with its status', () => { - const svc = makeService({ sendInvite: vi.fn().mockReturnValue({ error: 'Already a member', status: 409 }) }); - expect(thrown(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) + it('surfaces a sendInvite error with its status', async () => { + const svc = makeService({ sendInvite: vi.fn().mockResolvedValue({ error: 'Already a member', status: 409 }) }); + expect(await thrownAsync(() => new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 409, body: { error: 'Already a member' } }); }); - it('invites successfully as owner', () => { + it('invites successfully as owner', async () => { const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); }); - it('accept surfaces an error, else succeeds', () => { - const bad = makeService({ acceptInvite: vi.fn().mockReturnValue({ error: 'Gone', status: 404 }) }); - expect(thrown(() => new CollectionsController(bad, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid'))) + it('accept surfaces an error, else succeeds', async () => { + const bad = makeService({ acceptInvite: vi.fn().mockResolvedValue({ error: 'Gone', status: 404 }) }); + expect(await thrownAsync(() => new CollectionsController(bad, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid'))) .toEqual({ status: 404, body: { error: 'Gone' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).acceptInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); expect(svc.acceptInvite).toHaveBeenCalledWith(1, 3, 'sid'); }); - it('decline + leave forward the socket id', () => { + it('decline + leave forward the socket id', async () => { const svc = makeService(); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); - expect(c.declineInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); + expect(await c.declineInvite(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); expect(svc.declineInvite).toHaveBeenCalledWith(1, 3, 'sid'); - expect(c.leave(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); + expect(await c.leave(user, { collection_id: 3 } as never, 'sid')).toEqual({ success: true }); expect(svc.leaveCollection).toHaveBeenCalledWith(1, 3, 'sid'); }); - it('cancel is owner-gated', () => { - const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never))) + it('cancel is owner-gated', async () => { + const notOwner = makeService({ isOwner: vi.fn().mockResolvedValue(false) }); + expect(await thrownAsync(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can cancel invites' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).cancelInvite(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); }); }); describe('members', () => { - it('removeMember is owner-gated', () => { - const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never))) + it('removeMember is owner-gated', async () => { + const notOwner = makeService({ isOwner: vi.fn().mockResolvedValue(false) }); + expect(await thrownAsync(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never))) .toEqual({ status: 403, body: { error: 'Only the owner can remove members' } }); const svc = makeService(); - expect(new CollectionsController(svc, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); + expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).removeMember(user, { collection_id: 3, user_id: 2 } as never)).toEqual({ success: true }); expect(svc.removeMember).toHaveBeenCalledWith(1, 3, 2); }); - it('availableUsers is owner-gated', () => { - const notOwner = makeService({ isOwner: vi.fn().mockReturnValue(false) }); - expect(thrown(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).availableUsers(user, '3'))) + it('availableUsers is owner-gated', async () => { + const notOwner = makeService({ isOwner: vi.fn().mockResolvedValue(false) }); + expect(await thrownAsync(() => new CollectionsController(notOwner, new RuntimeEnvService(), storageStub).availableUsers(user, '3'))) .toEqual({ status: 403, body: { error: 'Only the owner can manage members' } }); - expect(new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).availableUsers(user, '3')).toEqual({ users: [{ id: 2 }] }); + expect(await new CollectionsController(makeService(), new RuntimeEnvService(), storageStub).availableUsers(user, '3')).toEqual({ users: [{ id: 2 }] }); }); }); @@ -223,7 +223,7 @@ describe('CollectionsController', () => { .toEqual({ status: 400, body: { error: 'No image uploaded' } }); }); it('commits + stores the place image via /uploads/places and forwards the socket id', async () => { - const svc = makeService({ setPlaceImage: vi.fn().mockReturnValue({ id: 9 }) }); + const svc = makeService({ setPlaceImage: vi.fn().mockResolvedValue({ id: 9 }) }); expect(await new CollectionsController(svc, new RuntimeEnvService(), storageStub).uploadPlaceImage(user, '9', file, 'sid')).toEqual({ id: 9 }); expect(storageStub.put).toHaveBeenCalledWith('places', 'x.jpg', { tmpPath: undefined }); expect(svc.setPlaceImage).toHaveBeenCalledWith(1, 9, '/uploads/places/x.jpg', 'sid'); @@ -231,34 +231,34 @@ describe('CollectionsController', () => { }); describe('labels', () => { - it('create / update / delete / assign / unassign forward user + socket id', () => { + it('create / update / delete / assign / unassign forward user + socket id', async () => { const svc = makeService(); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); - c.createLabel(user, { collection_id: 3, name: 'Berlin', color: '#ff0000' } as never, 'sock'); + await c.createLabel(user, { collection_id: 3, name: 'Berlin', color: '#ff0000' } as never, 'sock'); expect(svc.createLabel).toHaveBeenCalledWith(1, 3, 'Berlin', '#ff0000', 'sock'); - c.updateLabel(user, '5', { name: 'Hamburg' } as never, 'sock'); + await c.updateLabel(user, '5', { name: 'Hamburg' } as never, 'sock'); expect(svc.updateLabel).toHaveBeenCalledWith(1, 5, { name: 'Hamburg' }, 'sock'); - expect(c.deleteLabel(user, '5', 'sock')).toEqual({ success: true }); + expect(await c.deleteLabel(user, '5', 'sock')).toEqual({ success: true }); expect(svc.deleteLabel).toHaveBeenCalledWith(1, 5, 'sock'); - c.assignLabels(user, { label_ids: [5], place_ids: [9, 10] } as never, 'sock'); + await c.assignLabels(user, { label_ids: [5], place_ids: [9, 10] } as never, 'sock'); expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9, 10], false, 'sock'); - c.unassignLabels(user, { label_ids: [5], place_ids: [9] } as never, 'sock'); + await c.unassignLabels(user, { label_ids: [5], place_ids: [9] } as never, 'sock'); expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9], true, 'sock'); }); }); describe('GPX (#2301)', () => { - it('exports the list as GPX for the caller, with the id as a number', () => { + it('exports the list as GPX for the caller, with the id as a number', async () => { const exported = { name: 'Lisbon', gpx: '', waypoints: 0, omitted: 0 }; - const svc = makeService({ exportCollectionGpx: vi.fn().mockReturnValue(exported) }); + const svc = makeService({ exportCollectionGpx: vi.fn().mockResolvedValue(exported) }); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); - expect(c.exportCollectionGpx(user, '3')).toBe(exported); + expect(await c.exportCollectionGpx(user, '3')).toBe(exported); expect(svc.exportCollectionGpx).toHaveBeenCalledWith(1, 3); }); diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 4b1052118..38c0debb1 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -61,7 +61,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; const storageFx = makeStorageFixture(''); let svc: CollectionsService; beforeAll(async () => { - svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage); + svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage, await createTestUnitOfWork(testDb)); }); // The real cache: these cases assert what removeIfUnreferenced actually does // about collection_places (#1081), so a stub would assert nothing. @@ -103,34 +103,34 @@ afterAll(() => { // ── Lists CRUD + visibility ────────────────────────────────────────────────── describe('collections CRUD + visibility', () => { - it('COLLECTIONS-SVC-001: createCollection + listCollections is owner-scoped', () => { + it('COLLECTIONS-SVC-001: createCollection + listCollections is owner-scoped', async () => { const a = createUser(testDb).user; const b = createUser(testDb).user; - const col = svc.createCollection(a.id, { name: 'Tokyo' }); + const col = await svc.createCollection(a.id, { name: 'Tokyo' }); expect(col.is_owner).toBe(true); expect(col.owner_id).toBe(a.id); - expect(svc.listCollections(a.id).collections).toHaveLength(1); - expect(svc.listCollections(b.id).collections).toHaveLength(0); + expect((await svc.listCollections(a.id)).collections).toHaveLength(1); + expect((await svc.listCollections(b.id)).collections).toHaveLength(0); }); - it('COLLECTIONS-SVC-002: getCollection 404 for a non-member', () => { + it('COLLECTIONS-SVC-002: getCollection 404 for a non-member', async () => { const a = createUser(testDb).user; const b = createUser(testDb).user; - const col = svc.createCollection(a.id, { name: 'Private' }); - expect(() => svc.getCollection(b.id, col.id)).toThrow(); - try { svc.getCollection(b.id, col.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } + const col = await svc.createCollection(a.id, { name: 'Private' }); + await expect(svc.getCollection(b.id, col.id)).rejects.toThrow(); + try { await svc.getCollection(b.id, col.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); - it('COLLECTIONS-SVC-003: updateCollection renames; reorder only touches visible rows', () => { + it('COLLECTIONS-SVC-003: updateCollection renames; reorder only touches visible rows', async () => { const a = createUser(testDb).user; - const col = svc.createCollection(a.id, { name: 'Old' }); - const updated = svc.updateCollection(a.id, col.id, { name: 'New' }); + const col = await svc.createCollection(a.id, { name: 'Old' }); + const updated = await svc.updateCollection(a.id, col.id, { name: 'New' }); expect(updated.name).toBe('New'); const b = createUser(testDb).user; - const other = svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0 - svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1 + const other = await svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0 + await svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1 const otherRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(other.id) as { sort_order: number }; const colRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(col.id) as { sort_order: number }; expect(otherRow.sort_order).toBe(0); // untouched — not visible to a @@ -141,13 +141,13 @@ describe('collections CRUD + visibility', () => { // ── Saved places + dedup ───────────────────────────────────────────────────── describe('saved places + dedup', () => { - it('COLLECTIONS-SVC-010: savePlace sets owner_id=owner, saved_by=caller, no itinerary cols', () => { + it('COLLECTIONS-SVC-010: savePlace sets owner_id=owner, saved_by=caller, no itinerary cols', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared' }); + const col = await svc.createCollection(owner.id, { name: 'Shared' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); - const res = svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 }); + const res = await svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 }); expect(res.place).toBeDefined(); const row = testDb.prepare('SELECT * FROM collection_places WHERE id = ?').get(res.place!.id) as Record; expect(row.owner_id).toBe(owner.id); @@ -156,57 +156,57 @@ describe('saved places + dedup', () => { expect('place_time' in row).toBe(false); }); - it('COLLECTIONS-SVC-011: second identical save is a duplicate; force inserts', () => { + it('COLLECTIONS-SVC-011: second identical save is a duplicate; force inserts', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Dedup' }); - svc.savePlace(u.id, { collection_id: col.id, name: 'Eiffel Tower' }); + const col = await svc.createCollection(u.id, { name: 'Dedup' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Eiffel Tower' }); - const dup = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower' }); + const dup = await svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower' }); expect(dup.duplicate).toBe(true); expect(dup.duplicateOf?.name).toBe('Eiffel Tower'); - const forced = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true }); + const forced = await svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true }); expect(forced.place).toBeDefined(); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 2 }); }); - it('COLLECTIONS-SVC-012: savePlace attaches tags', () => { + it('COLLECTIONS-SVC-012: savePlace attaches tags', async () => { const u = createUser(testDb).user; const tag = createTag(testDb, u.id, { name: 'food' }); - const col = svc.createCollection(u.id, { name: 'Tagged' }); - const res = svc.savePlace(u.id, { collection_id: col.id, name: 'Ramen', tag_ids: [tag.id] }); + const col = await svc.createCollection(u.id, { name: 'Tagged' }); + const res = await svc.savePlace(u.id, { collection_id: col.id, name: 'Ramen', tag_ids: [tag.id] }); expect(res.place!.tags?.map((t) => t.name)).toContain('food'); }); - it('COLLECTIONS-SVC-013: savePlace rejects an inaccessible collection (404)', () => { + it('COLLECTIONS-SVC-013: savePlace rejects an inaccessible collection (404)', async () => { const a = createUser(testDb).user; const b = createUser(testDb).user; - const col = svc.createCollection(a.id, { name: 'Locked' }); - expect(() => svc.savePlace(b.id, { collection_id: col.id, name: 'X' })).toThrow(); + const col = await svc.createCollection(a.id, { name: 'Locked' }); + await expect(svc.savePlace(b.id, { collection_id: col.id, name: 'X' })).rejects.toThrow(); }); - it('COLLECTIONS-SVC-100: a NAMED candidate does not merge into a different place at the same coordinates', () => { + it('COLLECTIONS-SVC-100: a NAMED candidate does not merge into a different place at the same coordinates', async () => { // The wrong-city hazard: findDuplicateCollectionPlace used to fall through to // a coordinate match for a named candidate whose name did not match anything, // which would report two distinct places at one address (the restaurant and // the bar) as duplicates of each other. const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Berlin' }); - svc.savePlace(u.id, { collection_id: col.id, name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 }); + const col = await svc.createCollection(u.id, { name: 'Berlin' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 }); - const result = svc.savePlace(u.id, { collection_id: col.id, name: 'Rooftop Bar', lat: 52.52, lng: 13.405 }); + const result = await svc.savePlace(u.id, { collection_id: col.id, name: 'Rooftop Bar', lat: 52.52, lng: 13.405 }); expect(result.duplicate).toBeFalsy(); expect(result.place).toBeDefined(); }); - it('COLLECTIONS-SVC-101: a provider id still recognises a renamed place a name/coords search would miss', () => { + it('COLLECTIONS-SVC-101: a provider id still recognises a renamed place a name/coords search would miss', async () => { // google_place_id/google_ftid/osm_id are stored on every collection_places row // but were never read back for dedup, so a renamed place with no matching // name or coordinates could be saved again under its old provider id. const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Renames' }); - svc.savePlace(u.id, { + const col = await svc.createCollection(u.id, { name: 'Renames' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Original Name', lat: 1, @@ -214,7 +214,7 @@ describe('saved places + dedup', () => { google_place_id: 'ChIJ_abc', }); - const result = svc.savePlace(u.id, { + const result = await svc.savePlace(u.id, { collection_id: col.id, name: 'Renamed By User', lat: 2, @@ -226,34 +226,34 @@ describe('saved places + dedup', () => { expect(result.duplicateOf?.name).toBe('Original Name'); }); - it('COLLECTIONS-SVC-102: the bulk import recognises a renamed place by its provider id too', () => { + it('COLLECTIONS-SVC-102: the bulk import recognises a renamed place by its provider id too', async () => { // savePlace was not the only caller. The bulk copy carries the provider ids // into the row it writes, so asking without them would recognise less than // the row it just wrote already knows. const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Rome' }); + const col = await svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); - svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); - const out = svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]); + const out = await svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]); expect(out.copied).toBe(0); expect(out.skipped.map(s => s.name)).toEqual(['Trattoria da Enzo']); }); - it('COLLECTIONS-SVC-103: the import picker marks that same place as already saved', () => { + it('COLLECTIONS-SVC-103: the import picker marks that same place as already saved', async () => { // The dialog and the import have to agree: a row shown as new that the import // then refuses is the drift this method exists to prevent. const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Rome' }); + const col = await svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); - svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); - const listed = svc.importablePlaces(u.id, col.id, trip.id).places.find(p => p.place_id === place.id); + const listed = (await svc.importablePlaces(u.id, col.id, trip.id)).places.find(p => p.place_id === place.id); expect(listed?.already_in_list).toBe(true); }); @@ -262,53 +262,53 @@ describe('saved places + dedup', () => { // ── save-from-trip provenance + IDOR ───────────────────────────────────────── describe('saveFromTripPlace', () => { - it('COLLECTIONS-SVC-014: records provenance from a readable trip', () => { + it('COLLECTIONS-SVC-014: records provenance from a readable trip', async () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); - const col = svc.createCollection(u.id, { name: 'From trip' }); + const col = await svc.createCollection(u.id, { name: 'From trip' }); - const res = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id); + const res = await svc.saveFromTripPlace(u.id, col.id, trip.id, place.id); expect(res.place!.source_trip_id).toBe(trip.id); expect(res.place!.source_place_id).toBe(place.id); expect(res.place!.name).toBe('Louvre'); }); - it('COLLECTIONS-SVC-015: rejects a trip the user cannot read (no IDOR)', () => { + it('COLLECTIONS-SVC-015: rejects a trip the user cannot read (no IDOR)', async () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id, { name: 'Secret' }); - const col = svc.createCollection(stranger.id, { name: 'Mine' }); + const col = await svc.createCollection(stranger.id, { name: 'Mine' }); - expect(() => svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id)).toThrow(); - try { svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } + await expect(svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id)).rejects.toThrow(); + try { await svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); }); // ── status + move ──────────────────────────────────────────────────────────── describe('status + updatePlace move', () => { - it('COLLECTIONS-SVC-016: setStatus cycles idea→want→visited', () => { + it('COLLECTIONS-SVC-016: setStatus cycles idea→want→visited', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'S' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Place' }).place!; + const col = await svc.createCollection(u.id, { name: 'S' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Place' })).place!; expect(p.status).toBe('idea'); - expect(svc.setStatus(u.id, p.id, 'want').status).toBe('want'); - expect(svc.setStatus(u.id, p.id, 'visited').status).toBe('visited'); + expect((await svc.setStatus(u.id, p.id, 'want')).status).toBe('want'); + expect((await svc.setStatus(u.id, p.id, 'visited')).status).toBe('visited'); }); it('COLLECTIONS-SVC-017: updatePlace moves to another list (asserts access on target, resets owner_id)', async () => { const owner = createUser(testDb).user; - const a = svc.createCollection(owner.id, { name: 'A' }); + const a = await svc.createCollection(owner.id, { name: 'A' }); const targetOwner = createUser(testDb).user; - const b = svc.createCollection(targetOwner.id, { name: 'B' }); + const b = await svc.createCollection(targetOwner.id, { name: 'B' }); // owner is also an accepted member of b so the move target is visible to them testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(b.id, owner.id); - const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' }).place!; + const p = (await svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' })).place!; const moved = await svc.updatePlace(owner.id, p.id, { collection_id: b.id }); expect(moved.collection_id).toBe(b.id); const row = testDb.prepare('SELECT owner_id FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number }; @@ -317,10 +317,10 @@ describe('status + updatePlace move', () => { it('COLLECTIONS-SVC-018: updatePlace move to an inaccessible target is rejected', async () => { const owner = createUser(testDb).user; - const a = svc.createCollection(owner.id, { name: 'A' }); + const a = await svc.createCollection(owner.id, { name: 'A' }); const stranger = createUser(testDb).user; - const b = svc.createCollection(stranger.id, { name: 'B' }); - const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'X' }).place!; + const b = await svc.createCollection(stranger.id, { name: 'B' }); + const p = (await svc.savePlace(owner.id, { collection_id: a.id, name: 'X' })).place!; await expect(svc.updatePlace(owner.id, p.id, { collection_id: b.id })).rejects.toThrow(); }); @@ -328,8 +328,8 @@ describe('status + updatePlace move', () => { // moved restaurant could only be fixed by deleting and re-adding the place. it('COLLECTIONS-SVC-019: updatePlace corrects the address and clears it with null', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Rome' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!; + const col = await svc.createCollection(u.id, { name: 'Rome' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' })).place!; expect((await svc.updatePlace(u.id, p.id, { address: 'Via Nuova 1' })).address).toBe('Via Nuova 1'); expect((await svc.updatePlace(u.id, p.id, { address: null })).address).toBeNull(); @@ -337,8 +337,8 @@ describe('status + updatePlace move', () => { it('COLLECTIONS-SVC-019b: an update without an address leaves the stored one alone', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Rome' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!; + const col = await svc.createCollection(u.id, { name: 'Rome' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' })).place!; expect((await svc.updatePlace(u.id, p.id, { name: 'Trattoria da Enzo' })).address).toBe('Via Vechia 1'); }); @@ -352,12 +352,12 @@ describe('copyToTrip', () => { createCategory(testDb); const trip = createTrip(testDb, u.id); const tag = createTag(testDb, u.id, { name: 'must-see' }); - const col = svc.createCollection(u.id, { name: 'Plan' }); - const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'Colosseum', tag_ids: [tag.id] }).place!; + const col = await svc.createCollection(u.id, { name: 'Plan' }); + const p1 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Colosseum', tag_ids: [tag.id] })).place!; // pre-existing trip place that should make a duplicate createPlace(testDb, trip.id, { name: 'Pantheon' }); - const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' }).place!; + const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' })).place!; const res = await svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); expect(res.copied).toBe(1); @@ -374,8 +374,8 @@ describe('copyToTrip', () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); - const hidden = svc.createCollection(owner.id, { name: 'Hidden' }); - const p = svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' }).place!; + const hidden = await svc.createCollection(owner.id, { name: 'Hidden' }); + const p = (await svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' })).place!; const trip = createTrip(testDb, stranger.id); await expect(svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); @@ -386,8 +386,8 @@ describe('copyToTrip', () => { const owner2 = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner2.id); // u has no access - const col = svc.createCollection(u.id, { name: 'C' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'X' }).place!; + const col = await svc.createCollection(u.id, { name: 'C' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'X' })).place!; await expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); }); @@ -397,8 +397,8 @@ describe('copyToTrip', () => { createCategory(testDb); const trip = createTrip(testDb, owner2.id); addTripMember(testDb, trip.id, member.id); - const col = svc.createCollection(member.id, { name: 'C' }); - const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' }).place!; + const col = await svc.createCollection(member.id, { name: 'C' }); + const p = (await svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' })).place!; const res = await svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); expect(res.copied).toBe(1); }); @@ -409,9 +409,9 @@ describe('copyToTrip', () => { describe('delete places', () => { it('COLLECTIONS-SVC-024: deletePlace + deletePlacesMany assert access', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'D' }); - const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!; - const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!; + const col = await svc.createCollection(u.id, { name: 'D' }); + const p1 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'A' })).place!; + const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'B' })).place!; await svc.deletePlace(u.id, p1.id); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 1 }); expect(await svc.deletePlacesMany(u.id, [p2.id])).toEqual([p2.id]); @@ -422,129 +422,129 @@ describe('delete places', () => { // ── Fusion state machine ───────────────────────────────────────────────────── describe('fusion invitations', () => { - function setup() { + async function setup() { const owner = createUser(testDb).user; const target = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Fusion' }); + const col = await svc.createCollection(owner.id, { name: 'Fusion' }); return { owner, target, col }; } it('COLLECTIONS-SVC-030: sendInvite — self 400, unknown 404, non-owner 403, happy path', async () => { - const { owner, target, col } = setup(); - expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, owner.id).status).toBe(400); - expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, 99999).status).toBe(404); - expect(svc.sendInvite(col.id, target.id, target.username, target.email, owner.id).status).toBe(403); // non-owner inviter + const { owner, target, col } = await setup(); + expect((await svc.sendInvite(col.id, owner.id, owner.username, owner.email, owner.id)).status).toBe(400); + expect((await svc.sendInvite(col.id, owner.id, owner.username, owner.email, 99999)).status).toBe(404); + expect((await svc.sendInvite(col.id, target.id, target.username, target.email, owner.id)).status).toBe(403); // non-owner inviter - const ok = svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + const ok = await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(ok.error).toBeUndefined(); expect(broadcastToUser).toHaveBeenCalledWith(target.id, expect.objectContaining({ type: 'collections:invite' })); // the notification send is fire-and-forget via a dynamic import — flush microtasks. await vi.waitFor(() => expect(notifSend).toHaveBeenCalledWith(expect.objectContaining({ event: 'collection_invite', targetId: target.id }))); }); - it('COLLECTIONS-SVC-031: double-invite while pending → 400; existing member → 400', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).status).toBe(400); - svc.acceptInvite(target.id, col.id, undefined); - expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).error).toBe('Already a member'); + it('COLLECTIONS-SVC-031: double-invite while pending → 400; existing member → 400', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect((await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id)).status).toBe(400); + await svc.acceptInvite(target.id, col.id, undefined); + expect((await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id)).error).toBe('Already a member'); }); - it('COLLECTIONS-SVC-032: acceptInvite — 404 with no pending; flips to accepted → member now sees list', () => { - const { owner, target, col } = setup(); - expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - expect(svc.acceptInvite(target.id, col.id, undefined).error).toBeUndefined(); - expect(svc.listCollections(target.id).collections.map((c) => c.id)).toContain(col.id); + it('COLLECTIONS-SVC-032: acceptInvite — 404 with no pending; flips to accepted → member now sees list', async () => { + const { owner, target, col } = await setup(); + expect((await svc.acceptInvite(target.id, col.id, undefined)).status).toBe(404); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect((await svc.acceptInvite(target.id, col.id, undefined)).error).toBeUndefined(); + expect((await svc.listCollections(target.id)).collections.map((c) => c.id)).toContain(col.id); }); - it('COLLECTIONS-SVC-033: accept-after-cancel → 404 (no orphan accept)', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - svc.cancelInvite(col.id, owner.id, target.id); - expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); + it('COLLECTIONS-SVC-033: accept-after-cancel → 404 (no orphan accept)', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await svc.cancelInvite(col.id, owner.id, target.id); + expect((await svc.acceptInvite(target.id, col.id, undefined)).status).toBe(404); }); - it('COLLECTIONS-SVC-034: declineInvite removes the pending row', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - svc.declineInvite(target.id, col.id, undefined); + it('COLLECTIONS-SVC-034: declineInvite removes the pending row', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await svc.declineInvite(target.id, col.id, undefined); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); - it('COLLECTIONS-SVC-035: cancelInvite is owner-only', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - expect(() => svc.cancelInvite(col.id, target.id, target.id)).toThrow(); // non-owner - svc.cancelInvite(col.id, owner.id, target.id); // owner ok + it('COLLECTIONS-SVC-035: cancelInvite is owner-only', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await expect(svc.cancelInvite(col.id, target.id, target.id)).rejects.toThrow(); // non-owner + await svc.cancelInvite(col.id, owner.id, target.id); // owner ok expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); - it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - svc.acceptInvite(target.id, col.id, undefined); - svc.leaveCollection(target.id, col.id, undefined); - expect(svc.listCollections(target.id).collections.map((c) => c.id)).not.toContain(col.id); + it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await svc.acceptInvite(target.id, col.id, undefined); + await svc.leaveCollection(target.id, col.id, undefined); + expect((await svc.listCollections(target.id)).collections.map((c) => c.id)).not.toContain(col.id); - expect(() => svc.leaveCollection(owner.id, col.id, undefined)).toThrow(); - try { svc.leaveCollection(owner.id, col.id, undefined); } catch (e) { expect((e as { status: number }).status).toBe(400); } + await expect(svc.leaveCollection(owner.id, col.id, undefined)).rejects.toThrow(); + try { await svc.leaveCollection(owner.id, col.id, undefined); } catch (e) { expect((e as { status: number }).status).toBe(400); } }); - it('COLLECTIONS-SVC-037: availableUsers is scoped to THIS collection only (no one-fusion bug)', () => { + it('COLLECTIONS-SVC-037: availableUsers is scoped to THIS collection only (no one-fusion bug)', async () => { const owner = createUser(testDb).user; const target = createUser(testDb).user; - const colA = svc.createCollection(owner.id, { name: 'A' }); - const colB = svc.createCollection(owner.id, { name: 'B' }); + const colA = await svc.createCollection(owner.id, { name: 'A' }); + const colB = await svc.createCollection(owner.id, { name: 'B' }); // target is accepted in A; must still be invitable to B - svc.sendInvite(colA.id, owner.id, owner.username, owner.email, target.id); - svc.acceptInvite(target.id, colA.id, undefined); + await svc.sendInvite(colA.id, owner.id, owner.username, owner.email, target.id); + await svc.acceptInvite(target.id, colA.id, undefined); - const forB = svc.availableUsers(owner.id, colB.id).map((u) => u.id); + const forB = (await svc.availableUsers(owner.id, colB.id)).map((u) => u.id); expect(forB).toContain(target.id); - const forA = svc.availableUsers(owner.id, colA.id).map((u) => u.id); + const forA = (await svc.availableUsers(owner.id, colA.id)).map((u) => u.id); expect(forA).not.toContain(target.id); // already a member of A }); - it('COLLECTIONS-SVC-038: availableUsers excludes self + guests', () => { + it('COLLECTIONS-SVC-038: availableUsers excludes self + guests', async () => { const owner = createUser(testDb).user; const normal = createUser(testDb).user; const guest = createUser(testDb).user; testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); - const col = svc.createCollection(owner.id, { name: 'C' }); - const ids = svc.availableUsers(owner.id, col.id).map((u) => u.id); + const col = await svc.createCollection(owner.id, { name: 'C' }); + const ids = (await svc.availableUsers(owner.id, col.id)).map((u) => u.id); expect(ids).toContain(normal.id); expect(ids).not.toContain(owner.id); expect(ids).not.toContain(guest.id); }); - it('COLLECTIONS-SVC-039: visibility = owner OR accepted member (pending does NOT grant access)', () => { - const { owner, target, col } = setup(); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); - expect(() => svc.getCollection(target.id, col.id)).toThrow(); // pending, no access yet - svc.acceptInvite(target.id, col.id, undefined); - expect(svc.getCollection(target.id, col.id).collection.id).toBe(col.id); + it('COLLECTIONS-SVC-039: visibility = owner OR accepted member (pending does NOT grant access)', async () => { + const { owner, target, col } = await setup(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await expect(svc.getCollection(target.id, col.id)).rejects.toThrow(); // pending, no access yet + await svc.acceptInvite(target.id, col.id, undefined); + expect((await svc.getCollection(target.id, col.id)).collection.id).toBe(col.id); }); }); // ── deleteCollection snapshot + broadcast + cascade ────────────────────────── describe('deleteCollection', () => { - it('COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted, cascades', () => { + it('COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted, cascades', async () => { const owner = createUser(testDb).user; const accepted = createUser(testDb).user; const pending = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Doomed' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'P' }); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, accepted.id); - svc.acceptInvite(accepted.id, col.id, undefined); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id); + const col = await svc.createCollection(owner.id, { name: 'Doomed' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'P' }); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, accepted.id); + await svc.acceptInvite(accepted.id, col.id, undefined); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id); // a non-owner member cannot delete - expect(() => svc.deleteCollection(accepted.id, col.id)).toThrow(); + await expect(svc.deleteCollection(accepted.id, col.id)).rejects.toThrow(); broadcastToUser.mockClear(); - svc.deleteCollection(owner.id, col.id); + await svc.deleteCollection(owner.id, col.id); const targets = broadcastToUser.mock.calls.map((c) => c[0]); expect(targets).toEqual(expect.arrayContaining([accepted.id, pending.id])); @@ -560,12 +560,12 @@ describe('deleteCollection', () => { // ── owner_id semantics: member account deletion keeps shared content ───────── describe('owner_id semantics', () => { - it('COLLECTIONS-SVC-041: deleting a MEMBER account nulls saved_by but keeps the place', () => { + it('COLLECTIONS-SVC-041: deleting a MEMBER account nulls saved_by but keeps the place', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared' }); + const col = await svc.createCollection(owner.id, { name: 'Shared' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); - const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' }).place!; + const p = (await svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' })).place!; testDb.prepare('DELETE FROM users WHERE id = ?').run(member.id); @@ -581,10 +581,10 @@ describe('owner_id semantics', () => { describe('photo-cache widening', () => { it('COLLECTIONS-SVC-042: a collection_places row keeps a photo no places row references', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Photos' }); + const col = await svc.createCollection(u.id, { name: 'Photos' }); // cache meta for place_id 'gp-x', referenced ONLY by a collection_places row. testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-x', null, Date.now()); - svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' }); await removeIfUnreferenced('gp-x'); // would evict if isReferenced ignored collection_places @@ -607,81 +607,81 @@ function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'a } describe('collection labels', () => { - it('COLLECTIONS-SVC-050: createLabel is returned by getCollection; duplicate name is 409', () => { + it('COLLECTIONS-SVC-050: createLabel is returned by getCollection; duplicate name is 409', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Germany' }); - const label = svc.createLabel(u.id, col.id, 'Berlin', '#ff0000'); + const col = await svc.createCollection(u.id, { name: 'Germany' }); + const label = await svc.createLabel(u.id, col.id, 'Berlin', '#ff0000'); expect(label.name).toBe('Berlin'); expect(label.collection_id).toBe(col.id); - expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(1); + expect((await svc.getCollection(u.id, col.id)).collection.labels).toHaveLength(1); - expect(() => svc.createLabel(u.id, col.id, 'berlin')).toThrow(); // case-insensitive dup - try { svc.createLabel(u.id, col.id, 'berlin'); } catch (e) { expect((e as { status: number }).status).toBe(409); } + await expect(svc.createLabel(u.id, col.id, 'berlin')).rejects.toThrow(); // case-insensitive dup + try { await svc.createLabel(u.id, col.id, 'berlin'); } catch (e) { expect((e as { status: number }).status).toBe(409); } }); - it('COLLECTIONS-SVC-051: a viewer cannot manage labels (403); an editor can', () => { + it('COLLECTIONS-SVC-051: a viewer cannot manage labels (403); an editor can', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Trip' }); + const col = await svc.createCollection(owner.id, { name: 'Trip' }); addMember(col.id, viewer.id, 'viewer'); - expect(() => svc.createLabel(viewer.id, col.id, 'X')).toThrow(expect.objectContaining({ status: 403, message: 'You have read-only access to this list' })); - expect(svc.getCollection(owner.id, col.id).collection.labels).toHaveLength(0); + await expect(svc.createLabel(viewer.id, col.id, 'X')).rejects.toThrow(expect.objectContaining({ status: 403, message: 'You have read-only access to this list' })); + expect((await svc.getCollection(owner.id, col.id)).collection.labels).toHaveLength(0); const editor = createUser(testDb).user; addMember(col.id, editor.id, 'editor'); - expect(svc.createLabel(editor.id, col.id, 'Museums').id).toBeGreaterThan(0); + expect((await svc.createLabel(editor.id, col.id, 'Museums')).id).toBeGreaterThan(0); }); it('COLLECTIONS-SVC-052: updatePlace label_ids sets labels; a label from another list is ignored', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'DE' }); - const other = svc.createCollection(u.id, { name: 'Other' }); - const l1 = svc.createLabel(u.id, col.id, 'Berlin'); - const foreign = svc.createLabel(u.id, other.id, 'Paris'); - const place = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!; + const col = await svc.createCollection(u.id, { name: 'DE' }); + const other = await svc.createCollection(u.id, { name: 'Other' }); + const l1 = await svc.createLabel(u.id, col.id, 'Berlin'); + const foreign = await svc.createLabel(u.id, other.id, 'Paris'); + const place = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' })).place!; await svc.updatePlace(u.id, place.id, { label_ids: [l1.id, foreign.id] }); - const stored = svc.getCollection(u.id, col.id).places.find(p => p.id === place.id)!; + const stored = (await svc.getCollection(u.id, col.id)).places.find(p => p.id === place.id)!; expect(stored.label_ids).toEqual([l1.id]); }); - it('COLLECTIONS-SVC-053: assignLabels bulk-adds then unassigns across places', () => { + it('COLLECTIONS-SVC-053: assignLabels bulk-adds then unassigns across places', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'DE' }); - const l = svc.createLabel(u.id, col.id, 'Coast'); - const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!; - const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!; + const col = await svc.createCollection(u.id, { name: 'DE' }); + const l = await svc.createLabel(u.id, col.id, 'Coast'); + const p1 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'A' })).place!; + const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'B' })).place!; - expect(svc.assignLabels(u.id, [l.id], [p1.id, p2.id], false).changed).toBe(2); - expect(svc.getCollection(u.id, col.id).places.every(p => p.label_ids?.includes(l.id))).toBe(true); + expect((await svc.assignLabels(u.id, [l.id], [p1.id, p2.id], false)).changed).toBe(2); + expect((await svc.getCollection(u.id, col.id)).places.every(p => p.label_ids?.includes(l.id))).toBe(true); - svc.assignLabels(u.id, [l.id], [p1.id], true); - const after = svc.getCollection(u.id, col.id).places; + await svc.assignLabels(u.id, [l.id], [p1.id], true); + const after = (await svc.getCollection(u.id, col.id)).places; expect(after.find(p => p.id === p1.id)!.label_ids).toEqual([]); expect(after.find(p => p.id === p2.id)!.label_ids).toEqual([l.id]); }); it('COLLECTIONS-SVC-054: deleteLabel removes it and cascades its place assignments', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'DE' }); - const l = svc.createLabel(u.id, col.id, 'Berlin'); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!; + const col = await svc.createCollection(u.id, { name: 'DE' }); + const l = await svc.createLabel(u.id, col.id, 'Berlin'); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' })).place!; await svc.updatePlace(u.id, p.id, { label_ids: [l.id] }); - svc.deleteLabel(u.id, l.id); - expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(0); - expect(svc.getCollection(u.id, col.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]); + await svc.deleteLabel(u.id, l.id); + expect((await svc.getCollection(u.id, col.id)).collection.labels).toHaveLength(0); + expect((await svc.getCollection(u.id, col.id)).places.find(x => x.id === p.id)!.label_ids).toEqual([]); }); it('COLLECTIONS-SVC-055: moving a place to another list drops its labels', async () => { const u = createUser(testDb).user; - const a = svc.createCollection(u.id, { name: 'A' }); - const b = svc.createCollection(u.id, { name: 'B' }); - const l = svc.createLabel(u.id, a.id, 'Berlin'); - const p = svc.savePlace(u.id, { collection_id: a.id, name: 'Gate' }).place!; + const a = await svc.createCollection(u.id, { name: 'A' }); + const b = await svc.createCollection(u.id, { name: 'B' }); + const l = await svc.createLabel(u.id, a.id, 'Berlin'); + const p = (await svc.savePlace(u.id, { collection_id: a.id, name: 'Gate' })).place!; await svc.updatePlace(u.id, p.id, { label_ids: [l.id] }); await svc.updatePlace(u.id, p.id, { collection_id: b.id }); - expect(svc.getCollection(u.id, b.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]); + expect((await svc.getCollection(u.id, b.id)).places.find(x => x.id === p.id)!.label_ids).toEqual([]); }); }); @@ -696,16 +696,16 @@ describe('custom saved-place image', () => { it('COLLECTIONS-SVC-060: updatePlace sets image_url', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Photos' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; + const col = await svc.createCollection(u.id, { name: 'Photos' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' })).place!; const updated = await svc.updatePlace(u.id, p.id, { image_url: '/uploads/places/col-set.jpg' }); expect(updated.image_url).toBe('/uploads/places/col-set.jpg'); }); it('COLLECTIONS-SVC-061: setPlaceImage stores the url and reclaims a replaced upload', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Photos' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; + const col = await svc.createCollection(u.id, { name: 'Photos' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' })).place!; const fileA = writePlaceImage('col-replace-a.jpg'); await svc.setPlaceImage(u.id, p.id, '/uploads/places/col-replace-a.jpg'); expect(fs.existsSync(fileA)).toBe(true); @@ -717,8 +717,8 @@ describe('custom saved-place image', () => { it('COLLECTIONS-SVC-062: deletePlace reclaims the uploaded image when unreferenced', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Photos' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic', image_url: '/uploads/places/col-delete.jpg' }).place!; + const col = await svc.createCollection(u.id, { name: 'Photos' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Pic', image_url: '/uploads/places/col-delete.jpg' })).place!; const fileA = writePlaceImage('col-delete.jpg'); expect(fs.existsSync(fileA)).toBe(true); @@ -730,52 +730,52 @@ describe('custom saved-place image', () => { // ── Collaborative ratings (#1435) ──────────────────────────────────────────── describe('collaborative ratings (#1435)', () => { - it('COLLECTIONS-SVC-070: setRating stores a vote, updates it, and clears with null', () => { + it('COLLECTIONS-SVC-070: setRating stores a vote, updates it, and clears with null', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Rate' }); - const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Louvre' }).place!; + const col = await svc.createCollection(u.id, { name: 'Rate' }); + const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Louvre' })).place!; - let updated = svc.setRating(u.id, p.id, 5); + let updated = await svc.setRating(u.id, p.id, 5); expect(updated.rating_avg).toBe(5); expect(updated.rating_count).toBe(1); expect(updated.ratings?.find(r => r.user_id === u.id)?.rating).toBe(5); - updated = svc.setRating(u.id, p.id, 3); // same user re-votes → replaces, not appends + updated = await svc.setRating(u.id, p.id, 3); // same user re-votes → replaces, not appends expect(updated.rating_avg).toBe(3); expect(updated.rating_count).toBe(1); - updated = svc.setRating(u.id, p.id, null); // clear + updated = await svc.setRating(u.id, p.id, null); // clear expect(updated.rating_avg).toBeNull(); expect(updated.rating_count).toBe(0); }); - it('COLLECTIONS-SVC-071: every accepted member may vote; the value is the average', () => { + it('COLLECTIONS-SVC-071: every accepted member may vote; the value is the average', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared rate' }); + const col = await svc.createCollection(owner.id, { name: 'Shared rate' }); // A viewer (read-only) member — still allowed to cast a personal vote. testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id); - const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' }).place!; + const p = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' })).place!; - svc.setRating(owner.id, p.id, 5); - const updated = svc.setRating(member.id, p.id, 2); + await svc.setRating(owner.id, p.id, 5); + const updated = await svc.setRating(member.id, p.id, 2); expect(updated.rating_count).toBe(2); expect(updated.rating_avg).toBe(3.5); }); - it('COLLECTIONS-SVC-072: a non-member cannot rate', () => { + it('COLLECTIONS-SVC-072: a non-member cannot rate', async () => { const owner = createUser(testDb).user; const outsider = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Private rate' }); - const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Secret' }).place!; - expect(() => svc.setRating(outsider.id, p.id, 4)).toThrow(); + const col = await svc.createCollection(owner.id, { name: 'Private rate' }); + const p = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Secret' })).place!; + await expect(svc.setRating(outsider.id, p.id, 4)).rejects.toThrow(); }); - it('COLLECTIONS-SVC-073: saving a trip place copies only the saver + shared-member votes', () => { + it('COLLECTIONS-SVC-073: saving a trip place copies only the saver + shared-member votes', async () => { const owner = createUser(testDb).user; const shared = createUser(testDb).user; // member of BOTH the trip and the collection const tripOnly = createUser(testDb).user; // member of the trip only - const col = svc.createCollection(owner.id, { name: 'From trip rated' }); + const col = await svc.createCollection(owner.id, { name: 'From trip rated' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id); const trip = createTrip(testDb, owner.id); @@ -787,9 +787,9 @@ describe('collaborative ratings (#1435)', () => { ins.run(place.id, shared.id, 4); ins.run(place.id, tripOnly.id, 1); - const saved = svc.savePlace(owner.id, { + const saved = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id, - }).place!; + })).place!; const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[]; const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b); @@ -800,11 +800,11 @@ describe('collaborative ratings (#1435)', () => { it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Copyable' }); + const col = await svc.createCollection(owner.id, { name: 'Copyable' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id); - const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' }).place!; - svc.setRating(owner.id, cp.id, 5); - svc.setRating(member.id, cp.id, 3); + const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' })).place!; + await svc.setRating(owner.id, cp.id, 5); + await svc.setRating(member.id, cp.id, 3); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries @@ -818,10 +818,10 @@ describe('collaborative ratings (#1435)', () => { expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3); }); - it('COLLECTIONS-SVC-075: savePlace does NOT harvest ratings from a source place the caller cannot access', () => { + it('COLLECTIONS-SVC-075: savePlace does NOT harvest ratings from a source place the caller cannot access', async () => { const attacker = createUser(testDb).user; const victim = createUser(testDb).user; - const col = svc.createCollection(attacker.id, { name: 'Harvest attempt' }); + const col = await svc.createCollection(attacker.id, { name: 'Harvest attempt' }); // The victim is a member of the attacker's collection (so they'd be "eligible"). testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id); // A PRIVATE trip the attacker is not on, with the victim's vote on a place. @@ -829,9 +829,9 @@ describe('collaborative ratings (#1435)', () => { const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' }); testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5); - const saved = svc.savePlace(attacker.id, { + const saved = (await svc.savePlace(attacker.id, { collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id, - }).place!; + })).place!; const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id); expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied @@ -841,14 +841,14 @@ describe('collaborative ratings (#1435)', () => { const owner = createUser(testDb).user; const inTrip = createUser(testDb).user; // collection member AND trip member const notInTrip = createUser(testDb).user; // collection member only - const col = svc.createCollection(owner.id, { name: 'Mixed membership' }); + const col = await svc.createCollection(owner.id, { name: 'Mixed membership' }); for (const u of [inTrip, notInTrip]) { testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id); } - const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' }).place!; - svc.setRating(owner.id, cp.id, 5); - svc.setRating(inTrip.id, cp.id, 4); - svc.setRating(notInTrip.id, cp.id, 1); + const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' })).place!; + await svc.setRating(owner.id, cp.id, 5); + await svc.setRating(inTrip.id, cp.id, 4); + await svc.setRating(notInTrip.id, cp.id, 1); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, inTrip.id); @@ -865,33 +865,33 @@ describe('collaborative ratings (#1435)', () => { // ── Membership lookups ─────────────────────────────────────────────────────── describe('membership lookups', () => { - it('COLLECTIONS-SVC-080: findMembership matches by google id and coords, never by bare name', () => { + it('COLLECTIONS-SVC-080: findMembership matches by google id and coords, never by bare name', async () => { const u = createUser(testDb).user; - const col = svc.createCollection(u.id, { name: 'Lookup' }); - svc.savePlace(u.id, { collection_id: col.id, name: 'Starbucks', lat: 48.8584, lng: 2.2945, google_place_id: 'gp-1' }); + const col = await svc.createCollection(u.id, { name: 'Lookup' }); + await svc.savePlace(u.id, { collection_id: col.id, name: 'Starbucks', lat: 48.8584, lng: 2.2945, google_place_id: 'gp-1' }); - expect(svc.findMembership(u.id, { google_place_id: 'gp-1' }).saved).toBe(true); - expect(svc.findMembership(u.id, { lat: 48.8584, lng: 2.2945 }).saved).toBe(true); + expect((await svc.findMembership(u.id, { google_place_id: 'gp-1' })).saved).toBe(true); + expect((await svc.findMembership(u.id, { lat: 48.8584, lng: 2.2945 })).saved).toBe(true); // A bare name is deliberately NOT a condition on its own — no false positives. - expect(svc.findMembership(u.id, { name: 'Starbucks' })).toEqual({ saved: false, lists: [] }); + expect(await svc.findMembership(u.id, { name: 'Starbucks' })).toEqual({ saved: false, lists: [] }); // No lists at all short-circuits. const other = createUser(testDb).user; - expect(svc.findMembership(other.id, { google_place_id: 'gp-1' })).toEqual({ saved: false, lists: [] }); + expect(await svc.findMembership(other.id, { google_place_id: 'gp-1' })).toEqual({ saved: false, lists: [] }); }); - it('COLLECTIONS-SVC-081: findMembershipForUser reports owner / accepted / pending / none', () => { + it('COLLECTIONS-SVC-081: findMembershipForUser reports owner / accepted / pending / none', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const outsider = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'M' }); + const col = await svc.createCollection(owner.id, { name: 'M' }); - expect(svc.findMembershipForUser(owner.id, col.id)).toEqual({ is_member: true, is_owner: true, status: 'accepted' }); - expect(svc.findMembershipForUser(outsider.id, col.id)).toEqual({ is_member: false, is_owner: false, status: null }); + expect(await svc.findMembershipForUser(owner.id, col.id)).toEqual({ is_member: true, is_owner: true, status: 'accepted' }); + expect(await svc.findMembershipForUser(outsider.id, col.id)).toEqual({ is_member: false, is_owner: false, status: null }); - svc.sendInvite(col.id, owner.id, owner.username, owner.email, member.id); - expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: false, is_owner: false, status: 'pending' }); - svc.acceptInvite(member.id, col.id, undefined); - expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: true, is_owner: false, status: 'accepted' }); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, member.id); + expect(await svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: false, is_owner: false, status: 'pending' }); + await svc.acceptInvite(member.id, col.id, undefined); + expect(await svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: true, is_owner: false, status: 'accepted' }); }); }); @@ -901,12 +901,12 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { it('COLLECTIONS-SVC-090: deletePlacesMany is all-or-nothing — a mid-list 403 deletes nothing', async () => { const u = createUser(testDb).user; const otherOwner = createUser(testDb).user; - const mine = svc.createCollection(u.id, { name: 'Mine' }); - const shared = svc.createCollection(otherOwner.id, { name: 'Shared' }); + const mine = await svc.createCollection(u.id, { name: 'Mine' }); + const shared = await svc.createCollection(otherOwner.id, { name: 'Shared' }); // u is an editor on the shared list — can add/edit but NOT delete (owner/admin only). testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(shared.id, u.id); - const p1 = svc.savePlace(u.id, { collection_id: mine.id, name: 'Deletable' }).place!; - const p2 = svc.savePlace(u.id, { collection_id: shared.id, name: 'Protected' }).place!; + const p1 = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'Deletable' })).place!; + const p2 = (await svc.savePlace(u.id, { collection_id: shared.id, name: 'Protected' })).place!; // The relocated legacy interleaved checks with deletes, so p1 was gone by // the time p2's 403 fired. Now every id is checked first: nothing deleted. @@ -915,38 +915,38 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p2.id)).toEqual({ n: 1 }); }); - it('COLLECTIONS-SVC-091: assignLabels permission-checks every list before writing anything', () => { + it('COLLECTIONS-SVC-091: assignLabels permission-checks every list before writing anything', async () => { const u = createUser(testDb).user; const otherOwner = createUser(testDb).user; - const mine = svc.createCollection(u.id, { name: 'Mine' }); - const readonly = svc.createCollection(otherOwner.id, { name: 'ReadOnly' }); + const mine = await svc.createCollection(u.id, { name: 'Mine' }); + const readonly = await svc.createCollection(otherOwner.id, { name: 'ReadOnly' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(readonly.id, u.id); - const label = svc.createLabel(u.id, mine.id, 'Coast'); - const pa = svc.savePlace(u.id, { collection_id: mine.id, name: 'A' }).place!; + const label = await svc.createLabel(u.id, mine.id, 'Coast'); + const pa = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'A' })).place!; const pb = testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, saved_by, name) VALUES (?, ?, ?, ?)') .run(readonly.id, otherOwner.id, otherOwner.id, 'B').lastInsertRowid as number; // The relocated legacy checked per list inside the write loop, so `mine` // was labeled before `readonly`'s 403 fired. Now all lists check first. - expect(() => svc.assignLabels(u.id, [label.id], [pa.id, Number(pb)], false)).toThrow('You have read-only access to this list'); + await expect(svc.assignLabels(u.id, [label.id], [pa.id, Number(pb)], false)).rejects.toThrow('You have read-only access to this list'); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_place_labels WHERE collection_place_id = ?').get(pa.id)).toEqual({ n: 0 }); }); - it('COLLECTIONS-SVC-092: from-trip saves forward the socket id so the origin client does not echo', () => { + it('COLLECTIONS-SVC-092: from-trip saves forward the socket id so the origin client does not echo', async () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); // Distinct coords — the factory default would coord-dedup against Louvre. const place2 = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }); - const col = svc.createCollection(u.id, { name: 'From trip' }); + const col = await svc.createCollection(u.id, { name: 'From trip' }); broadcastToUser.mockClear(); - svc.saveFromTripPlace(u.id, col.id, trip.id, place.id, undefined, 'sock-1'); + await svc.saveFromTripPlace(u.id, col.id, trip.id, place.id, undefined, 'sock-1'); expect(broadcastToUser).toHaveBeenCalledWith(u.id, expect.objectContaining({ type: 'collections:updated' }), 'sock-1'); broadcastToUser.mockClear(); - svc.saveFromTripPlaces(u.id, col.id, trip.id, [place2.id], undefined, 'sock-2'); + await svc.saveFromTripPlaces(u.id, col.id, trip.id, [place2.id], undefined, 'sock-2'); expect(broadcastToUser).toHaveBeenCalledWith(u.id, expect.objectContaining({ type: 'collections:updated' }), 'sock-2'); }); }); @@ -959,46 +959,46 @@ function statusOf(placeId: number): string { } describe('bulk status', () => { - it('COLLECTIONS-SVC-093: setStatusMany writes one status across lists and counts only real changes', () => { + it('COLLECTIONS-SVC-093: setStatusMany writes one status across lists and counts only real changes', async () => { const u = createUser(testDb).user; - const a = svc.createCollection(u.id, { name: 'A' }); - const b = svc.createCollection(u.id, { name: 'B' }); - const pa = svc.savePlace(u.id, { collection_id: a.id, name: 'Louvre' }).place!; - const pb = svc.savePlace(u.id, { collection_id: b.id, name: 'Louvre' }).place!; - svc.setStatus(u.id, pb.id, 'visited'); + const a = await svc.createCollection(u.id, { name: 'A' }); + const b = await svc.createCollection(u.id, { name: 'B' }); + const pa = (await svc.savePlace(u.id, { collection_id: a.id, name: 'Louvre' })).place!; + const pb = (await svc.savePlace(u.id, { collection_id: b.id, name: 'Louvre' })).place!; + await svc.setStatus(u.id, pb.id, 'visited'); // pb is already visited, so only pa is a change. - expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 }); + expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 }); expect(statusOf(pa.id)).toBe('visited'); - expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 }); + expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 }); }); - it('COLLECTIONS-SVC-094: setStatusMany is all-or-nothing — a read-only list stops the batch', () => { + it('COLLECTIONS-SVC-094: setStatusMany is all-or-nothing — a read-only list stops the batch', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; - const mine = svc.createCollection(viewer.id, { name: 'Mine' }); - const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + const mine = await svc.createCollection(viewer.id, { name: 'Mine' }); + const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); - const p1 = svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' }).place!; - const p2 = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' }).place!; + const p1 = (await svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' })).place!; + const p2 = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' })).place!; - expect(() => svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).toThrow('You have read-only access to this list'); + await expect(svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).rejects.toThrow('You have read-only access to this list'); expect(statusOf(p1.id)).toBe('idea'); }); - it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', () => { + it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', async () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const louvre = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); const orsay = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }); - const a = svc.createCollection(u.id, { name: 'Paris' }); - const b = svc.createCollection(u.id, { name: 'Museums' }); - svc.saveFromTripPlace(u.id, a.id, trip.id, louvre.id); - svc.saveFromTripPlace(u.id, b.id, trip.id, louvre.id); - svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id); + const a = await svc.createCollection(u.id, { name: 'Paris' }); + const b = await svc.createCollection(u.id, { name: 'Museums' }); + await svc.saveFromTripPlace(u.id, a.id, trip.id, louvre.id); + await svc.saveFromTripPlace(u.id, b.id, trip.id, louvre.id); + await svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id); - expect(svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 }); + expect(await svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 }); const statuses = testDb.prepare('SELECT status FROM collection_places ORDER BY id').all() as { status: string }[]; expect(statuses.map(s => s.status)).toEqual(['visited', 'visited', 'idea']); }); @@ -1008,52 +1008,52 @@ describe('bulk status', () => { createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo', lat: 41.88, lng: 12.47 }); - const col = svc.createCollection(u.id, { name: 'Rome' }); - const saved = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id).place!; + const col = await svc.createCollection(u.id, { name: 'Rome' }); + const saved = (await svc.saveFromTripPlace(u.id, col.id, trip.id, place.id)).place!; // Renamed on both sides, and moved far enough that coordinates cannot match. await svc.updatePlace(u.id, saved.id, { name: 'Dinner spot', lat: 45, lng: 9 }); testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Enzo', place.id); - expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 }); + expect(await svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 }); }); - it('COLLECTIONS-SVC-097: a trip the caller cannot see is a 404, and unsaved places are a no-op', () => { + it('COLLECTIONS-SVC-097: a trip the caller cannot see is a 404, and unsaved places are a no-op', async () => { const u = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); - expect(() => svc.setStatusFromTrip(stranger.id, trip.id, [place.id], 'visited')).toThrow('Trip not found'); - expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); + await expect(svc.setStatusFromTrip(stranger.id, trip.id, [place.id], 'visited')).rejects.toThrow('Trip not found'); + expect(await svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); }); - it('COLLECTIONS-SVC-098: lists the caller may only read are skipped, not refused', () => { + it('COLLECTIONS-SVC-098: lists the caller may only read are skipped, not refused', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, viewer.id); const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); - const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); - const theirs = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 }).place!; + const theirs = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 })).place!; - expect(svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); + expect(await svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); expect(statusOf(theirs.id)).toBe('idea'); }); - it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', () => { + it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; - const readonly = svc.createCollection(owner.id, { name: 'Shared' }); + const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); - const saved = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' }).place!; - svc.setStatus(owner.id, saved.id, 'visited'); + const saved = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' })).place!; + await svc.setStatus(owner.id, saved.id, 'visited'); - expect(svc.findMembership(viewer.id, { google_place_id: 'gp-9' }).lists).toEqual([ + expect((await svc.findMembership(viewer.id, { google_place_id: 'gp-9' })).lists).toEqual([ { collection_id: readonly.id, name: 'Shared', place_id: saved.id, status: 'visited', can_edit: false }, ]); - expect(svc.findMembership(owner.id, { google_place_id: 'gp-9' }).lists[0].can_edit).toBe(true); + expect((await svc.findMembership(owner.id, { google_place_id: 'gp-9' })).lists[0].can_edit).toBe(true); }); }); @@ -1061,28 +1061,28 @@ describe('bulk status', () => { describe('exportCollection / importCollection (#2198)', () => { /** A list with two labels, a category and one place carrying both. */ - function seedList(ownerId: number) { + async function seedList(ownerId: number) { const cat = createCategory(testDb, { name: 'Restaurant' }); - const col = svc.createCollection(ownerId, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); - const must = svc.createLabel(ownerId, col.id, 'Must see', '#ff0000'); - const rain = svc.createLabel(ownerId, col.id, 'Rainy day', '#00ff00'); - const market = svc.savePlace(ownerId, { + const col = await svc.createCollection(ownerId, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); + const must = await svc.createLabel(ownerId, col.id, 'Must see', '#ff0000'); + const rain = await svc.createLabel(ownerId, col.id, 'Rainy day', '#00ff00'); + const market = (await svc.savePlace(ownerId, { collection_id: col.id, name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', category_id: cat.id, price: 12, currency: 'EUR', - }).place!; - const view = svc.savePlace(ownerId, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327 }).place!; - svc.assignLabels(ownerId, [must.id], [market.id], false); - svc.assignLabels(ownerId, [rain.id], [view.id], false); + })).place!; + const view = (await svc.savePlace(ownerId, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327 })).place!; + await svc.assignLabels(ownerId, [must.id], [market.id], false); + await svc.assignLabels(ownerId, [rain.id], [view.id], false); return { col, cat, must, rain, market, view }; } - it('COLLECTIONS-SVC-100: a file carries the list, its labels and each place with its label names', () => { + it('COLLECTIONS-SVC-100: a file carries the list, its labels and each place with its label names', async () => { const owner = createUser(testDb).user; - const { col, market } = seedList(owner.id); + const { col, market } = await seedList(owner.id); - const file = svc.exportCollection(owner.id, col.id); + const file = await svc.exportCollection(owner.id, col.id); expect(file.format).toBe('trek.collection'); expect(file.version).toBe(1); @@ -1102,14 +1102,14 @@ describe('exportCollection / importCollection (#2198)', () => { expect(market.id).toBeGreaterThan(0); }); - it('COLLECTIONS-SVC-101: a file carries no ids, no members and no ratings', () => { + it('COLLECTIONS-SVC-101: a file carries no ids, no members and no ratings', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const { col, market } = seedList(owner.id); + const { col, market } = await seedList(owner.id); addMember(col.id, member.id, 'editor'); - svc.setRating(member.id, market.id, 5); + await svc.setRating(member.id, market.id, 5); - const file = svc.exportCollection(owner.id, col.id); + const file = await svc.exportCollection(owner.id, col.id); const serialised = JSON.stringify(file); for (const forbidden of ['"id"', 'collection_id', 'owner_id', 'saved_by', 'source_trip_id', 'source_place_id', 'rating', 'user_id', 'category_id']) { @@ -1117,84 +1117,84 @@ describe('exportCollection / importCollection (#2198)', () => { } }); - it('COLLECTIONS-SVC-102: an instance-local image path does not leave the instance', () => { + it('COLLECTIONS-SVC-102: an instance-local image path does not leave the instance', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Pictures' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Uploaded', image_url: '/uploads/places/secret.jpg' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Proxied', image_url: '/api/maps/place-photo/abc' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Remote', image_url: 'https://example.com/photo.jpg' }); + const col = await svc.createCollection(owner.id, { name: 'Pictures' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Uploaded', image_url: '/uploads/places/secret.jpg' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Proxied', image_url: '/api/maps/place-photo/abc' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Remote', image_url: 'https://example.com/photo.jpg' }); - const images = svc.exportCollection(owner.id, col.id).places.map(p => (p as { image_url?: string | null }).image_url); + const images = (await svc.exportCollection(owner.id, col.id)).places.map(p => (p as { image_url?: string | null }).image_url); expect(images).toEqual([null, null, 'https://example.com/photo.jpg']); }); - it('COLLECTIONS-SVC-103: a member may export, a stranger may not', () => { + it('COLLECTIONS-SVC-103: a member may export, a stranger may not', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const stranger = createUser(testDb).user; - const { col } = seedList(owner.id); + const { col } = await seedList(owner.id); addMember(col.id, viewer.id, 'viewer'); - expect(svc.exportCollection(viewer.id, col.id).places).toHaveLength(2); + expect((await svc.exportCollection(viewer.id, col.id)).places).toHaveLength(2); try { - svc.exportCollection(stranger.id, col.id); + await svc.exportCollection(stranger.id, col.id); throw new Error('should have thrown'); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); - it('COLLECTIONS-SVC-104: a file round-trips into an equivalent list of the importer', () => { + it('COLLECTIONS-SVC-104: a file round-trips into an equivalent list of the importer', async () => { const owner = createUser(testDb).user; const other = createUser(testDb).user; - const { col } = seedList(owner.id); - const file = svc.exportCollection(owner.id, col.id); + const { col } = await seedList(owner.id); + const file = await svc.exportCollection(owner.id, col.id); - const result = svc.importCollection(other.id, { file }); + const result = await svc.importCollection(other.id, { file }); const created = result.collection as { id: number; owner_id: number; name: string }; expect(result).toMatchObject({ imported: 2, skipped: 0 }); expect(created.owner_id).toBe(other.id); expect(created.name).toBe('Lisbon'); // Re-exporting the copy gives the same file, bar the timestamp. - const again = svc.exportCollection(other.id, created.id); + const again = await svc.exportCollection(other.id, created.id); expect({ ...again, exported_at: undefined }).toEqual({ ...file, exported_at: undefined }); }); - it('COLLECTIONS-SVC-105: the importer may rename the list on the way in', () => { + it('COLLECTIONS-SVC-105: the importer may rename the list on the way in', async () => { const owner = createUser(testDb).user; - const { col } = seedList(owner.id); - const file = svc.exportCollection(owner.id, col.id); + const { col } = await seedList(owner.id); + const file = await svc.exportCollection(owner.id, col.id); - const created = svc.importCollection(owner.id, { file, name: 'Lisbon (from Ana)' }).collection as { id: number; name: string }; + const created = (await svc.importCollection(owner.id, { file, name: 'Lisbon (from Ana)' })).collection as { id: number; name: string }; expect(created.name).toBe('Lisbon (from Ana)'); // And the original is untouched. - expect(svc.getCollection(owner.id, col.id).collection.name).toBe('Lisbon'); + expect((await svc.getCollection(owner.id, col.id)).collection.name).toBe('Lisbon'); }); - it('COLLECTIONS-SVC-106: a category arrives by name, matched case-insensitively, unknown names drop', () => { + it('COLLECTIONS-SVC-106: a category arrives by name, matched case-insensitively, unknown names drop', async () => { const owner = createUser(testDb).user; createCategory(testDb, { name: 'Restaurant' }); - const created = svc.importCollection(owner.id, { file: { + const created = (await svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Categories', places: [ { name: 'Exact', category: 'Restaurant' }, { name: 'Sloppy', category: ' rEsTaUrAnT ' }, { name: 'Unknown', category: 'Not a category here' }, { name: 'None' }, ], - } }).collection as { id: number }; + } })).collection as { id: number }; - const places = svc.getCollection(owner.id, created.id).places; + const places = (await svc.getCollection(owner.id, created.id)).places; expect(places.map(p => p.category?.name ?? null)).toEqual(['Restaurant', 'Restaurant', null, null]); }); - it('COLLECTIONS-SVC-107: labels are recreated and only names the file defines are assigned', () => { + it('COLLECTIONS-SVC-107: labels are recreated and only names the file defines are assigned', async () => { const owner = createUser(testDb).user; - const created = svc.importCollection(owner.id, { file: { + const created = (await svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Labelled', labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Must see', color: '#00ff00' }], places: [ @@ -1202,19 +1202,19 @@ describe('exportCollection / importCollection (#2198)', () => { { name: 'Sloppy case', labels: [' MUST SEE '] }, { name: 'Ghost label', labels: ['Never defined'] }, ], - } }).collection as { id: number }; + } })).collection as { id: number }; - const detail = svc.getCollection(owner.id, created.id); + const detail = await svc.getCollection(owner.id, created.id); // The duplicate name collapses to one label. expect(detail.collection.labels).toHaveLength(1); const labelId = detail.collection.labels![0].id; expect(detail.places.map(p => p.label_ids)).toEqual([[labelId], [labelId], []]); }); - it('COLLECTIONS-SVC-108: a place the contract refuses is skipped, the rest still arrive', () => { + it('COLLECTIONS-SVC-108: a place the contract refuses is skipped, the rest still arrive', async () => { const owner = createUser(testDb).user; - const result = svc.importCollection(owner.id, { file: { + const result = await svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Mixed', places: [ { name: 'Good' }, { noName: true }, @@ -1226,22 +1226,22 @@ describe('exportCollection / importCollection (#2198)', () => { expect(result).toMatchObject({ imported: 2, skipped: 3 }); const created = result.collection as { id: number }; - expect(svc.getCollection(owner.id, created.id).places.map(p => p.name)).toEqual(['Good', 'Also good']); + expect((await svc.getCollection(owner.id, created.id)).places.map(p => p.name)).toEqual(['Good', 'Also good']); }); - it('COLLECTIONS-SVC-109: a file cannot hand the importer ids, provenance or other people', () => { + it('COLLECTIONS-SVC-109: a file cannot hand the importer ids, provenance or other people', async () => { const owner = createUser(testDb).user; const victim = createUser(testDb).user; const trip = createTrip(testDb, victim.id); const place = createPlace(testDb, trip.id, { name: 'Private place' }); - const created = svc.importCollection(owner.id, { file: { + const created = (await svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Hostile', places: [{ name: 'Claims things', id: 9999, collection_id: 1, owner_id: victim.id, saved_by: victim.id, source_trip_id: trip.id, source_place_id: place.id, } as never], - } }).collection as { id: number }; + } })).collection as { id: number }; const row = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(created.id) as Record; expect(row.owner_id).toBe(owner.id); @@ -1251,27 +1251,27 @@ describe('exportCollection / importCollection (#2198)', () => { expect(row.id).not.toBe(9999); }); - it('COLLECTIONS-SVC-110: a link the browser must not follow arrives as nothing, the place still does', () => { + it('COLLECTIONS-SVC-110: a link the browser must not follow arrives as nothing, the place still does', async () => { const owner = createUser(testDb).user; - const created = svc.importCollection(owner.id, { file: { + const created = (await svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Links', places: [ { name: 'Script', website: 'javascript:alert(1)' as never }, { name: 'Local image', image_url: '/uploads/x.jpg' as never }, { name: 'Fine', website: 'https://example.com', image_url: 'https://example.com/p.jpg' }, ], - } }).collection as { id: number }; + } })).collection as { id: number }; - const places = svc.getCollection(owner.id, created.id).places; + const places = (await svc.getCollection(owner.id, created.id)).places; expect(places.map(p => p.name)).toEqual(['Script', 'Local image', 'Fine']); expect(places[0].website).toBeNull(); expect(places[1].image_url).toBeNull(); expect(places[2]).toMatchObject({ website: 'https://example.com', image_url: 'https://example.com/p.jpg' }); }); - it('COLLECTIONS-SVC-111: an import writes nothing at all when one of its writes fails', () => { + it('COLLECTIONS-SVC-111: an import writes nothing at all when one of its writes fails', async () => { const owner = createUser(testDb).user; - const before = svc.listCollections(owner.id).collections.length; + const before = (await svc.listCollections(owner.id)).collections.length; const insert = testDb.prepare.bind(testDb); const spy = vi.spyOn(testDb, 'prepare').mockImplementation((sql: string) => { if (sql.includes('INSERT INTO collection_places')) throw new Error('disk is full'); @@ -1279,14 +1279,14 @@ describe('exportCollection / importCollection (#2198)', () => { }); try { - expect(() => svc.importCollection(owner.id, { file: { + await expect(svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Doomed', places: [{ name: 'Never arrives' }], - } })).toThrow('disk is full'); + } })).rejects.toThrow('disk is full'); } finally { spy.mockRestore(); } - expect(svc.listCollections(owner.id).collections).toHaveLength(before); + expect((await svc.listCollections(owner.id)).collections).toHaveLength(before); }); }); @@ -1298,15 +1298,15 @@ describe('importIntoCollection', () => { description: 'Their notes', color: '#ef4444', ...(labels ? { labels } : {}), places, }); - it('COLLECTIONS-SVC-120: adds the file to the list and leaves the list itself alone', () => { + it('COLLECTIONS-SVC-120: adds the file to the list and leaves the list itself alone', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Lisbon', description: 'Mine', color: '#111827' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); + const col = await svc.createCollection(owner.id, { name: 'Lisbon', description: 'Mine', color: '#111827' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); - const result = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'Alfama' }]) }); + const result = await svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'Alfama' }]) }); expect(result).toMatchObject({ imported: 2, skipped: 0, duplicates: 0 }); - const after = svc.getCollection(owner.id, col.id); + const after = await svc.getCollection(owner.id, col.id); expect(after.collection).toMatchObject({ name: 'Lisbon', description: 'Mine', color: '#111827' }); expect(after.places.map(p => p.name)).toEqual(['Time Out Market', 'Belém', 'Alfama']); // Appended after what was there, so a manual order survives. @@ -1318,109 +1318,109 @@ describe('importIntoCollection', () => { ]); }); - it('COLLECTIONS-SVC-121: a place the list already has is counted and left exactly as it was', () => { + it('COLLECTIONS-SVC-121: a place the list already has is counted and left exactly as it was', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Lisbon' }); - const mine = svc.savePlace(owner.id, { + const col = await svc.createCollection(owner.id, { name: 'Lisbon' }); + const mine = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market', notes: 'Before noon', status: 'visited', osm_id: 'node/1', - }).place!; + })).place!; - const result = svc.importIntoCollection(owner.id, col.id, { file: file([ + const result = await svc.importIntoCollection(owner.id, col.id, { file: file([ { name: 'Time Out Market', notes: 'Overrated', status: 'idea' }, { name: 'Renamed at the source', osm_id: 'node/1' }, { name: 'Belém' }, ]) }); expect(result).toMatchObject({ imported: 1, duplicates: 2, skipped: 0 }); - const places = svc.getCollection(owner.id, col.id).places; + const places = (await svc.getCollection(owner.id, col.id)).places; expect(places.map(p => p.name)).toEqual(['Time Out Market', 'Belém']); expect(places[0]).toMatchObject({ id: mine.id, notes: 'Before noon', status: 'visited' }); }); - it('COLLECTIONS-SVC-122: a file that lists the same place twice adds it once', () => { + it('COLLECTIONS-SVC-122: a file that lists the same place twice adds it once', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Lisbon' }); + const col = await svc.createCollection(owner.id, { name: 'Lisbon' }); - const result = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'belém' }]) }); + const result = await svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }, { name: 'belém' }]) }); expect(result).toMatchObject({ imported: 1, duplicates: 1 }); }); - it('COLLECTIONS-SVC-123: labels are matched by name, and only the missing ones are created', () => { + it('COLLECTIONS-SVC-123: labels are matched by name, and only the missing ones are created', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Lisbon' }); - const must = svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); + const col = await svc.createCollection(owner.id, { name: 'Lisbon' }); + const must = await svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); - svc.importIntoCollection(owner.id, col.id, { file: file( + await svc.importIntoCollection(owner.id, col.id, { file: file( [{ name: 'Belém', labels: ['Must see', 'Rainy day'] }], [{ name: 'must see', color: '#00ff00' }, { name: 'Rainy day', color: '#0000ff' }], ) }); - const labels = svc.getCollection(owner.id, col.id).collection.labels ?? []; + const labels = (await svc.getCollection(owner.id, col.id)).collection.labels ?? []; expect(labels.map(l => ({ name: l.name, color: l.color }))).toEqual([ // The list's own label keeps its name and its colour. { name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#0000ff' }, ]); - const added = svc.getCollection(owner.id, col.id).places.find(p => p.name === 'Belém')!; + const added = (await svc.getCollection(owner.id, col.id)).places.find(p => p.name === 'Belém')!; expect([...(added.label_ids ?? [])].sort()).toEqual([must.id, labels[1].id].sort()); }); - it('COLLECTIONS-SVC-124: an editor may add a file, a viewer may not, a stranger does not see the list', () => { + it('COLLECTIONS-SVC-124: an editor may add a file, a viewer may not, a stranger does not see the list', async () => { const owner = createUser(testDb).user; const editor = createUser(testDb).user; const viewer = createUser(testDb).user; const stranger = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared' }); + const col = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(col.id, editor.id, 'editor'); addMember(col.id, viewer.id, 'viewer'); - expect(svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) })).toMatchObject({ imported: 1 }); - expect(() => svc.importIntoCollection(viewer.id, col.id, { file: file([{ name: 'Alfama' }]) })).toThrow(); + expect(await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) })).toMatchObject({ imported: 1 }); + await expect(svc.importIntoCollection(viewer.id, col.id, { file: file([{ name: 'Alfama' }]) })).rejects.toThrow(); try { - svc.importIntoCollection(stranger.id, col.id, { file: file([{ name: 'Alfama' }]) }); + await svc.importIntoCollection(stranger.id, col.id, { file: file([{ name: 'Alfama' }]) }); throw new Error('should have thrown'); } catch (e) { expect((e as { status: number }).status).toBe(404); } - expect(svc.getCollection(owner.id, col.id).places.map(p => p.name)).toEqual(['Belém']); + expect((await svc.getCollection(owner.id, col.id)).places.map(p => p.name)).toEqual(['Belém']); }); - it('COLLECTIONS-SVC-125: the places stay the list owner\'s, saved by whoever brought the file', () => { + it('COLLECTIONS-SVC-125: the places stay the list owner\'s, saved by whoever brought the file', async () => { const owner = createUser(testDb).user; const editor = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared' }); + const col = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(col.id, editor.id, 'editor'); - svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) }); + await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) }); const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE collection_id = ?').get(col.id); expect(row).toEqual({ owner_id: owner.id, saved_by: editor.id }); }); - it('COLLECTIONS-SVC-126: everyone on the list is told once, and nothing is said when nothing changed', () => { + it('COLLECTIONS-SVC-126: everyone on the list is told once, and nothing is said when nothing changed', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Shared' }); + const col = await svc.createCollection(owner.id, { name: 'Shared' }); addMember(col.id, member.id, 'editor'); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Belém' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Belém' }); broadcastToUser.mockClear(); - svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); + await svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); // Once per person on the list, whole import, not once per place. expect(broadcastToUser).toHaveBeenCalledTimes(2); expect(broadcastToUser).toHaveBeenCalledWith(member.id, expect.objectContaining({ type: 'collections:updated' }), undefined); broadcastToUser.mockClear(); - const again = svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); + const again = await svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Alfama' }]) }); expect(again).toMatchObject({ imported: 0, duplicates: 1 }); expect(broadcastToUser).not.toHaveBeenCalled(); }); - it('COLLECTIONS-SVC-127: a file that fails halfway leaves the list as it was', () => { + it('COLLECTIONS-SVC-127: a file that fails halfway leaves the list as it was', async () => { const owner = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Lisbon' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); + const col = await svc.createCollection(owner.id, { name: 'Lisbon' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market' }); const real = testDb.prepare.bind(testDb); const spy = vi.spyOn(testDb, 'prepare').mockImplementation((sql: string) => { if (sql.includes('INSERT INTO collection_places')) throw new Error('disk is full'); @@ -1428,15 +1428,15 @@ describe('importIntoCollection', () => { }); try { - expect(() => svc.importIntoCollection(owner.id, col.id, { + await expect(svc.importIntoCollection(owner.id, col.id, { file: file([{ name: 'Belém' }], [{ name: 'Rainy day' }]), - })).toThrow('disk is full'); + })).rejects.toThrow('disk is full'); } finally { spy.mockRestore(); } - expect(svc.getCollection(owner.id, col.id).places.map(p => p.name)).toEqual(['Time Out Market']); - expect(svc.getCollection(owner.id, col.id).collection.labels ?? []).toEqual([]); + expect((await svc.getCollection(owner.id, col.id)).places.map(p => p.name)).toEqual(['Time Out Market']); + expect((await svc.getCollection(owner.id, col.id)).collection.labels ?? []).toEqual([]); }); }); @@ -1445,59 +1445,59 @@ describe('importIntoCollection', () => { describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { const fixture = (name: string) => fs.readFileSync(path.join(__dirname, '../../fixtures/gpx', name), 'utf8'); - it('COLLECTIONS-SVC-120: a list that goes out as GPX comes back as the same list', () => { + it('COLLECTIONS-SVC-120: a list that goes out as GPX comes back as the same list', async () => { const owner = createUser(testDb).user; const other = createUser(testDb).user; const cat = createCategory(testDb, { name: 'Restaurant' }); - const col = svc.createCollection(owner.id, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); - const must = svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); - svc.createLabel(owner.id, col.id, 'Rainy day', '#00ff00'); - const market = svc.savePlace(owner.id, { + const col = await svc.createCollection(owner.id, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); + const must = await svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); + await svc.createLabel(owner.id, col.id, 'Rainy day', '#00ff00'); + const market = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', category_id: cat.id, price: 12.5, currency: 'EUR', links: [{ url: 'https://menu.example/', label: 'Menu' }], - }).place!; - svc.savePlace(owner.id, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327, status: 'visited' }); - svc.assignLabels(owner.id, [must.id], [market.id], false); - const original = svc.exportCollection(owner.id, col.id); + })).place!; + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327, status: 'visited' }); + await svc.assignLabels(owner.id, [must.id], [market.id], false); + const original = await svc.exportCollection(owner.id, col.id); - const exported = svc.exportCollectionGpx(owner.id, col.id); + const exported = await svc.exportCollectionGpx(owner.id, col.id); const read = svc.readCollectionGpx({ gpx: exported.gpx, file_name: 'lisbon.gpx' }); - const result = svc.importCollection(other.id, { file: read.file }); + const result = await svc.importCollection(other.id, { file: read.file }); expect(exported).toMatchObject({ name: 'Lisbon', waypoints: 2, omitted: 0 }); expect(read).toMatchObject({ skipped: 0, track_points: 0 }); expect(result).toMatchObject({ imported: 2, skipped: 0 }); // Names, coordinates, notes, website, category, labels, status and the rest: // exported again from the copy, the file is the one the original gave. - const again = svc.exportCollection(other.id, (result.collection as { id: number }).id); + const again = await svc.exportCollection(other.id, (result.collection as { id: number }).id); expect({ ...again, exported_at: undefined }).toEqual({ ...original, exported_at: undefined }); }); - it('COLLECTIONS-SVC-121: a place without coordinates is left out and counted, and a stranger gets nothing', () => { + it('COLLECTIONS-SVC-121: a place without coordinates is left out and counted, and a stranger gets nothing', async () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; - const col = svc.createCollection(owner.id, { name: 'Mixed' }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Pinned', lat: 1, lng: 2 }); - svc.savePlace(owner.id, { collection_id: col.id, name: 'Somewhere vague' }); + const col = await svc.createCollection(owner.id, { name: 'Mixed' }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Pinned', lat: 1, lng: 2 }); + await svc.savePlace(owner.id, { collection_id: col.id, name: 'Somewhere vague' }); - const result = svc.exportCollectionGpx(owner.id, col.id); + const result = await svc.exportCollectionGpx(owner.id, col.id); expect(result).toMatchObject({ name: 'Mixed', waypoints: 1, omitted: 1 }); expect(result.gpx).toContain('Pinned'); expect(result.gpx).not.toContain('Somewhere vague'); - expect(() => svc.exportCollectionGpx(stranger.id, col.id)).toThrow(expect.objectContaining({ status: 404 })); + await expect(svc.exportCollectionGpx(stranger.id, col.id)).rejects.toThrow(expect.objectContaining({ status: 404 })); }); - it('COLLECTIONS-SVC-122: OsmAnd favourites become a list, their groups matched against the palette', () => { + it('COLLECTIONS-SVC-122: OsmAnd favourites become a list, their groups matched against the palette', async () => { const owner = createUser(testDb).user; createCategory(testDb, { name: 'Restaurant' }); const read = svc.readCollectionGpx({ gpx: fixture('osmand-favourites.gpx'), file_name: 'favourites.gpx' }); - const created = svc.importCollection(owner.id, { file: read.file, name: 'Lisbon favourites' }).collection as { id: number }; + const created = (await svc.importCollection(owner.id, { file: read.file, name: 'Lisbon favourites' })).collection as { id: number }; - const detail = svc.getCollection(owner.id, created.id); + const detail = await svc.getCollection(owner.id, created.id); expect(detail.collection.name).toBe('Lisbon favourites'); expect(detail.places.map(p => [p.name, p.category?.name ?? null, p.address ?? null])).toEqual([ ['Time Out Market', 'Restaurant', 'Av. 24 de Julho 49, 1200-479 Lisboa'], @@ -1507,7 +1507,7 @@ describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { ]); }); - it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', () => { + it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', async () => { const owner = createUser(testDb).user; const before = testDb.prepare('SELECT COUNT(*) AS n FROM collections').get() as { n: number }; @@ -1515,6 +1515,6 @@ describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { expect(() => svc.readCollectionGpx({ gpx: fixture('hostile-doctype.gpx') })).toThrow(expect.objectContaining({ code: 'unreadable' })); expect(testDb.prepare('SELECT COUNT(*) AS n FROM collections').get()).toEqual(before); - expect(svc.listCollections(owner.id).collections).toHaveLength(0); + expect((await svc.listCollections(owner.id)).collections).toHaveLength(0); }); }); diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts index 78d5781fb..862886834 100644 --- a/server/tests/unit/nest/maps.amap.test.ts +++ b/server/tests/unit/nest/maps.amap.test.ts @@ -538,7 +538,7 @@ describe('MapsService.keyedProvider', () => { it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', async () => { mockProviderGet.mockReturnValue({ value: 'not-a-provider' }); keys({ google: 'gkey' }); - expect(svc.placesProviderChoice()).toBe('auto'); + expect(await svc.placesProviderChoice()).toBe('auto'); expect((await svc.keyedProvider(1))?.id).toBe('google'); }); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index 9ec209b96..b10f4a4b2 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -143,7 +143,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { describe('POST /autocomplete', () => { it('returns the disabled envelope when the kill-switch is off', async () => { const autocomplete = vi.fn(); - const res = await makeController({ autocompleteDisabled: () => true, autocomplete }).autocomplete(user, { input: 'be' }); + const res = await makeController({ autocompleteDisabled: async () => true, autocomplete }).autocomplete(user, { input: 'be' }); expect(res).toEqual({ suggestions: [], source: 'disabled' }); expect(autocomplete).not.toHaveBeenCalled(); }); @@ -151,7 +151,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { it('delegates a valid request', async () => { const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' }); const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; - await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, { input: 'be', lang: 'en', locationBias: bias }); + await makeController({ autocompleteDisabled: async () => false, autocomplete }).autocomplete(user, { input: 'be', lang: 'en', locationBias: bias }); expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias, undefined); }); @@ -159,14 +159,14 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { // Google billing session instead of charging each request. it('passes a session token through to the service', async () => { const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'google' }); - await makeController({ autocompleteDisabled: () => false, autocomplete }) + await makeController({ autocompleteDisabled: async () => false, autocomplete }) .autocomplete(user, { input: 'be', sessionToken: 'abc123' }); expect(autocomplete).toHaveBeenCalledWith(3, 'be', undefined, undefined, 'abc123'); }); it('maps a service error', async () => { const autocomplete = vi.fn().mockRejectedValue(withError(503, 'Upstream down')); - const c = makeController({ autocompleteDisabled: () => false, autocomplete }); + const c = makeController({ autocompleteDisabled: async () => false, autocomplete }); expect(await thrown(() => c.autocomplete(user, { input: 'be' }))).toEqual({ status: 503, body: { error: 'Upstream down' }, }); @@ -175,14 +175,14 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { describe('GET /details/:placeId', () => { it('returns the disabled envelope when off', async () => { - const res = await makeController({ detailsDisabled: () => true }).details(user, 'p1'); + const res = await makeController({ detailsDisabled: async () => true }).details(user, 'p1'); expect(res).toEqual({ place: null, disabled: true }); }); it('uses the expanded lookup when expand is set', async () => { const detailsExpanded = vi.fn().mockResolvedValue({ place: { id: 'p1' } }); const details = vi.fn(); - await makeController({ detailsDisabled: () => false, detailsExpanded, details }) + await makeController({ detailsDisabled: async () => false, detailsExpanded, details }) .details(user, 'p1', 'full', 'de', '1'); expect(detailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); expect(details).not.toHaveBeenCalled(); @@ -190,7 +190,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { it('uses the plain lookup without expand', async () => { const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } }); - await makeController({ detailsDisabled: () => false, details }).details(user, 'p1', undefined, 'de'); + await makeController({ detailsDisabled: async () => false, details }).details(user, 'p1', undefined, 'de'); expect(details).toHaveBeenCalledWith(3, 'p1', 'de', undefined); }); @@ -198,7 +198,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { // and a junk value degrades to per-request billing instead of reaching Google. it('forwards a well-formed session token and drops a malformed one', async () => { const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } }); - const c = makeController({ detailsDisabled: () => false, details }); + const c = makeController({ detailsDisabled: async () => false, details }); await c.details(user, 'p1', undefined, 'de', undefined, 'a-b_C9'); expect(details).toHaveBeenLastCalledWith(3, 'p1', 'de', 'a-b_C9'); @@ -212,7 +212,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { it('maps a service error', async () => { const details = vi.fn().mockRejectedValue(withError(404, 'Not found')); - expect(await thrown(() => makeController({ detailsDisabled: () => false, details }).details(user, 'p1'))).toEqual({ + expect(await thrown(() => makeController({ detailsDisabled: async () => false, details }).details(user, 'p1'))).toEqual({ status: 404, body: { error: 'Not found' }, }); }); @@ -221,20 +221,20 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { describe('GET /place-photo/:placeId', () => { it('returns { photoUrl: null } when photos are disabled (non-coords)', async () => { const photo = vi.fn(); - const res = await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'p1', '1', '2'); + const res = await makeController({ photosDisabled: async () => true, photo }).placePhoto(user, 'p1', '1', '2'); expect(res).toEqual({ photoUrl: null }); expect(photo).not.toHaveBeenCalled(); }); it('bypasses the kill-switch for coords: ids', async () => { const photo = vi.fn().mockResolvedValue({ photoUrl: 'u', attribution: null }); - await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'coords:1,2', '1', '2', 'Spot'); + await makeController({ photosDisabled: async () => true, photo }).placePhoto(user, 'coords:1,2', '1', '2', 'Spot'); expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot'); }); it('maps a 4xx service error', async () => { const photo = vi.fn().mockRejectedValue(withError(429, 'Rate limited')); - expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ + expect(await thrown(() => makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ status: 429, body: { error: 'Rate limited' }, }); }); @@ -243,13 +243,13 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { // place gets the user banned by any 404-rate IPS in front of TREK (#1727). it('passes a photo-less place through as a 200 with photoUrl null', async () => { const photo = vi.fn().mockResolvedValue({ photoUrl: null, attribution: null }); - const res = await makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'node:123', '1', '2'); + const res = await makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'node:123', '1', '2'); expect(res).toEqual({ photoUrl: null, attribution: null }); }); it('logs and maps a 5xx service error', async () => { const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed')); - expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ + expect(await thrown(() => makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ status: 502, body: { error: 'Upstream failed' }, }); expect(console.error).toHaveBeenCalledWith('Place photo error:', expect.any(Error)); @@ -257,7 +257,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { it('defaults a status-less error to 500 and parses NaN coords', async () => { const photo = vi.fn().mockRejectedValue(new Error('Error fetching photo')); - expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1'))).toEqual({ + expect(await thrown(() => makeController({ photosDisabled: async () => false, photo }).placePhoto(user, 'p1'))).toEqual({ status: 500, body: { error: 'Error fetching photo' }, }); const [, , lat, lng] = photo.mock.calls[0]; diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index fa8e8d391..7c41d0af8 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -2663,32 +2663,32 @@ function settingsSvc(row?: { value: string }) { } describe('kill-switch settings reads', () => { - it('reports a switch disabled when the stored value is exactly "false"', () => { - expect(settingsSvc({ value: 'false' }).autocompleteDisabled()).toBe(true); - expect(settingsSvc({ value: 'false' }).detailsDisabled()).toBe(true); - expect(settingsSvc({ value: 'false' }).photosDisabled()).toBe(true); + it('reports a switch disabled when the stored value is exactly "false"', async () => { + expect(await settingsSvc({ value: 'false' }).autocompleteDisabled()).toBe(true); + expect(await settingsSvc({ value: 'false' }).detailsDisabled()).toBe(true); + expect(await settingsSvc({ value: 'false' }).photosDisabled()).toBe(true); }); - it('reports enabled when the value is "true"', () => { - expect(settingsSvc({ value: 'true' }).autocompleteDisabled()).toBe(false); - expect(settingsSvc({ value: 'true' }).detailsDisabled()).toBe(false); - expect(settingsSvc({ value: 'true' }).photosDisabled()).toBe(false); + it('reports enabled when the value is "true"', async () => { + expect(await settingsSvc({ value: 'true' }).autocompleteDisabled()).toBe(false); + expect(await settingsSvc({ value: 'true' }).detailsDisabled()).toBe(false); + expect(await settingsSvc({ value: 'true' }).photosDisabled()).toBe(false); }); - it('reports enabled when the setting row is absent', () => { - expect(settingsSvc(undefined).autocompleteDisabled()).toBe(false); - expect(settingsSvc(undefined).detailsDisabled()).toBe(false); - expect(settingsSvc(undefined).photosDisabled()).toBe(false); + it('reports enabled when the setting row is absent', async () => { + expect(await settingsSvc(undefined).autocompleteDisabled()).toBe(false); + expect(await settingsSvc(undefined).detailsDisabled()).toBe(false); + expect(await settingsSvc(undefined).photosDisabled()).toBe(false); }); - it('queries the matching app_settings key', () => { + it('queries the matching app_settings key', async () => { const { db: settingsDb, get } = makeSettingsDb({ value: 'true' }); const s = new MapsService(settingsDb, photoCacheStub); - s.autocompleteDisabled(); + await s.autocompleteDisabled(); expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled'); - s.detailsDisabled(); + await s.detailsDisabled(); expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled'); - s.photosDisabled(); + await s.photosDisabled(); expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled'); }); }); 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 7e3bbc168..0992ef258 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -118,7 +118,7 @@ const buildReadModel = async (database: DatabaseService, roster: TripMembersServ database, roster, daysSvc, accommodationsSvc, budgetSvc, new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), 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()), + new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)), placesSvc, new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)), new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage), diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 40aec1881..b85d4ef50 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -154,7 +154,7 @@ beforeAll(async () => { dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc, new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()), 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()), + new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)), placesSvc, new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)), new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), coversFx.storage),