refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 01:49:18 +02:00
parent fdb12aed1e
commit 6b755314d8
26 changed files with 1115 additions and 1116 deletions
+41 -41
View File
@@ -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<ReturnType<CollabService['verifyTripAccess']>>, user: User): Promise<void> {
private async requireEdit(trip: NonNullable<Awaited<ReturnType<CollabService['verifyTripAccess']>>>, user: User): Promise<void> {
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);
+29 -29
View File
@@ -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);
}
}
+7 -7
View File
@@ -33,7 +33,7 @@ export class CollabRpc {
listNotes(params: Record<string, unknown>, 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<string, unknown>, 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;
+42 -37
View File
@@ -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<LinkPreviewResult, 'url'> {
* 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<string, Promise<LinkPreviewResult>>();
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<ReactionRow[]> {
return this.db.all<ReactionRow>(`
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<NoteFileRow>('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<CollabNote>(`
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<CollabService['formatNote']> | 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<Awaited<ReturnType<CollabService['formatNote']>> | 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<CollabNote>('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<CollabPoll>(`
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<CollabService['getPollWithVotes']> } {
async votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): Promise<{ error?: string; poll?: Awaited<ReturnType<CollabService['getPollWithVotes']>> }> {
const poll = this.db.get<CollabPoll>('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<CollabService['getPollWithVotes']> | null {
async closePoll(tripId: string | number, pollId: string | number): Promise<Awaited<ReturnType<CollabService['getPollWithVotes']>> | 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<boolean> {
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<any>('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<boolean> {
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<any>(
`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<number> {
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<CollabService['formatMessage']> } {
): Promise<{ error?: string; message?: Awaited<ReturnType<CollabService['formatMessage']>> }> {
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<CollabMessage>('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<void> {
// 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
@@ -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 };
}
}
+26 -26
View File
@@ -77,7 +77,7 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'read' },
})
async listCollections(_args: Record<string, never>, 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); }
}
}
@@ -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<T>(fn: () => T | Promise<T>): Promise<T> {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -142,7 +142,7 @@ export class MapsController {
@CurrentUser() user: User,
@Body() body: MapsAutocompleteDto,
): Promise<MapsAutocompleteResult | { suggestions: never[]; source: string }> {
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<MapsPlaceDetailsResult> {
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<MapsPlacePhotoResult | { photoUrl: null }> {
// 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
+1 -1
View File
@@ -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,
+6 -6
View File
@@ -696,7 +696,7 @@ export class MapsService {
* oldest entry is the one evicted when it fills up. */
private readonly brandLogoCache = new Map<string, { at: number; logo: BrandLogo | null }>();
private isSettingDisabled(key: string): boolean {
private async isSettingDisabled(key: string): Promise<boolean> {
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<boolean> {
return this.isSettingDisabled('places_autocomplete_enabled');
}
detailsDisabled(): boolean {
detailsDisabled(): Promise<boolean> {
return this.isSettingDisabled('places_details_enabled');
}
photosDisabled(): boolean {
photosDisabled(): Promise<boolean> {
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<PlacesProviderChoice> {
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<KeyedProvider | null> {
const choice = this.placesProviderChoice();
const choice = await this.placesProviderChoice();
if (choice === 'openstreetmap') return null;
if (choice !== 'amap') {
@@ -449,7 +449,7 @@ export class PlaceEnrichmentService {
details: Record<string, unknown> | null,
): Promise<PlacePhotoCandidate[]> {
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 {
@@ -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,
+2 -2
View File
@@ -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
+2 -2
View File
@@ -151,7 +151,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
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<McpRegistry> {
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),
+2 -2
View File
@@ -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);
@@ -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) => {
@@ -15,9 +15,9 @@ const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as
function svc(o: Partial<CollabService> = {}): 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<unknown>): 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<CollabService>);
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<CollabService>);
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<CollabService>);
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<CollabService>), 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<CollabService>), 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<CollabService>);
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<CollabService>);
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<CollabService>), 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<CollabService>), 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<CollabService>);
const s = svc({ addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }), getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial<CollabService>);
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<CollabService>), storageStub).addNoteFile(user, '5', '9', file));
await thrownAsync(() => new CollabController(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), 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<CollabService>), 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<CollabService>);
const s = svc({ deleteNoteFile: vi.fn().mockResolvedValue(true), getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }), broadcast: vi.fn() } as Partial<CollabService>);
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<CollabService>);
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<CollabService>);
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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>);
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<CollabService>);
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<CollabService>), 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<CollabService>);
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<CollabService>), 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<CollabService>);
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<CollabService>), 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<CollabService>);
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<CollabService>), 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<CollabService>);
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<CollabService>), 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<CollabService>), 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<CollabService>);
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial<CollabService>);
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<CollabService>), storageStub).createMessage(user, '5', { text: 'hi' }, undefined)))
expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), 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<CollabService>), storageStub).createMessage(user, '5', { text: 'hi' }, undefined)))
expect(await thrownAsync(() => new CollabController(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>);
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<CollabService>);
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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>), 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<CollabService>);
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<CollabService>);
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<CollabService>);
const s = svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial<CollabService>);
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<CollabService>);
const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial<CollabService>);
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
+105 -105
View File
@@ -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 });
});
@@ -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<Record<keyof CollectionsService, unknown>> = {}): 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<unknown>): 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: '<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);
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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');
});
+15 -15
View File
@@ -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];
+16 -16
View File
@@ -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');
});
});
@@ -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),
+1 -1
View File
@@ -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),