import { convexTest } from 'convex-test'; import { describe, expect, test } from 'vitest'; import type { Id } from '../../convex/_generated/dataModel.js'; import { internal } from '../../convex/_generated/api.js'; import schema from '../../convex/schema'; const modules = import.meta.glob('../../convex/**/*.*s'); type SeedOptions = { profile?: 'valid' | 'disabled' | 'missing-credentials' | 'none'; settingsEnabled?: boolean; runtime?: 'opencode' | 'openai_direct'; secret?: 'valid' | 'foreign'; }; const seedQueuedJob = async ( t: ReturnType, options: SeedOptions = {}, ) => await t.mutation(async (ctx) => { const now = Date.now(); const ownerId = await ctx.db.insert('users', { email: 'owner@example.com', name: 'Owner', }); const spoonId = await ctx.db.insert('spoons', { ownerId, name: 'Editor 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, }); await ctx.db.insert('spoonAgentSettings', { spoonId, ownerId, enabled: options.settingsEnabled ?? true, runtime: 'opencode', defaultBaseBranch: 'main', branchPrefix: 'spoon/', agentModel: 'gpt-5.1-codex', reasoningEffort: 'medium', maxJobDurationMs: 60_000, maxOutputBytes: 1_000_000, createdAt: now, updatedAt: now, }); const profileKind = options.profile ?? 'none'; const aiProviderProfileId = profileKind === 'none' ? undefined : await ctx.db.insert('aiProviderProfiles', { ownerId, name: 'Test provider', provider: 'openai', authType: profileKind === 'missing-credentials' ? 'api_key' : 'none', defaultModel: 'gpt-5.1-codex', modelOptions: ['gpt-5.1-codex'], reasoningEffort: 'medium', enabled: profileKind !== 'disabled', createdAt: now, updatedAt: now, }); const selectedSecretIds: Id<'spoonSecrets'>[] = []; if (options.secret) { const secretOwnerId = options.secret === 'foreign' ? await ctx.db.insert('users', { email: 'other@example.com', name: 'Other', }) : ownerId; selectedSecretIds.push( await ctx.db.insert('spoonSecrets', { spoonId, ownerId: secretOwnerId, name: 'TOKEN', encryptedValue: 'encrypted', createdAt: now, updatedAt: now, }), ); } const requestId = await ctx.db.insert('agentRequests', { spoonId, ownerId, prompt: 'Fix the editor', status: 'queued', createdAt: now, updatedAt: now, }); const jobId = await ctx.db.insert('agentJobs', { spoonId, ownerId, agentRequestId: requestId, status: 'queued', prompt: 'Fix the editor', runtime: options.runtime ?? 'opencode', workspaceStatus: 'not_started', baseBranch: 'main', workBranch: 'spoon/fix-editor', forkOwner: 'team', forkRepo: 'editor-spoon', forkUrl: 'https://github.com/team/editor-spoon', upstreamOwner: 'upstream', upstreamRepo: 'editor', selectedSecretIds, aiProviderProfileId, model: 'gpt-5.1-codex', reasoningEffort: 'medium', createdAt: now, updatedAt: now, }); await ctx.db.patch(requestId, { agentJobId: jobId }); return { jobId, requestId, spoonId }; }); const expectFailedClaim = async ( t: ReturnType, jobId: Id<'agentJobs'>, ) => { const result = await t.mutation(internal.agentJobs.claimNextInternal, { workerId: 'w1', }); expect(result).toBeNull(); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(job?.status).toBe('failed'); expect(job?.error).toBeTruthy(); }; describe('claimNextInternal validation', () => { test('fails a queued job when its owner has no configured profile', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t); await expectFailedClaim(t, jobId); }); test('claims a fully valid queued job', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'valid' }); const result = await t.mutation(internal.agentJobs.claimNextInternal, { workerId: 'w1', }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(result).not.toBeNull(); expect(result?.aiProviderProfile?.enabled).toBe(true); expect(job?.status).toBe('claimed'); }); test('fails a queued job when its profile is disabled', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'disabled' }); await expectFailedClaim(t, jobId); }); test('fails a queued job when its profile is missing credentials', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'missing-credentials', }); await expectFailedClaim(t, jobId); }); test('fails a queued job when Spoon agent jobs are disabled', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'valid', settingsEnabled: false, }); await expectFailedClaim(t, jobId); }); test('fails a queued job with an unsupported legacy runtime', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'valid', runtime: 'openai_direct', }); await expectFailedClaim(t, jobId); }); test('fails a queued job with a secret owned by another user', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'valid', secret: 'foreign', }); await expectFailedClaim(t, jobId); }); test('fails a queued job whose Spoon was deleted', async () => { const t = convexTest(schema, modules); const { jobId, spoonId } = await seedQueuedJob(t, { profile: 'valid' }); await t.mutation(async (ctx) => await ctx.db.delete(spoonId)); await expectFailedClaim(t, jobId); }); });