import { convexTest } from 'convex-test'; import { beforeAll, describe, expect, test } from 'vitest'; import type { Id } from '../../convex/_generated/dataModel'; import { api, internal } from '../../convex/_generated/api'; import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); const authed = (t: ReturnType, userId: string) => t.withIdentity({ subject: `${userId}|session`, issuer: 'https://convex.test', }); const countNotifications = async ( t: ReturnType, userId: Id<'users'>, kind: string, ) => await t.run(async (ctx) => { const rows = await ctx.db.query('notifications').collect(); return rows.filter((n) => n.userId === userId && n.kind === kind).length; }); describe('notifications schema', () => { test('inserts and reads back a notifications row', async () => { const t = convexTest(schema, modules); const { notificationId, userId } = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A', }); const now = Date.now(); const notificationId = await ctx.db.insert('notifications', { userId, kind: 'agent_turn_finished', title: 'Agent finished', body: 'Your agent completed its turn.', link: '/threads/abc', createdAt: now, }); return { notificationId, userId }; }); const notification = await t.run( async (ctx) => await ctx.db.get(notificationId), ); expect(notification).not.toBeNull(); expect(notification?.userId).toBe(userId); expect(notification?.kind).toBe('agent_turn_finished'); expect(notification?.title).toBe('Agent finished'); expect(notification?.body).toBe('Your agent completed its turn.'); expect(notification?.link).toBe('/threads/abc'); expect(notification?.readAt).toBeUndefined(); expect(notification?.emailedAt).toBeUndefined(); expect(typeof notification?.createdAt).toBe('number'); }); test('inserts and reads back a notificationPreferences row', async () => { const t = convexTest(schema, modules); const { preferenceId, userId } = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'prefs@b.c', name: 'Prefs', }); const preferenceId = await ctx.db.insert('notificationPreferences', { userId, emailEnabled: true, emailSyncFailed: false, updatedAt: Date.now(), }); return { preferenceId, userId }; }); const preference = await t.run( async (ctx) => await ctx.db.get(preferenceId), ); expect(preference).not.toBeNull(); expect(preference?.userId).toBe(userId); expect(preference?.emailEnabled).toBe(true); expect(preference?.emailSyncFailed).toBe(false); expect(preference?.emailMaintenance).toBeUndefined(); expect(typeof preference?.updatedAt).toBe('number'); }); }); describe('emitNotification', () => { test('inserts an unread notification row for the user', async () => { const t = convexTest(schema, modules); const userId = await t.run( async (ctx) => await ctx.db.insert('users', { email: 'emit@b.c', name: 'Emit' }), ); const notificationId = await t.mutation(internal.notifications.emit, { userId, kind: 'agent_turn_finished', title: 'Agent finished', body: 'Your agent completed its turn.', link: '/threads/abc', }); const notification = await t.run( async (ctx) => await ctx.db.get(notificationId), ); expect(notification).not.toBeNull(); expect(notification?.userId).toBe(userId); expect(notification?.kind).toBe('agent_turn_finished'); expect(notification?.title).toBe('Agent finished'); expect(notification?.body).toBe('Your agent completed its turn.'); expect(notification?.link).toBe('/threads/abc'); expect(notification?.readAt).toBeUndefined(); expect(notification?.emailedAt).toBeUndefined(); expect(typeof notification?.createdAt).toBe('number'); }); test('does not email when emailEnabled is false', async () => { const t = convexTest(schema, modules); const userId = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'noemail@b.c', name: 'NoEmail', }); await ctx.db.insert('notificationPreferences', { userId, emailEnabled: false, updatedAt: Date.now(), }); return userId; }); const notificationId = await t.mutation(internal.notifications.emit, { userId, kind: 'sync_failed', title: 'Sync failed', body: 'Your sync failed.', }); // No email should have been scheduled; draining is a safe no-op. await t.finishAllScheduledFunctions(() => undefined); const notification = await t.run( async (ctx) => await ctx.db.get(notificationId), ); expect(notification?.emailedAt).toBeUndefined(); }); test('getForEmail returns the recipient shape and markEmailed patches emailedAt', async () => { const t = convexTest(schema, modules); const { notificationId } = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'foremail@b.c', name: 'ForEmail', }); const notificationId = await ctx.db.insert('notifications', { userId, kind: 'agent_needs_input', title: 'Needs input', body: 'The agent needs your input.', link: '/threads/xyz', createdAt: Date.now(), }); return { notificationId }; }); const forEmail = await t.query(internal.notifications.getForEmail, { notificationId, }); expect(forEmail).toEqual({ to: 'foremail@b.c', title: 'Needs input', body: 'The agent needs your input.', link: '/threads/xyz', }); await t.mutation(internal.notifications.markEmailed, { notificationId }); const notification = await t.run( async (ctx) => await ctx.db.get(notificationId), ); expect(typeof notification?.emailedAt).toBe('number'); }); }); describe('emit points', () => { beforeAll(() => { process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; }); const seedSpoon = async (t: ReturnType) => await t.run(async (ctx) => { const now = Date.now(); const ownerId = await ctx.db.insert('users', { email: 'emit-points@example.com', name: 'Emit Points Owner', }); // Disable email so emitNotification does not schedule the best-effort // node email action, which would log asynchronously and race teardown. await ctx.db.insert('notificationPreferences', { userId: ownerId, emailEnabled: false, updatedAt: now, }); const spoonId = await ctx.db.insert('spoons', { ownerId, name: 'Emit Points Spoon', provider: 'github', upstreamOwner: 'upstream', upstreamRepo: 'editor', upstreamDefaultBranch: 'main', upstreamUrl: 'https://github.com/upstream/editor', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://github.com/team/editor-spoon', visibility: 'private', maintenanceMode: 'watch', syncCadence: 'daily', productionRefStrategy: 'default_branch', status: 'active', createdAt: now, updatedAt: now, }); return { ownerId, spoonId }; }); const maintenanceArgs = ( ownerId: Id<'users'>, spoonId: Id<'spoons'>, dedupKey: string, ) => ({ spoonId, ownerId, source: 'upstream_update' as const, title: 'Upstream maintenance review', summary: 'Upstream advanced; review recommended.', upstreamTo: 'headsha', dedupKey, jobType: 'maintenance_review' as const, }); test('createMaintenanceThread emits exactly one maintenance_thread notification, and the dedup call does not add another', async () => { const t = convexTest(schema, modules); const { ownerId, spoonId } = await seedSpoon(t); await t.mutation( internal.threads.createMaintenanceThread, maintenanceArgs(ownerId, spoonId, 'base:head'), ); expect(await countNotifications(t, ownerId, 'maintenance_thread')).toBe(1); // Second call with the same dedupKey reuses the existing thread and must // NOT emit a second notification. await t.mutation( internal.threads.createMaintenanceThread, maintenanceArgs(ownerId, spoonId, 'base:head'), ); expect(await countNotifications(t, ownerId, 'maintenance_thread')).toBe(1); }); const seedThreadedJob = async ( t: ReturnType, ownerId: Id<'users'>, spoonId: Id<'spoons'>, ) => await t.run(async (ctx) => { const now = Date.now(); const threadId = await ctx.db.insert('threads', { ownerId, spoonId, title: 'Agent turn thread', summary: 'Working.', source: 'user_request', status: 'running', priority: 'normal', createdAt: now, updatedAt: now, }); const requestId = await ctx.db.insert('agentRequests', { spoonId, ownerId, prompt: 'x', status: 'running', createdAt: now, updatedAt: now, }); const jobId = await ctx.db.insert('agentJobs', { spoonId, ownerId, agentRequestId: requestId, threadId, status: 'running', prompt: 'x', runtime: 'opencode', baseBranch: 'main', workBranch: 'spoon/x', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://github.com/team/editor-spoon', upstreamOwner: 'upstream', upstreamRepo: 'editor', selectedSecretIds: [], model: '', reasoningEffort: 'medium', claimedBy: 'worker-1', createdAt: now, updatedAt: now, }); return jobId; }); test('updateStatus to changes_ready emits one agent_turn_finished, and a repeat call at the same status does not add another', async () => { const t = convexTest(schema, modules); const { ownerId, spoonId } = await seedSpoon(t); const jobId = await seedThreadedJob(t, ownerId, spoonId); await t.mutation(api.agentJobs.updateStatus, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, status: 'changes_ready', summary: 'Draft is ready.', }); expect(await countNotifications(t, ownerId, 'agent_turn_finished')).toBe(1); // Same status again: not a transition, so no second notification. await t.mutation(api.agentJobs.updateStatus, { workerToken: 'test-worker-token', workerId: 'worker-1', jobId, status: 'changes_ready', summary: 'Draft is ready.', }); expect(await countNotifications(t, ownerId, 'agent_turn_finished')).toBe(1); }); test('setConnectionStatusByInstallation to a non-active status emits one connection_needs_reauth for the owner', async () => { const t = convexTest(schema, modules); const userId = await t.run(async (ctx) => { const now = Date.now(); const userId = await ctx.db.insert('users', { email: 'reauth@example.com', name: 'Reauth Owner', }); await ctx.db.insert('notificationPreferences', { userId, emailEnabled: false, updatedAt: now, }); await ctx.db.insert('gitConnections', { userId, provider: 'github', displayName: 'GitHub installation 77', installationId: '77', status: 'active', connectedAt: now, updatedAt: now, }); return userId; }); await t.mutation(internal.github.setConnectionStatusByInstallation, { installationId: '77', status: 'revoked', }); expect(await countNotifications(t, userId, 'connection_needs_reauth')).toBe( 1, ); }); }); describe('inbox query + mark-read mutations', () => { const seedInbox = async (t: ReturnType) => await t.run(async (ctx) => { const userA = await ctx.db.insert('users', { email: 'inbox-a@example.com', name: 'Inbox A', }); const userB = await ctx.db.insert('users', { email: 'inbox-b@example.com', name: 'Inbox B', }); const base = 1_000_000; // Three notifications for A with ascending createdAt (a3 is newest). const a1 = await ctx.db.insert('notifications', { userId: userA, kind: 'agent_turn_finished', title: 'A one', body: 'first', createdAt: base + 1, }); const a2 = await ctx.db.insert('notifications', { userId: userA, kind: 'agent_turn_finished', title: 'A two', body: 'second', createdAt: base + 2, }); const a3 = await ctx.db.insert('notifications', { userId: userA, kind: 'agent_needs_input', title: 'A three', body: 'third', createdAt: base + 3, }); // One notification for B, which must never leak to A. const b1 = await ctx.db.insert('notifications', { userId: userB, kind: 'sync_failed', title: 'B one', body: 'b first', createdAt: base + 4, }); return { userA, userB, a1, a2, a3, b1 }; }); test('listMine returns only the caller notifications, newest-first, capped by limit', async () => { const t = convexTest(schema, modules); const { userA, a1, a2, a3 } = await seedInbox(t); const all = await authed(t, userA).query(api.notifications.listMine, {}); expect(all.map((n) => n._id)).toEqual([a3, a2, a1]); // No notification belongs to any other user. expect(all.every((n) => n.userId === userA)).toBe(true); const limited = await authed(t, userA).query(api.notifications.listMine, { limit: 2, }); expect(limited.map((n) => n._id)).toEqual([a3, a2]); }); test('unreadCount counts only the caller unread notifications', async () => { const t = convexTest(schema, modules); const { userA, userB } = await seedInbox(t); expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( 3, ); // B has a single unread notification; A's three do not leak in. expect(await authed(t, userB).query(api.notifications.unreadCount, {})).toBe( 1, ); }); test('markRead marks the caller notification and drops unreadCount', async () => { const t = convexTest(schema, modules); const { userA, a1 } = await seedInbox(t); await authed(t, userA).mutation(api.notifications.markRead, { notificationId: a1, }); const notification = await t.run(async (ctx) => await ctx.db.get(a1)); expect(typeof notification?.readAt).toBe('number'); expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( 2, ); }); test('markRead rejects a notification owned by another user', async () => { const t = convexTest(schema, modules); const { userA, b1 } = await seedInbox(t); await expect( authed(t, userA).mutation(api.notifications.markRead, { notificationId: b1, }), ).rejects.toThrow('Notification not found.'); // B's notification is untouched and B still has one unread. const notification = await t.run(async (ctx) => await ctx.db.get(b1)); expect(notification?.readAt).toBeUndefined(); }); test('markAllRead zeroes the caller unread count but leaves other users untouched', async () => { const t = convexTest(schema, modules); const { userA, userB } = await seedInbox(t); await authed(t, userA).mutation(api.notifications.markAllRead, {}); expect(await authed(t, userA).query(api.notifications.unreadCount, {})).toBe( 0, ); // B's unread notification is left alone. expect(await authed(t, userB).query(api.notifications.unreadCount, {})).toBe( 1, ); }); });