import { describe, expect, it } from 'vitest'; import worker from '../worker/index'; import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth'; type NotificationStatus = 'unread' | 'viewed'; type NotificationRow = { id: string; user_id: string; title: string; body: string | null; kind: string; related_submission_id: string | null; status: NotificationStatus; created_at: string; viewed_at: string | null; proposed_alias?: string | null; private_link_id?: string | null; private_link_alias?: string | null; }; type SessionRow = { id: string; email: string | null; name: string | null; image_url: string | null; role: 'user' | 'admin'; expires_at: string; session_token_hash: string; }; type SubmissionRow = { id: string; private_link_id: string; submitted_by_user_id: string; proposed_alias: string; note: string | null; status: 'pending' | 'approved' | 'rejected' | 'needs_changes'; reviewed_by_user_id: string | null; rejection_reason: string | null; public_link_id: string | null; created_at: string; reviewed_at: string | null; }; type LinkRow = { id: string; scope: 'public' | 'private'; owner_user_id: string | null; alias: string; link_type: 'redirect' | 'custom'; target_url: string | null; content_markdown: string | null; description: string | null; status: 'active' | 'archived' | 'deleted'; click_count: number; created_at: string; updated_at: string; }; type AllResult = { results: T[]; success: true; meta: Record; }; class FakeD1Database { readonly runCalls: { sql: string; params: unknown[] }[] = []; constructor( readonly notifications: NotificationRow[] = [], readonly submissions: SubmissionRow[] = [], readonly links: LinkRow[] = [], private readonly sessions: SessionRow[] = [], ) {} prepare(sql: string): FakeD1PreparedStatement { return new FakeD1PreparedStatement(this, sql); } findSession(hash: string): SessionRow | null { return this.sessions.find((s) => s.session_token_hash === hash) ?? null; } findNotification(id: string): NotificationRow | null { return this.notifications.find((n) => n.id === id) ?? null; } listForUser(userId: string): NotificationRow[] { return this.notifications .filter((n) => n.user_id === userId) .sort((a, b) => b.created_at.localeCompare(a.created_at)) .map((n) => this.enrich(n)); } enrich(n: NotificationRow): NotificationRow { const submission = this.submissions.find((s) => s.id === n.related_submission_id) ?? null; const link = submission ? this.links.find((l) => l.id === submission.private_link_id) ?? null : null; return { ...n, proposed_alias: submission?.proposed_alias ?? null, private_link_id: submission?.private_link_id ?? null, private_link_alias: link?.alias ?? null, }; } countUnread(userId: string): number { return this.notifications.filter((n) => n.user_id === userId && n.status === 'unread').length; } insertNotification(params: unknown[]): NotificationRow { const [id, userId, title, body, kind, relatedSubmissionId] = params; const row: NotificationRow = { id: String(id), user_id: String(userId), title: String(title), body: body == null ? null : String(body), kind: String(kind), related_submission_id: relatedSubmissionId == null ? null : String(relatedSubmissionId), status: 'unread', created_at: '2026-06-20T00:00:00.000Z', viewed_at: null, }; this.notifications.push(row); return row; } } class FakeD1PreparedStatement { private params: unknown[] = []; constructor( private readonly db: FakeD1Database, private readonly sql: string, ) {} bind(...params: unknown[]): FakeD1PreparedStatement { this.params = params; return this; } async first(): Promise { if (this.sql.includes('session_token_hash')) { const row = this.db.findSession(String(this.params[0])); if (!row) return null; return { id: row.id, email: row.email, name: row.name, image_url: row.image_url, role: row.role, expires_at: row.expires_at, } as T; } if (this.sql.includes('SELECT COUNT(*)')) { return { count: this.db.countUnread(String(this.params[0])) } as T; } if (this.sql.includes('SELECT id, user_id, status FROM notifications')) { const row = this.db.findNotification(String(this.params[0])); if (!row) return null; return { id: row.id, user_id: row.user_id, status: row.status } as T; } return null; } async all(): Promise> { if (this.sql.includes('FROM notifications n')) { return { results: this.db.listForUser(String(this.params[0])) as unknown as T[], success: true, meta: {} }; } return { results: [], success: true, meta: {} }; } async run(): Promise { this.db.runCalls.push({ sql: this.sql, params: this.params }); if (this.sql.startsWith('INSERT INTO notifications')) { this.db.insertNotification(this.params); } if (this.sql.startsWith('UPDATE notifications') && this.sql.includes("status='viewed'")) { const userId = String(this.params[0]); for (const n of this.db.notifications) { if (n.user_id === userId && n.status === 'unread') { n.status = 'viewed'; n.viewed_at = '2026-06-20T00:00:00.000Z'; } } } if (this.sql.startsWith('DELETE FROM notifications')) { const id = String(this.params[0]); const userId = String(this.params[1]); const idx = this.db.notifications.findIndex((n) => n.id === id && n.user_id === userId); const changes = idx === -1 ? 0 : 1; if (idx !== -1) this.db.notifications.splice(idx, 1); return { success: true, meta: { changes } } as unknown as D1Result; } return { success: true, meta: { changes: 1 } } as unknown as D1Result; } } class FakeExecutionContext { waitUntil(): void {} passThroughOnException(): void {} } function notification(overrides: Partial = {}): NotificationRow { return { id: 'notif_1', user_id: 'user_1', title: 'Promotion approved', body: 'Your link was approved.', kind: 'promotion_approved', related_submission_id: 'sub_1', status: 'unread', created_at: '2026-06-20T00:00:00.000Z', viewed_at: null, ...overrides, }; } function futureIso(): string { return new Date(Date.now() + 60 * 60 * 1000).toISOString(); } async function userSession(token: string, userId: string, role: 'user' | 'admin' = 'user'): Promise { return { id: userId, email: `${userId}@heygo.cc`, name: userId, image_url: null, role, expires_at: futureIso(), session_token_hash: await hashSessionToken(token), }; } function cookie(token: string): string { return `${AUTH_SESSION_COOKIE_NAME}=${token}`; } function makeEnv( notifications: NotificationRow[] = [], submissions: SubmissionRow[] = [], links: LinkRow[] = [], sessions: SessionRow[] = [], ) { const db = new FakeD1Database(notifications, submissions, links, sessions); return { env: { DB: db as unknown as D1Database, PUBLIC_HOST: 'heygo.cc', PRIVATE_HOST: 'my.heygo.cc', APP_BASE_URL: 'https://heygo.cc', COOKIE_DOMAIN: '.heygo.cc', }, db, ctx: new FakeExecutionContext(), }; } async function fetchWorker( path: string, opts: { method?: string; body?: unknown; notifications?: NotificationRow[]; sessions?: SessionRow[]; cookie?: string; } = {}, ) { const { env, db, ctx } = makeEnv(opts.notifications ?? [], [], [], opts.sessions ?? []); const headers = new Headers(); if (opts.cookie) headers.set('cookie', opts.cookie); if (opts.body !== undefined) headers.set('content-type', 'application/json'); const response = await worker.fetch( new Request(`https://heygo.cc${path}`, { method: opts.method ?? 'GET', headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }) as unknown as Parameters[0], env as unknown as Parameters[1], ctx as unknown as Parameters[2], ); return { response, db }; } async function expectJson(response: Response): Promise { expect(response.headers.get('content-type')).toContain('application/json'); return response.json() as Promise; } describe('notifications API', () => { it('lists the current user notifications and auto-marks them viewed', async () => { const session = await userSession('token-a', 'user_1'); const notifications = [ notification({ id: 'n1', user_id: 'user_1', status: 'unread', title: 'A', created_at: '2026-06-20T00:00:00.000Z' }), notification({ id: 'n2', user_id: 'user_1', status: 'unread', title: 'B', created_at: '2026-06-20T01:00:00.000Z' }), notification({ id: 'n_other', user_id: 'user_2', title: 'Other' }), ]; const { response, db } = await fetchWorker('/api/notifications', { cookie: cookie('token-a'), sessions: [session], notifications, }); expect(response.status).toBe(200); const body = await expectJson(response); expect(body.notifications.map((n: { id: string }) => n.id)).toEqual(['n2', 'n1']); expect(db.notifications.filter((n) => n.user_id === 'user_1').every((n) => n.status === 'viewed')).toBe(true); }); it('returns the unread count for the current user', async () => { const session = await userSession('token-a', 'user_1'); const notifications = [ notification({ id: 'n1', user_id: 'user_1', status: 'unread' }), notification({ id: 'n2', user_id: 'user_1', status: 'viewed' }), notification({ id: 'n3', user_id: 'user_2', status: 'unread' }), ]; const { response } = await fetchWorker('/api/notifications/unread-count', { cookie: cookie('token-a'), sessions: [session], notifications, }); expect(response.status).toBe(200); const body = await expectJson(response); expect(body.count).toBe(1); }); it('deletes a notification owned by the current user', async () => { const session = await userSession('token-a', 'user_1'); const notifications = [notification({ id: 'n1', user_id: 'user_1' })]; const { response, db } = await fetchWorker('/api/notifications/n1', { method: 'DELETE', cookie: cookie('token-a'), sessions: [session], notifications, }); expect(response.status).toBe(200); expect(db.notifications.some((n) => n.id === 'n1')).toBe(false); }); it('returns 404 when deleting another user notification', async () => { const session = await userSession('token-a', 'user_1'); const notifications = [notification({ id: 'n_secret', user_id: 'user_2' })]; const { response, db } = await fetchWorker('/api/notifications/n_secret', { method: 'DELETE', cookie: cookie('token-a'), sessions: [session], notifications, }); expect(response.status).toBe(404); expect(db.notifications.some((n) => n.id === 'n_secret')).toBe(true); }); it('deletes multiple notifications via delete-batch', async () => { const session = await userSession('token-a', 'user_1'); const notifications = [ notification({ id: 'n1', user_id: 'user_1' }), notification({ id: 'n2', user_id: 'user_1' }), notification({ id: 'n3', user_id: 'user_2' }), ]; const { response, db } = await fetchWorker('/api/notifications/delete-batch', { method: 'POST', cookie: cookie('token-a'), sessions: [session], notifications, body: { ids: ['n1', 'n2', 'n3'] }, }); expect(response.status).toBe(200); const body = await expectJson(response); expect(body.ok).toBe(true); expect(body.deleted).toBe(2); expect(db.notifications.map((n) => n.id)).toEqual(['n3']); }); it('rejects unauthenticated requests with 401', async () => { const { response } = await fetchWorker('/api/notifications'); expect(response.status).toBe(401); await expect(expectJson(response)).resolves.toEqual({ error: 'Authentication required' }); }); it('returns 400 for an invalid delete-batch body', async () => { const session = await userSession('token-a', 'user_1'); const { response } = await fetchWorker('/api/notifications/delete-batch', { method: 'POST', cookie: cookie('token-a'), sessions: [session], body: { ids: [] }, }); expect(response.status).toBe(400); await expect(expectJson(response)).resolves.toHaveProperty('error'); }); it('returns 405 for unsupported methods', async () => { const session = await userSession('token-a', 'user_1'); const { response } = await fetchWorker('/api/notifications', { method: 'POST', cookie: cookie('token-a'), sessions: [session], }); expect(response.status).toBe(405); }); });