// @vitest-environment node import { describe, it, expect, beforeEach } from 'vitest'; import { Repository } from './repository.js'; function makeRepo(): Repository { // new Repository(':memory:') calls initSchema() in the constructor — no separate call needed. return new Repository(':memory:'); } describe('A2A revocation repo primitives', () => { let repo: Repository; beforeEach(() => { repo = makeRepo(); }); it('getA2aDelegationById returns the row or undefined', () => { repo.createA2aDelegation({ id: 'd1', userId: 'u1', clientId: 'c1', grantId: 'g1', grantedSpaceIds: ['s1'], grantedSkills: ['chat'], audience: 'aud', expiresAt: null, revokedAt: null, }); expect(repo.getA2aDelegationById('d1')?.grantId).toBe('g1'); expect(repo.getA2aDelegationById('nope')).toBeUndefined(); }); it('listNonTerminalA2aTasksByGrant returns only non-terminal tasks for the grant', async () => { // createJob is async and returns Promise; use job.id for the jobId link. const job = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'test' }); const jobRunning = job.id; repo.saveA2aTask({ id: 't-working', contextId: null, jobId: jobRunning, localTaskId: null, payload: { status: { state: 'working' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 't-done', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'completed' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 't-other', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'working' } }, grantId: 'g2' }); const rows = repo.listNonTerminalA2aTasksByGrant('g1'); expect(rows.map(r => r.id)).toEqual(['t-working']); }); it('cancelA2aLinkedJob moves a queued job to cancelled and is idempotent', async () => { const job = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'test' }); const jid = job.id; // starts as 'queued' expect(repo.cancelA2aLinkedJob(jid)).toBe(true); expect((await repo.getJob(jid))?.status).toBe('cancelled'); expect(repo.cancelA2aLinkedJob(jid)).toBe(false); // already terminal — no change }); it('cancelA2aLinkedJob cancels a retry-status job (non-terminal, waiting for requeue)', async () => { const job = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'test' }); const jid = job.id; // Force the job into 'retry' status directly — this is the state set by scheduleRetryOrFail() // before the worker requeues it. Cancelling it must prevent the requeue from running the job. (repo as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }) .db.prepare("UPDATE jobs SET status = 'retry' WHERE id = ?").run(jid); expect((await repo.getJob(jid))?.status).toBe('retry'); // cancelA2aLinkedJob must now reach 'retry' jobs expect(repo.cancelA2aLinkedJob(jid)).toBe(true); expect((await repo.getJob(jid))?.status).toBe('cancelled'); // idempotent — already terminal expect(repo.cancelA2aLinkedJob(jid)).toBe(false); }); it('listLiveA2aDelegationsForClient excludes revoked and expired', () => { const now = '2026-07-02T00:00:00.000Z'; repo.createA2aDelegation({ id: 'live', userId: 'u1', clientId: 'c1', grantId: 'g-live', grantedSpaceIds: [], grantedSkills: [], audience: null, expiresAt: null, revokedAt: null }); repo.createA2aDelegation({ id: 'revoked', userId: 'u1', clientId: 'c1', grantId: 'g-rev', grantedSpaceIds: [], grantedSkills: [], audience: null, expiresAt: null, revokedAt: now }); repo.createA2aDelegation({ id: 'expired', userId: 'u1', clientId: 'c1', grantId: 'g-exp', grantedSpaceIds: [], grantedSkills: [], audience: null, expiresAt: '2020-01-01T00:00:00.000Z', revokedAt: null }); expect(repo.listLiveA2aDelegationsForClient('c1', now).map(d => d.id)).toEqual(['live']); }); it('countNonTerminalA2aTasksByGrant counts only non-terminal tasks for the grant', async () => { // Seed: working + completed + rejected + input-required for g1, plus working for g2 repo.saveA2aTask({ id: 'cnt-working', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'working' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 'cnt-completed', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'completed' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 'cnt-rejected', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'rejected' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 'cnt-input-required', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'input-required' } }, grantId: 'g1' }); repo.saveA2aTask({ id: 'cnt-g2-working', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'working' } }, grantId: 'g2' }); // g1: only the 'working' task counts — completed, rejected, and input-required are excluded. // input-required is parked (no live worker/LLM) so must not block new task creation. expect(repo.countNonTerminalA2aTasksByGrant('g1')).toBe(1); // g2: one non-terminal task expect(repo.countNonTerminalA2aTasksByGrant('g2')).toBe(1); // unknown grant: zero expect(repo.countNonTerminalA2aTasksByGrant('nope')).toBe(0); // listNonTerminalA2aTasksByGrant must still include input-required for revocation coverage. const listed = repo.listNonTerminalA2aTasksByGrant('g1').map(r => r.id); expect(listed).toContain('cnt-working'); expect(listed).toContain('cnt-input-required'); expect(listed).not.toContain('cnt-completed'); expect(listed).not.toContain('cnt-rejected'); }); it('countNonTerminalA2aTasksByGrant counts an input-required task once its job resumes (no ASK-park bypass)', async () => { // A parked input-required task (job still waiting_human) must NOT count — holds no capacity. const parked = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'p' }); (repo as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }) .db.prepare("UPDATE jobs SET status = 'waiting_human' WHERE id = ?").run(parked.id); repo.saveA2aTask({ id: 'ir-parked', contextId: null, jobId: parked.id, localTaskId: null, payload: { status: { state: 'input-required' } }, grantId: 'gx' }); expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(0); // The human answers → job resumes to 'running' while the a2a_tasks payload still reads // 'input-required'. That resumed task consumes capacity again and MUST count. (repo as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }) .db.prepare("UPDATE jobs SET status = 'running' WHERE id = ?").run(parked.id); expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(1); // An input-required task whose job is gone (jobId null) stays excluded — nothing running. repo.saveA2aTask({ id: 'ir-nojob', contextId: null, jobId: null, localTaskId: null, payload: { status: { state: 'input-required' } }, grantId: 'gx' }); expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(1); }); // sonnet review Finding 2: 二重失効で元の revoked_at を上書きしない it('revokeA2aDelegation does not overwrite an existing revoked_at', () => { const first = '2026-07-02T00:00:00.000Z'; const second = '2026-07-03T00:00:00.000Z'; repo.createA2aDelegation({ id: 'd1', userId: 'u1', clientId: 'c1', grantId: 'g1', grantedSpaceIds: [], grantedSkills: [], audience: null, expiresAt: null, revokedAt: null }); repo.revokeA2aDelegation('d1', first); repo.revokeA2aDelegation('d1', second); // 2 回目は no-op であるべき expect(repo.getA2aDelegationById('d1')?.revokedAt).toBe(first); }); });