import { convexTest } from 'convex-test'; import { beforeAll, describe, expect, test } from 'vitest'; import type { Id } from '../../convex/_generated/dataModel.js'; import { api, internal } from '../../convex/_generated/api.js'; import schema from '../../convex/schema'; import { encryptSecret } from '../../convex/secretCrypto'; const modules = import.meta.glob('../../convex/**/*.*s'); type SeedOptions = { profile?: | 'valid' | 'disabled' | 'missing-credentials' | 'encrypted-valid' | 'encrypted-malformed' | 'none'; settingsEnabled?: boolean; runtime?: 'opencode' | 'openai_direct'; secret?: 'valid' | 'foreign' | 'malformed'; }; 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' || profileKind.startsWith('encrypted-') ? 'api_key' : 'none', encryptedSecret: profileKind === 'encrypted-valid' ? encryptSecret('provider-secret') : profileKind === 'encrypted-malformed' ? 'malformed' : undefined, 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: options.secret === 'malformed' ? 'malformed' : encryptSecret('secret-value'), createdAt: now, updatedAt: now, }), ); } const requestId = await ctx.db.insert('agentRequests', { spoonId, ownerId, prompt: 'Fix the editor', status: 'queued', createdAt: now, updatedAt: now, }); const threadId = await ctx.db.insert('threads', { ownerId, spoonId, title: 'Fix the editor', source: 'user_request', status: 'queued', priority: 'normal', relatedAgentRequestId: requestId, createdAt: now, updatedAt: now, }); const jobId = await ctx.db.insert('agentJobs', { spoonId, ownerId, agentRequestId: requestId, threadId, 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 }); await ctx.db.patch(threadId, { latestAgentJobId: jobId }); return { jobId, requestId, spoonId, threadId }; }); const expectFailedClaimState = async ( t: ReturnType, jobId: Id<'agentJobs'>, requestId: Id<'agentRequests'>, threadId: Id<'threads'>, ) => { const state = await t.run(async (ctx) => ({ job: await ctx.db.get(jobId), request: await ctx.db.get(requestId), thread: await ctx.db.get(threadId), events: (await ctx.db.query('agentJobEvents').collect()).filter( (event) => event.jobId === jobId, ), })); expect(state.job?.status).toBe('failed'); expect(state.job?.error).toBeTruthy(); expect(state.request?.status).toBe('failed'); expect(state.thread?.status).toBe('failed'); expect(state.thread?.resolvedAt).toBeTypeOf('number'); expect(state.events).toEqual( expect.arrayContaining([ expect.objectContaining({ level: 'error', phase: 'queued', message: expect.stringContaining('Job could not be claimed:'), }), ]), ); }; const expectFailedClaim = async ( t: ReturnType, jobId: Id<'agentJobs'>, requestId: Id<'agentRequests'>, threadId: Id<'threads'>, ) => { const result = await t.mutation(internal.agentJobs.claimNextInternal, { workerId: 'w1', }); expect(result).toBeNull(); await expectFailedClaimState(t, jobId, requestId, threadId); }; beforeAll(() => { process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; process.env.SPOON_ENCRYPTION_KEY = 'test-encryption-key'; }); describe('claimNextInternal validation', () => { test('fails a queued job when its owner has no configured profile', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t); await expectFailedClaim(t, jobId, requestId, threadId); }); 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, requestId, threadId } = await seedQueuedJob(t, { profile: 'disabled', }); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a queued job when its profile is missing credentials', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'missing-credentials', }); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a queued job when Spoon agent jobs are disabled', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'valid', settingsEnabled: false, }); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a queued job with an unsupported legacy runtime', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'valid', runtime: 'openai_direct', }); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a queued job with a secret owned by another user', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'valid', secret: 'foreign', }); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a queued job whose Spoon was deleted', async () => { const t = convexTest(schema, modules); const { jobId, requestId, spoonId, threadId } = await seedQueuedJob(t, { profile: 'valid', }); await t.mutation(async (ctx) => await ctx.db.delete(spoonId)); await expectFailedClaim(t, jobId, requestId, threadId); }); test('fails a claim when provider credential decryption fails', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'encrypted-malformed', }); const result = await t.action(api.agentJobsNode.claimNextForWorker, { workerId: 'w1', workerToken: 'test-worker-token', }); expect(result).toBeNull(); await expectFailedClaimState(t, jobId, requestId, threadId); }); test('fails a claim when selected secret decryption fails', async () => { const t = convexTest(schema, modules); const { jobId, requestId, threadId } = await seedQueuedJob(t, { profile: 'valid', secret: 'malformed', }); const result = await t.action(api.agentJobsNode.claimNextForWorker, { workerId: 'w1', workerToken: 'test-worker-token', }); expect(result).toBeNull(); await expectFailedClaimState(t, jobId, requestId, threadId); }); test('returns decrypted provider credentials and selected secrets', async () => { const t = convexTest(schema, modules); const { jobId } = await seedQueuedJob(t, { profile: 'encrypted-valid', secret: 'valid', }); const result = await t.action(api.agentJobsNode.claimNextForWorker, { workerId: 'w1', workerToken: 'test-worker-token', }); const job = await t.run(async (ctx) => await ctx.db.get(jobId)); expect(result?.aiProviderProfile?.secret).toBe('provider-secret'); expect(result?.secrets).toEqual([ { name: 'TOKEN', value: 'secret-value' }, ]); expect(job?.status).toBe('claimed'); }); });