import { convexTest } from 'convex-test'; import { describe, expect, test } from 'vitest'; import type { Id } from '../../convex/_generated/dataModel.js'; import { api } from '../../convex/_generated/api.js'; import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); const createUser = async (t: ReturnType, email: string) => await t.mutation(async (ctx) => { return await ctx.db.insert('users', { email, name: email }); }); const authed = (t: ReturnType, userId: string) => t.withIdentity({ subject: `${userId}|session`, issuer: 'https://convex.test', }); const spoonInput = { name: 'Editor Spoon', provider: 'gitea' as const, upstreamOwner: 'upstream', upstreamRepo: 'editor', upstreamDefaultBranch: 'main', upstreamUrl: 'https://git.example.com/upstream/editor', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', visibility: 'private' as const, maintenanceMode: 'watch' as const, syncCadence: 'daily' as const, productionRefStrategy: 'default_branch' as const, }; const createAgentJob = async ( t: ReturnType, args: { ownerId: Id<'users'>; spoonId: Id<'spoons'>; status: 'running' | 'failed' | 'cancelled'; workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired'; }, ) => await t.mutation(async (ctx) => { const now = Date.now(); const requestId = await ctx.db.insert('agentRequests', { spoonId: args.spoonId, ownerId: args.ownerId, prompt: 'Clean this workspace', status: 'running', createdAt: now, updatedAt: now, }); const jobId = await ctx.db.insert('agentJobs', { spoonId: args.spoonId, ownerId: args.ownerId, agentRequestId: requestId, status: args.status, prompt: 'Clean this workspace', runtime: 'opencode', workspaceStatus: args.workspaceStatus, baseBranch: 'main', workBranch: 'spoon/test', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://git.example.com/team/editor-spoon', upstreamOwner: 'upstream', upstreamRepo: 'editor', selectedSecretIds: [], model: 'openai/gpt-5.1-codex', reasoningEffort: 'medium', createdAt: now, updatedAt: now, }); await ctx.db.patch(requestId, { agentJobId: jobId }); await ctx.db.insert('agentJobMessages', { jobId, spoonId: args.spoonId, ownerId: args.ownerId, role: 'assistant', content: 'done', status: 'completed', createdAt: now, updatedAt: now, }); return jobId; }); describe('convex-test harness', () => { test('boots and executes against the project schema', async () => { const t = convexTest(schema, modules); expect(await t.run(() => Promise.resolve(42))).toBe(42); }); test('requires authentication to create a Spoon', async () => { const t = convexTest(schema, modules); await expect( t.mutation(api.spoons.createManual, spoonInput), ).rejects.toThrow('Not authenticated.'); }); test('creates and lists Spoons for the current user', async () => { const t = convexTest(schema, modules); const userId = await createUser(t, 'one@example.com'); const session = authed(t, userId); const spoonId = await session.mutation(api.spoons.createManual, spoonInput); const spoons = await session.query(api.spoons.listMine, {}); expect(spoons).toHaveLength(1); expect(spoons[0]?._id).toBe(spoonId); expect(spoons[0]?.ownerId).toBe(userId); }); test('does not allow reading another user’s Spoon', async () => { const t = convexTest(schema, modules); const ownerId = await createUser(t, 'owner@example.com'); const otherId = await createUser(t, 'other@example.com'); const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); await expect( authed(t, otherId).query(api.spoons.get, { spoonId }), ).rejects.toThrow('Spoon not found.'); }); test('requires Spoon ownership for agent requests', async () => { const t = convexTest(schema, modules); const ownerId = await createUser(t, 'owner@example.com'); const otherId = await createUser(t, 'other@example.com'); const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); await expect( authed(t, otherId).mutation(api.agentRequests.create, { spoonId, prompt: 'Add a settings page', }), ).rejects.toThrow('Spoon not found.'); }); test('deletes terminal workspaces and associated rows', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'failed', workspaceStatus: 'failed', }); await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); const messages = await t.run( async (ctx) => await ctx.db .query('agentJobMessages') .withIndex('by_job', (q) => q.eq('jobId', jobId)) .collect(), ); expect(job).toBeNull(); expect(messages).toHaveLength(0); }); test('does not delete active workspaces', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'running', workspaceStatus: 'active', }); await expect( authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }), ).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces'); }); test('does not delete another user’s workspace', async () => { const t = convexTest(schema, modules); const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>; const spoonId = await authed(t, ownerId).mutation( api.spoons.createManual, spoonInput, ); const jobId = await createAgentJob(t, { ownerId, spoonId, status: 'cancelled', workspaceStatus: 'stopped', }); await expect( authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }), ).rejects.toThrow('Agent job not found.'); }); });