Files
spoon/packages/backend/tests/unit/harness.test.ts
T
Gabriel Brown d207b8b0b8
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s
Add features & update project
2026-06-23 02:06:58 -04:00

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof convexTest>, email: string) =>
await t.mutation(async (ctx) => {
return await ctx.db.insert('users', { email, name: email });
});
const authed = (t: ReturnType<typeof convexTest>, 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 githubSpoonInput = {
...spoonInput,
provider: 'github' as const,
upstreamUrl: 'https://github.com/upstream/editor',
forkUrl: 'https://github.com/team/editor-spoon',
};
const createAgentJob = async (
t: ReturnType<typeof convexTest>,
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('lists effective drift after ignored upstream changes', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
const now = Date.now();
await ctx.db.insert('spoonRepositoryStates', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamFullName: 'upstream/editor',
forkFullName: 'team/editor-spoon',
upstreamDefaultBranch: 'main',
forkDefaultBranch: 'main',
upstreamHeadSha: 'upstream-head',
forkHeadSha: 'fork-head',
upstreamAheadBy: 2,
forkAheadBy: 1,
status: 'diverged',
openForkPullRequestCount: 0,
openUpstreamPullRequestCount: 0,
refreshedAt: now,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('ignoredUpstreamChanges', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamTo: 'upstream-head',
commitShas: ['abc123'],
reason: 'irrelevant',
decidedBy: 'user',
createdAt: now,
});
});
const spoons = await authed(t, ownerId).query(
api.spoons.listMineWithState,
{},
);
expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
});
test('does not allow reading another users 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('thread notes are completed when no workspace handles them', 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,
githubSpoonInput,
);
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Manual note',
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const messageId = await authed(t, ownerId).mutation(
api.threads.appendUserMessage,
{
threadId,
content: 'extra context',
},
);
const message = await t.run(async (ctx) => await ctx.db.get(messageId));
expect(message?.status).toBe('completed');
});
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 users 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.');
});
test('queues a new thread job after the previous job is terminal', 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,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
await ctx.db.insert('aiProviderProfiles', {
ownerId,
name: 'Test provider',
provider: 'openai',
authType: 'none',
defaultModel: 'gpt-5.1-codex',
modelOptions: ['gpt-5.1-codex'],
reasoningEffort: 'medium',
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Retryable thread',
summary: 'try again',
source: 'user_request',
status: 'failed',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const failedJobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'failed',
workspaceStatus: 'failed',
});
await t.mutation(async (ctx) => {
await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
});
const newJobId = await authed(t, ownerId).mutation(
api.agentJobs.createForThread,
{
threadId,
jobType: 'user_change',
},
);
expect(newJobId).not.toBe(failedJobId);
});
});