mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user