godcrm/backend/services/bdd/__tests__/completionGate.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

280 lines
10 KiB
JavaScript

// ADR-0002 §8 Phase 3 — completionGate unit tests.
//
// Covers the three required scenarios:
// - all Must verified → gate ok
// - partial verified → gate fails 409 with blockers list
// - no Must criteria → gate ok (no-op for tickets without Must rows)
// Plus: ambiguous tickets — recompute on a ticket that shares a spec with
// other tickets must not pull in criteria that point to a different ticket_id.
import { describe, it, expect, beforeEach, vi } from 'vitest';
const dbAllMock = vi.fn();
const dbGetMock = vi.fn();
const dbRunMock = vi.fn();
vi.mock('../../../database/connection.js', () => ({
dbAll: (...args) => dbAllMock(...args),
dbGet: (...args) => dbGetMock(...args),
dbRun: (...args) => dbRunMock(...args),
isPostgres: () => true,
safeJsonParse: (v, d = null) => {
if (v == null) return d;
if (typeof v === 'object') return v;
try { return JSON.parse(v); } catch { return d; }
},
}));
vi.mock('../../../utils/logger.js', () => ({
apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const {
aggregateMustCriteria,
checkCompletionGate,
computeCriteriaProgress,
recomputeAndPersistProgress,
onCriterionChange,
formatGateError,
} = await import('../completionGate.js');
beforeEach(() => {
dbAllMock.mockReset();
dbGetMock.mockReset();
dbRunMock.mockReset();
});
// ===== aggregateMustCriteria =====
describe('aggregateMustCriteria', () => {
it('returns zeros for ticket with no criteria', async () => {
dbAllMock.mockResolvedValueOnce([]);
const r = await aggregateMustCriteria(42);
expect(r).toEqual({ must_total: 0, must_verified: 0, blockers: [] });
});
it('counts all-verified Must rows', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 42, priority: 'must', status: 'verified', code: 'A1', title: 'one' } },
{ id: 2, data: { ticket_id: 42, priority: 'must', status: 'verified', code: 'A2', title: 'two' } },
]);
const r = await aggregateMustCriteria(42);
expect(r).toEqual({ must_total: 2, must_verified: 2, blockers: [] });
});
it('lists unverified Must rows as blockers', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 10, data: { ticket_id: 42, priority: 'must', status: 'verified', code: 'A1', title: 'one' } },
{ id: 11, data: { ticket_id: 42, priority: 'must', status: 'pending', code: 'A2', title: 'two' } },
{ id: 12, data: { ticket_id: 42, priority: 'must', status: 'failed', code: 'A3', title: 'three' } },
]);
const r = await aggregateMustCriteria(42);
expect(r.must_total).toBe(3);
expect(r.must_verified).toBe(1);
expect(r.blockers).toEqual([
{ id: 11, code: 'A2', title: 'two', status: 'pending' },
{ id: 12, code: 'A3', title: 'three', status: 'failed' },
]);
});
it('handles serialized JSON data column', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 5, data: JSON.stringify({ ticket_id: 7, priority: 'must', status: 'verified' }) },
]);
const r = await aggregateMustCriteria(7);
expect(r.must_total).toBe(1);
expect(r.must_verified).toBe(1);
});
});
// ===== checkCompletionGate =====
describe('checkCompletionGate (G4)', () => {
it('passes when all Must verified', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 1, priority: 'must', status: 'verified' } },
{ id: 2, data: { ticket_id: 1, priority: 'must', status: 'verified' } },
]);
const r = await checkCompletionGate(1);
expect(r.ok).toBe(true);
expect(r.must_total).toBe(2);
expect(r.must_verified).toBe(2);
expect(r.blockers).toEqual([]);
});
it('blocks with blockers when partial', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 1, priority: 'must', status: 'verified' } },
{ id: 2, data: { ticket_id: 1, priority: 'must', status: 'pending', code: 'X', title: 'pending' } },
]);
const r = await checkCompletionGate(1);
expect(r.ok).toBe(false);
expect(r.must_total).toBe(2);
expect(r.must_verified).toBe(1);
expect(r.blockers).toHaveLength(1);
expect(r.blockers[0]).toMatchObject({ id: 2, code: 'X', title: 'pending' });
});
it('passes when no Must criteria exist (no-op)', async () => {
dbAllMock.mockResolvedValueOnce([]); // empty
const r = await checkCompletionGate(99);
expect(r.ok).toBe(true);
expect(r.must_total).toBe(0);
expect(r.blockers).toEqual([]);
});
});
// ===== formatGateError =====
describe('formatGateError', () => {
it('shapes the 409 body with code + counts + failed list', () => {
const r = formatGateError({
ok: false,
must_total: 3,
must_verified: 1,
blockers: [{ id: 7, code: 'A2', title: 'two', status: 'pending' }],
});
expect(r).toEqual({
code: 'MUST_CRITERIA_INCOMPLETE',
must_total: 3,
must_verified: 1,
failed: [{ id: 7, code: 'A2', title: 'two', status: 'pending' }],
});
});
});
// ===== computeCriteriaProgress =====
describe('computeCriteriaProgress (G6)', () => {
it('returns string "M/N" when must_total > 0', async () => {
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 1, priority: 'must', status: 'verified' } },
{ id: 2, data: { ticket_id: 1, priority: 'must', status: 'pending' } },
{ id: 3, data: { ticket_id: 1, priority: 'must', status: 'verified' } },
]);
const r = await computeCriteriaProgress(1);
expect(r.progress).toBe('2/3');
expect(r.must_total).toBe(3);
expect(r.must_verified).toBe(2);
});
it('returns empty string when no Must criteria', async () => {
dbAllMock.mockResolvedValueOnce([]);
const r = await computeCriteriaProgress(99);
expect(r.progress).toBe('');
expect(r.must_total).toBe(0);
});
});
// ===== recomputeAndPersistProgress =====
describe('recomputeAndPersistProgress', () => {
it('writes new fields when progress differs from stored value', async () => {
dbGetMock.mockResolvedValueOnce({ id: 100, data: { criteria_progress: '0/0', state: 24276 } });
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 100, priority: 'must', status: 'verified' } },
{ id: 2, data: { ticket_id: 100, priority: 'must', status: 'pending' } },
]);
const r = await recomputeAndPersistProgress(100);
expect(r.changed).toBe(true);
expect(r.progress).toBe('1/2');
expect(dbRunMock).toHaveBeenCalledTimes(1);
const updateArg = dbRunMock.mock.calls[0][1];
const writtenJson = JSON.parse(updateArg[0]);
expect(writtenJson.criteria_progress).toBe('1/2');
expect(writtenJson.must_total).toBe(2);
expect(writtenJson.must_verified).toBe(1);
});
it('skips UPDATE when value already matches', async () => {
dbGetMock.mockResolvedValueOnce({
id: 100,
data: { criteria_progress: '2/2', must_total: 2, must_verified: 2 },
});
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 100, priority: 'must', status: 'verified' } },
{ id: 2, data: { ticket_id: 100, priority: 'must', status: 'verified' } },
]);
const r = await recomputeAndPersistProgress(100);
expect(r.changed).toBe(false);
expect(dbRunMock).not.toHaveBeenCalled();
});
it('returns null when ticket row not found', async () => {
dbGetMock.mockResolvedValueOnce(null);
const r = await recomputeAndPersistProgress(999);
expect(r).toBeNull();
expect(dbRunMock).not.toHaveBeenCalled();
});
});
// ===== onCriterionChange — ambiguous criterion isolation =====
describe('onCriterionChange — does not leak between tickets', () => {
it('recomputes only the old + new ticket_id when criterion is rebound', async () => {
// Ticket 50 has criterion 1; ticket 60 will receive criterion 1 after move.
// Other criteria pointing to ticket 50/60 must not affect each other.
// Sequence: oldData ticket=50, newData ticket=60.
// First recompute (ticket 50): query returns one remaining Must row.
// Second recompute (ticket 60): query returns the moved row.
dbGetMock.mockResolvedValueOnce({ id: 50, data: { criteria_progress: '1/1' } });
dbAllMock.mockResolvedValueOnce([
// Just one Must left on ticket 50 after the move (verified).
{ id: 99, data: { ticket_id: 50, priority: 'must', status: 'verified' } },
]);
dbGetMock.mockResolvedValueOnce({ id: 60, data: { criteria_progress: '0/0' } });
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 60, priority: 'must', status: 'pending' } },
]);
await onCriterionChange(
{ ticket_id: 50, priority: 'must', status: 'verified' },
{ ticket_id: 60, priority: 'must', status: 'pending' }
);
// Both tickets received an UPDATE.
expect(dbRunMock).toHaveBeenCalledTimes(2);
const firstWrite = JSON.parse(dbRunMock.mock.calls[0][1][0]);
const secondWrite = JSON.parse(dbRunMock.mock.calls[1][1][0]);
expect(firstWrite.criteria_progress).toBe('1/1');
expect(secondWrite.criteria_progress).toBe('0/1');
});
it('recomputes both old and new tickets on rebind even if status unchanged', async () => {
dbGetMock.mockResolvedValueOnce({ id: 7, data: {} });
dbAllMock.mockResolvedValueOnce([]);
dbGetMock.mockResolvedValueOnce({ id: 8, data: {} });
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 8, priority: 'must', status: 'verified' } },
]);
await onCriterionChange(
{ ticket_id: 7, priority: 'must', status: 'verified' },
{ ticket_id: 8, priority: 'must', status: 'verified' }
);
expect(dbRunMock).toHaveBeenCalledTimes(2);
});
it('handles INSERT (oldData=null) — recomputes only new ticket', async () => {
dbGetMock.mockResolvedValueOnce({ id: 30, data: {} });
dbAllMock.mockResolvedValueOnce([
{ id: 1, data: { ticket_id: 30, priority: 'must', status: 'pending' } },
]);
await onCriterionChange(null, { ticket_id: 30, priority: 'must', status: 'pending' });
expect(dbRunMock).toHaveBeenCalledTimes(1);
});
it('handles DELETE (newData=null) — recomputes only old ticket', async () => {
dbGetMock.mockResolvedValueOnce({ id: 40, data: { criteria_progress: '1/1' } });
dbAllMock.mockResolvedValueOnce([]); // criterion is gone
await onCriterionChange({ ticket_id: 40, priority: 'must', status: 'verified' }, null);
expect(dbRunMock).toHaveBeenCalledTimes(1);
const written = JSON.parse(dbRunMock.mock.calls[0][1][0]);
expect(written.criteria_progress).toBe('');
expect(written.must_total).toBe(0);
});
});