import { describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; import { notifyProjectOwner } from '@/lib/notifications'; import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session'; import { GET as listComments, POST as createCommentRoute, } from '@/app/api/versions/[versionId]/comments/route'; import { DELETE as deleteCommentRoute, GET as getCommentRoute, PATCH as patchCommentRoute, } from '@/app/api/comments/[commentId]/route'; import { isFreshAttachment } from '@/lib/upload-freshness'; import { apiRequest, callRoute, readData, readError } from '../helpers/request'; import { signedInAs, signedOut } from '../helpers/session'; import { addProjectMember, addWorkspaceMember, createComment, createCommentTag, createExpiredUser, createProject, createShareLink, createUser, createVersion, createVideo, createWorkspace, seedVersion, } from '../factories'; // The real check heads the object in R2. Standing in for it lets these tests // drive the attachment paths; a suite that wants a stale upload overrides it. vi.mock('@/lib/upload-freshness', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isFreshAttachment: vi.fn(async () => ({ isFresh: true, sizeBytes: BigInt(1024) })), }; }); const IMAGE_A = '/api/upload/image/11111111-2222-3333-4444-555555555555.png'; const IMAGE_B = '/api/upload/image/66666666-7777-8888-9999-aaaaaaaaaaaa.png'; const IMAGE_C = '/api/upload/image/bbbbbbbb-cccc-dddd-eeee-ffffffffffff.png'; const IMAGE_D = '/api/upload/image/12121212-3434-5656-7878-909090909090.png'; const IMAGE_E = '/api/upload/image/abababab-cdcd-efef-0101-232323232323.png'; const IMAGE_F = '/api/upload/image/45454545-6767-8989-0a0a-1b1b1b1b1b1b.png'; async function imageUrlsOf(commentId: string): Promise { const images = await db.commentImage.findMany({ where: { commentId }, orderBy: { position: 'asc' }, select: { url: true }, }); return images.map((image) => image.url); } const VALID_STROKE = { points: [ { x: 0.1, y: 0.2 }, { x: 0.3, y: 0.4 }, ], color: '#FF3B30', width: 4, }; function commentsUrl(versionId: string): string { return `/api/versions/${versionId}/comments`; } describe('GET /api/versions/[versionId]/comments', () => { it('returns 404 for an unknown version', async () => { const user = await createUser(); signedInAs(user); const response = await callRoute(listComments, apiRequest(commentsUrl('nope')), { versionId: 'nope', }); expect(response.status).toBe(404); }); it('returns 403 to an anonymous caller on a PRIVATE project', async () => { const scenario = await seedVersion({ visibility: 'PRIVATE' }); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); signedOut(); const response = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { versionId: scenario.version.id, }); expect(response.status).toBe(403); }); it('returns only top-level comments, with replies nested', async () => { const scenario = await seedVersion(); const parent = await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id, timestamp: 5, }); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id, parentId: parent.id, timestamp: 5, }); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id, timestamp: 1, }); signedInAs(scenario.owner); const payload = await readData<{ comments: Array<{ id: string; timestamp: number; replies: Array<{ id: string }> }>; total: number; hasMore: boolean; }>( await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { versionId: scenario.version.id, }) ); expect(payload.comments).toHaveLength(2); expect(payload.comments.map((entry) => entry.timestamp)).toEqual([1, 5]); expect(payload.comments.find((entry) => entry.id === parent.id)?.replies).toHaveLength(1); expect(payload.total).toBe(2); expect(payload.hasMore).toBe(false); }); it('omits resolved comments when includeResolved=false', async () => { const scenario = await seedVersion(); const open = await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id, }); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id, isResolved: true, resolvedAt: new Date(), }); signedInAs(scenario.owner); const payload = await readData<{ comments: Array<{ id: string }> }>( await callRoute( listComments, apiRequest(`${commentsUrl(scenario.version.id)}?includeResolved=false`), { versionId: scenario.version.id } ) ); expect(payload.comments.map((entry) => entry.id)).toEqual([open.id]); }); it('answers 304 when the caller presents the current ETag', async () => { const scenario = await seedVersion(); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); signedInAs(scenario.owner); const first = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), { versionId: scenario.version.id, }); const etag = first.headers.get('etag'); expect(etag).toBeTruthy(); const second = await callRoute( listComments, apiRequest(commentsUrl(scenario.version.id), { headers: { 'if-none-match': etag! } }), { versionId: scenario.version.id } ); expect(second.status).toBe(304); }); it('lets a guest with a VIEW share session read the comments', async () => { const scenario = await seedVersion({ visibility: 'PRIVATE' }); const link = await createShareLink({ projectId: scenario.project.id, videoId: scenario.video.id, permission: 'VIEW', }); await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id }); signedOut(); const response = await callRoute( listComments, apiRequest(commentsUrl(scenario.version.id), { cookies: { [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue( link.token, scenario.video.id, false ), }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(200); }); it('refuses a share session signed for a different video', async () => { const scenario = await seedVersion({ visibility: 'PRIVATE' }); const otherVideo = await createVideo({ projectId: scenario.project.id }); const link = await createShareLink({ projectId: scenario.project.id, videoId: otherVideo.id, permission: 'VIEW', }); signedOut(); const response = await callRoute( listComments, apiRequest(commentsUrl(scenario.version.id), { cookies: { [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue( link.token, otherVideo.id, false ), }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(403); }); }); describe('POST /api/versions/[versionId]/comments', () => { it('returns 403 to an anonymous caller with no share session', async () => { const scenario = await seedVersion(); signedOut(); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1, guestName: 'Anon' }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(403); expect(await db.comment.count()).toBe(0); }); it('returns 403 to a signed-in stranger', async () => { const scenario = await seedVersion(); const stranger = await createUser(); signedInAs(stranger); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), { versionId: scenario.version.id } ); expect(response.status).toBe(403); expect(await db.comment.count()).toBe(0); }); it('returns 403 once the workspace owner has lost billing access', async () => { const expiredOwner = await createExpiredUser(); const workspace = await createWorkspace({ ownerId: expiredOwner.id }); const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id }); const video = await createVideo({ projectId: project.id }); const version = await createVersion({ videoParentId: video.id }); signedInAs(expiredOwner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(version.id), { body: { content: 'hi', timestamp: 1 } }), { versionId: version.id } ); expect(response.status).toBe(403); expect(await db.comment.count()).toBe(0); }); it('returns 400 when the timestamp is missing', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi' } }), { versionId: scenario.version.id } ); expect(response.status).toBe(400); }); it.each([ [-1, 'a negative timestamp'], ['not-a-number', 'an unparseable timestamp'], [Number.POSITIVE_INFINITY, 'a non-finite timestamp'], [121, 'a timestamp past the version duration of 120'], ])('rejects the timestamp %s with 400 (%s)', async (timestamp, label) => { const scenario = await seedVersion({ duration: 120 }); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp } }), { versionId: scenario.version.id } ); expect(response.status, label).toBe(400); expect(await db.comment.count()).toBe(0); }); // The "do not email somebody about their own comment" rule lives in the route // (`const isOwnProject = session?.user?.id === project.ownerId`), not in // lib/notifications.ts: notifyUsers() takes no actor argument and has no way // to know. So it cannot be covered by a unit test of the notification module, // and until these two it was covered nowhere: deleting the guard turned // nothing red. They are written as a pair on purpose, because the negative one // alone would also pass if notifications stopped firing altogether. it('does not notify the project owner about the owner’s own comment', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); vi.mocked(notifyProjectOwner).mockClear(); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), { versionId: scenario.version.id } ); expect(response.status).toBe(201); expect(notifyProjectOwner).not.toHaveBeenCalled(); }); it('notifies the project owner about a collaborator’s comment', async () => { const scenario = await seedVersion(); const collaborator = await createUser(); await addProjectMember({ projectId: scenario.project.id, userId: collaborator.id }); signedInAs(collaborator); vi.mocked(notifyProjectOwner).mockClear(); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), { versionId: scenario.version.id } ); expect(response.status).toBe(201); expect(notifyProjectOwner).toHaveBeenCalledTimes(1); expect(vi.mocked(notifyProjectOwner).mock.calls[0][0]).toBe(scenario.owner.id); }); it('accepts a timestamp exactly equal to the duration', async () => { const scenario = await seedVersion({ duration: 120 }); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 120 } }), { versionId: scenario.version.id } ); expect(response.status).toBe(201); expect((await db.comment.findFirstOrThrow()).timestamp).toBe(120); }); it('rejects a timestampEnd below the timestamp', async () => { const scenario = await seedVersion({ duration: 120 }); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 10, timestampEnd: 5 }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(400); expect(await readError(response)).toMatch(/greater than or equal/i); }); it('rejects a comment with no content, voice, image or annotation', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { timestamp: 1 } }), { versionId: scenario.version.id } ); expect(response.status).toBe(400); }); it('rejects content longer than 10000 characters', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { content: 'x'.repeat(10_001), timestamp: 1 }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(400); expect(await db.comment.count()).toBe(0); }); it.each([ ['a bare object', { color: '#FF3B30', width: 4, points: [] }], ['a stroke with a 3-digit colour', [{ ...VALID_STROKE, color: '#f00' }]], ['a stroke with a named colour', [{ ...VALID_STROKE, color: 'red' }]], ['a stroke with width 0', [{ ...VALID_STROKE, width: 0 }]], ['a stroke with width 21', [{ ...VALID_STROKE, width: 21 }]], ['a stroke with a NaN coordinate', [{ ...VALID_STROKE, points: [{ x: 0, y: null }] }]], ['a stroke whose points are not an array', [{ ...VALID_STROKE, points: 'nope' }]], ['a double-encoded JSON string', JSON.stringify([VALID_STROKE])], ['an array of arrays', [[VALID_STROKE]]], ['an array containing null', [null]], ])('rejects annotationData given as %s', async (_label, annotationData) => { const scenario = await seedVersion(); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { timestamp: 1, annotationData }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(400); expect(await db.comment.count()).toBe(0); }); // Written as raw JSON on purpose. `{ __proto__: ... }` in an object literal // sets the prototype rather than creating an own property, so JSON.stringify // would silently drop it and the test would prove nothing. JSON.parse, by // contrast, does create a real own "__proto__" property. it('does not let a __proto__ key in annotationData reach the database or Object.prototype', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); const rawBody = JSON.stringify({ timestamp: 1, annotationData: [ JSON.parse( '{"points":[{"x":0,"y":0}],"color":"#FF3B30","width":4,' + '"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted":"yes"}}}' ), ], }); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { method: 'POST', rawBody, headers: { 'content-type': 'application/json' }, }), { versionId: scenario.version.id } ); expect(response.status).toBe(201); const stored = await db.comment.findFirstOrThrow(); expect(stored.annotationData).toBe( JSON.stringify([{ points: [{ x: 0, y: 0 }], color: '#FF3B30', width: 4 }]) ); expect(stored.annotationData).not.toContain('polluted'); expect(stored.annotationData).not.toContain('__proto__'); expect(({} as Record).polluted).toBeUndefined(); }); it('re-serialises accepted annotation strokes into canonical form', async () => { const scenario = await seedVersion(); signedInAs(scenario.owner); const response = await callRoute( createCommentRoute, apiRequest(commentsUrl(scenario.version.id), { body: { timestamp: 1, annotationData: [{ ...VALID_STROKE, extraneous: 'dropped', tool: '