133 lines
7.7 KiB
TypeScript
133 lines
7.7 KiB
TypeScript
// @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<Job>; 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);
|
|
});
|
|
});
|