/** * Tickets API Tests — ADR-098: Ticket Status Endpoint * * Tests for: * PATCH /api/v3/tickets/:id/status — State machine validated status change * GET /api/v3/tickets/:id — Ticket with allowed transitions * POST /api/v3/tickets/dispatch — Dispatch subtask * GET /api/v3/tickets/chains/:chainId — Chain progress * GET /api/v3/tickets/agents/me/tasks — Agent pending tasks * POST /api/v3/tickets/:id/message — Send ticket chat message * POST /api/v3/tickets/:id/invoke-agent — Invoke agent to work on ticket (ADR-077 Task #12) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import request from 'supertest'; import express from 'express'; // ===== MOCKS ===== const mockGetTicket = vi.fn(); const mockUpdateTicketStatus = vi.fn(); const mockDispatchSubtask = vi.fn(); const mockDispatchChain = vi.fn(); const mockGetChainStatus = vi.fn(); const mockGetAgentPendingTasks = vi.fn(); const mockResolveAgentId = vi.fn(); const mockLogActivity = vi.fn(); vi.mock('../../../services/ChainHandoffService.js', () => ({ default: { getTicket: (...args) => mockGetTicket(...args), updateTicketStatus: (...args) => mockUpdateTicketStatus(...args), dispatchSubtask: (...args) => mockDispatchSubtask(...args), dispatchChain: (...args) => mockDispatchChain(...args), getChainStatus: (...args) => mockGetChainStatus(...args), getAgentPendingTasks: (...args) => mockGetAgentPendingTasks(...args), resolveAgentId: (...args) => mockResolveAgentId(...args), logActivity: (...args) => mockLogActivity(...args), }, })); const mockDbGet = vi.fn(); const mockDbRun = vi.fn(); const mockDbAll = vi.fn(); vi.mock('../../../database/connection.js', () => ({ dbGet: (...args) => mockDbGet(...args), dbRun: (...args) => mockDbRun(...args), dbAll: (...args) => mockDbAll(...args), isPostgres: () => false, safeJsonParse: (v, d) => { try { return typeof v === 'string' ? JSON.parse(v) : (v || d); } catch { return d; } }, })); vi.mock('../../../utils/logger.js', () => ({ apiLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); const mockResolveAgentUser = vi.fn(); vi.mock('../../../services/agent-users.js', () => ({ resolveAgentUser: (...args) => mockResolveAgentUser(...args), })); const mockCreateAndDispatchJob = vi.fn(); vi.mock('../../../services/AgentJobService.js', () => ({ createAndDispatchJob: (...args) => mockCreateAndDispatchJob(...args), })); // Import after mocks import ticketsRoutes, { STATE_MAP, STATE_NAMES, TRANSITIONS, SUPERVISOR_AGENT_IDS, parseStatusDirective } from '../tickets.js'; // ===== TEST APP ===== function createApp(userOverride = {}) { const app = express(); app.use(express.json()); // Mock auth app.use((req, res, next) => { req.user = { id: 1, role: 'admin', user_type: 'human', ...userOverride }; next(); }); app.use('/api/v3', ticketsRoutes); return app; } // ===== TESTS ===== describe('ADR-098: Tickets API', () => { let app; beforeEach(() => { vi.clearAllMocks(); app = createApp(); }); // ===== STATE_MAP exports ===== describe('State Machine Constants (Phase 1 — 7 states)', () => { it('should export STATE_MAP with 7 states', () => { expect(STATE_MAP).toEqual({ backlog: 24275, assigned: 43436, in_progress: 24276, review: 24277, control: 43437, rejected: 43438, done: 24278, }); }); it('should export STATE_NAMES as reverse map for all 7 states', () => { expect(STATE_NAMES[24275]).toBe('backlog'); expect(STATE_NAMES[43436]).toBe('assigned'); expect(STATE_NAMES[24276]).toBe('in_progress'); expect(STATE_NAMES[24277]).toBe('review'); expect(STATE_NAMES[43437]).toBe('control'); expect(STATE_NAMES[43438]).toBe('rejected'); expect(STATE_NAMES[24278]).toBe('done'); }); it('should export TRANSITIONS with 7-state rules', () => { // backlog → assigned, in_progress (backward compat) expect(TRANSITIONS[24275]).toContain(43436); expect(TRANSITIONS[24275]).toContain(24276); // assigned → in_progress, backlog expect(TRANSITIONS[43436]).toEqual([24276, 24275]); // in_progress → review, backlog expect(TRANSITIONS[24276]).toEqual([24277, 24275]); // review → control, in_progress expect(TRANSITIONS[24277]).toEqual([43437, 24276]); // control → done, rejected (HUMAN ONLY) expect(TRANSITIONS[43437]).toEqual([24278, 43438]); // rejected → in_progress expect(TRANSITIONS[43438]).toEqual([24276]); // done → terminal (empty) expect(TRANSITIONS[24278]).toEqual([]); }); }); // ===== PATCH /tickets/:id/status ===== describe('PATCH /api/v3/tickets/:id/status', () => { it('should change ticket from in_progress to review', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276, // in_progress what: 'Test task', _chain: { chain_id: 'chain-abc' }, }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: 'chain-abc', }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.data.ticket_id).toBe(100); expect(res.body.data.old_state).toBe('in_progress'); expect(res.body.data.old_state_id).toBe(24276); expect(res.body.data.new_state).toBe('review'); expect(res.body.data.new_state_id).toBe(24277); expect(res.body.data.chain_id).toBe('chain-abc'); expect(res.body.data.updated_at).toBeDefined(); }); it('should accept state name string and resolve to ID', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review' }); expect(res.status).toBe(200); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ new_state: 24277 }) ); }); it('should accept state ID number directly', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 24277 }); expect(res.status).toBe(200); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ new_state: 24277 }) ); }); it('should reject invalid state name with 400 INVALID_STATE', async () => { const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'foo' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_STATE'); expect(res.body.error.message).toContain("Unknown state: 'foo'"); expect(res.body.error.message).toContain('backlog'); }); it('should reject invalid state ID with 400 INVALID_STATE', async () => { const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 99999 }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_STATE'); }); it('should reject missing new_state with 400', async () => { const res = await request(app) .patch('/api/v3/tickets/100/status') .send({}); expect(res.status).toBe(400); expect(res.body.error.message).toContain('new_state is required'); }); it('should reject invalid transition backlog->done with 400 INVALID_TRANSITION', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24275 }); // backlog const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_TRANSITION'); expect(res.body.error.message).toContain("Cannot transition from 'backlog' to 'done'"); expect(res.body.error.message).toContain('Allowed:'); }); it('should reject invalid transition backlog->review with 400 INVALID_TRANSITION', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24275 }); // backlog const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_TRANSITION'); expect(res.body.error.message).toContain("Cannot transition from 'backlog' to 'review'"); }); it('should reject invalid transition in_progress->done with 400 (must go through review→control)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); // in_progress const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_TRANSITION'); }); it('should allow in_progress->backlog (return to backlog)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); // in_progress mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24275, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'backlog' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('backlog'); }); it('should allow review->in_progress (rework)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24277 }); // review mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24277, new_state: 24276, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'in_progress' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('in_progress'); }); it('should reject transition from done (terminal state)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24278 }); // done const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'in_progress' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_TRANSITION'); expect(res.body.error.message).toContain('Allowed: none'); }); it('should return 404 for non-existent ticket', async () => { mockGetTicket.mockResolvedValue(null); const res = await request(app) .patch('/api/v3/tickets/99999/status') .send({ new_state: 'review' }); expect(res.status).toBe(404); expect(res.body.error.code).toBe('TICKET_NOT_FOUND'); }); it('should pass notes to ChainHandoffService', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: null, }); await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review', notes: 'All tests passing' }); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ notes: 'All tests passing' }) ); }); it('should pass calling user ID as agent_id', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: null, }); await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review' }); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ agent_id: 1 }) // from mock auth ); }); it('should handle case-insensitive state names', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'REVIEW' }); expect(res.status).toBe(200); }); }); // ===== GET /tickets/:id ===== describe('GET /api/v3/tickets/:id', () => { it('should return ticket with state_name and allowed_transitions', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test task', state: 24276, // in_progress assigned_to: 19, _chain: { chain_id: 'chain-abc' }, created_at: '2026-02-18T10:00:00Z', updated_at: '2026-02-18T12:00:00Z', }); const res = await request(app).get('/api/v3/tickets/100'); expect(res.status).toBe(200); expect(res.body.data.state_name).toBe('in_progress'); // Phase 1: in_progress → review, backlog expect(res.body.data.allowed_transitions).toEqual([ { id: 24277, name: 'review' }, { id: 24275, name: 'backlog' }, ]); }); it('should return 404 for non-existent ticket', async () => { mockGetTicket.mockResolvedValue(null); const res = await request(app).get('/api/v3/tickets/99999'); expect(res.status).toBe(404); expect(res.body.error.code).toBe('TICKET_NOT_FOUND'); }); it('should return empty allowed_transitions for done (terminal)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24278, what: 'Done task' }); const res = await request(app).get('/api/v3/tickets/100'); expect(res.status).toBe(200); expect(res.body.data.state_name).toBe('done'); expect(res.body.data.allowed_transitions).toEqual([]); }); }); // ===== POST /tickets/dispatch ===== describe('POST /api/v3/tickets/dispatch', () => { it('should dispatch a subtask and return ticket info', async () => { mockDispatchSubtask.mockResolvedValue({ ticket_id: 200, chain_id: 'chain-xyz', step: 1, state: 24275, assigned_to: 19, what: 'Implement API', }); mockDbGet.mockResolvedValue(null); // No existing conversation mockDbRun.mockResolvedValue({ lastInsertRowid: 50 }); // New conversation const res = await request(app) .post('/api/v3/tickets/dispatch') .send({ what: 'Implement API', assigned_to: 19, acceptance_criteria: 'Tests pass', }); expect(res.status).toBe(201); expect(res.body.data.ticket_id).toBe(200); expect(res.body.data.chain_id).toBe('chain-xyz'); expect(res.body.data.state).toBe('backlog'); expect(res.body.data.conversation_id).toBe(50); }); it('should resolve agent name to ID', async () => { mockResolveAgentId.mockReturnValue(19); mockDispatchSubtask.mockResolvedValue({ ticket_id: 200, chain_id: 'chain-xyz', step: 1, state: 24275, assigned_to: 19, what: 'Test', }); mockDbGet.mockResolvedValue(null); mockDbRun.mockResolvedValue({ lastInsertRowid: 50 }); const res = await request(app) .post('/api/v3/tickets/dispatch') .send({ what: 'Test', assigned_to: 'developer-ralph' }); expect(res.status).toBe(201); expect(mockResolveAgentId).toHaveBeenCalledWith('developer-ralph'); }); it('should return 400 when what is missing', async () => { const res = await request(app) .post('/api/v3/tickets/dispatch') .send({ assigned_to: 19 }); expect(res.status).toBe(400); expect(res.body.error.message).toContain('what is required'); }); it('should return 400 when assigned_to is missing', async () => { const res = await request(app) .post('/api/v3/tickets/dispatch') .send({ what: 'Test task' }); expect(res.status).toBe(400); expect(res.body.error.message).toContain('assigned_to is required'); }); }); // ===== POST /tickets/dispatch-chain ===== describe('POST /api/v3/tickets/dispatch-chain', () => { it('should dispatch a chain of tasks', async () => { mockDispatchChain.mockResolvedValue({ chain_id: 'chain-batch', parent_ticket_id: null, dispatched_by: 1, task_count: 2, tasks: [ { ticket_id: 301, chain_id: 'chain-batch', step: 1, state: 24275, assigned_to: 19, what: 'Task 1' }, { ticket_id: 302, chain_id: 'chain-batch', step: 2, state: 24275, assigned_to: 20, what: 'Task 2' }, ], }); const res = await request(app) .post('/api/v3/tickets/dispatch-chain') .send({ tasks: [ { what: 'Task 1', assigned_to: 19 }, { what: 'Task 2', assigned_to: 20 }, ], }); expect(res.status).toBe(201); expect(res.body.data.chain_id).toBe('chain-batch'); expect(res.body.data.task_count).toBe(2); expect(res.body.data.tickets).toHaveLength(2); }); it('should return 400 when tasks array is empty', async () => { const res = await request(app) .post('/api/v3/tickets/dispatch-chain') .send({ tasks: [] }); expect(res.status).toBe(400); }); }); // ===== GET /tickets/chains/:chainId ===== describe('GET /api/v3/tickets/chains/:chainId', () => { it('should return chain progress', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-abc', status: 'in_progress', progress: { total: 3, completed: 1, in_progress: 1, review: 0, backlog: 1, percent_complete: 33, }, tasks: [], current_step: 2, next_step: 3, }); const res = await request(app).get('/api/v3/tickets/chains/chain-abc'); expect(res.status).toBe(200); expect(res.body.data.chain_id).toBe('chain-abc'); expect(res.body.data.progress_pct).toBe(33); expect(res.body.data.total).toBe(3); expect(res.body.data.completed).toBe(1); }); it('should return 404 for non-existent chain', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-unknown', status: 'not_found', tasks: [], }); const res = await request(app).get('/api/v3/tickets/chains/chain-unknown'); expect(res.status).toBe(404); }); it('should return completed chain status with 100% progress', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-done', status: 'completed', progress: { total: 5, completed: 5, in_progress: 0, review: 0, backlog: 0, percent_complete: 100, }, tasks: [ { ticket_id: 1, step: 1, what: 'Task 1', state: 24278 }, { ticket_id: 2, step: 2, what: 'Task 2', state: 24278 }, ], current_step: null, next_step: null, }); const res = await request(app).get('/api/v3/tickets/chains/chain-done'); expect(res.status).toBe(200); expect(res.body.data.status).toBe('completed'); expect(res.body.data.progress_pct).toBe(100); expect(res.body.data.in_progress).toBe(0); expect(res.body.data.backlog).toBe(0); expect(res.body.data.current_step).toBeNull(); expect(res.body.data.next_step).toBeNull(); expect(res.body.data.tasks).toHaveLength(2); }); it('should return pending chain status when all tasks in backlog', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-pending', status: 'pending', progress: { total: 3, completed: 0, in_progress: 0, review: 0, backlog: 3, percent_complete: 0, }, tasks: [], current_step: null, next_step: 1, }); const res = await request(app).get('/api/v3/tickets/chains/chain-pending'); expect(res.status).toBe(200); expect(res.body.data.status).toBe('pending'); expect(res.body.data.progress_pct).toBe(0); expect(res.body.data.next_step).toBe(1); }); it('should include review count in response', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-review', status: 'in_progress', progress: { total: 4, completed: 1, in_progress: 1, review: 2, backlog: 0, percent_complete: 25, }, tasks: [], current_step: 3, next_step: null, }); const res = await request(app).get('/api/v3/tickets/chains/chain-review'); expect(res.status).toBe(200); expect(res.body.data.review).toBe(2); expect(res.body.data.in_progress).toBe(1); }); it('should return 500 when ChainHandoffService throws', async () => { mockGetChainStatus.mockRejectedValue(new Error('Database connection failed')); const res = await request(app).get('/api/v3/tickets/chains/chain-err'); expect(res.status).toBe(500); expect(res.body.error).toBeDefined(); }); it('should pass chainId param to ChainHandoffService.getChainStatus', async () => { mockGetChainStatus.mockResolvedValue({ chain_id: 'chain-param-test-123', status: 'not_found', tasks: [], }); await request(app).get('/api/v3/tickets/chains/chain-param-test-123'); expect(mockGetChainStatus).toHaveBeenCalledWith('chain-param-test-123'); }); }); // ===== GET /tickets/agents/me/tasks ===== describe('GET /api/v3/tickets/agents/me/tasks', () => { it('should return pending tasks for calling agent', async () => { mockGetAgentPendingTasks.mockResolvedValue([ { ticket_id: 100, what: 'Task 1', state: 24275, priority: 24274 }, { ticket_id: 101, what: 'Task 2', state: 24276, priority: 24274 }, ]); const res = await request(app).get('/api/v3/tickets/agents/me/tasks'); expect(res.status).toBe(200); expect(res.body.data.agent_id).toBe(1); expect(res.body.data.count).toBe(2); expect(res.body.data.tasks).toHaveLength(2); expect(res.body.data.tasks[0].state_name).toBe('backlog'); expect(res.body.data.tasks[1].state_name).toBe('in_progress'); }); }); // ===== POST /tickets/:id/message ===== describe('POST /api/v3/tickets/:id/message', () => { it('should send message in existing ticket conversation', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test', state: 24276 }); mockDbGet.mockResolvedValue({ id: 42 }); // existing conversation mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); // new message const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Progress update' }); expect(res.status).toBe(201); expect(res.body.data.message_id).toBe(500); expect(res.body.data.conversation_id).toBe(42); expect(res.body.data.ticket_id).toBe(100); }); it('should create conversation if none exists', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test', state: 24276 }); mockDbGet.mockResolvedValue(null); // no existing conversation mockDbRun .mockResolvedValueOnce({ lastInsertRowid: 60 }) // create conversation .mockResolvedValueOnce({ lastInsertRowid: 501 }); // create message const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Starting work' }); expect(res.status).toBe(201); expect(res.body.data.conversation_id).toBe(60); }); it('should return 404 for non-existent ticket', async () => { mockGetTicket.mockResolvedValue(null); const res = await request(app) .post('/api/v3/tickets/99999/message') .send({ content: 'Test' }); expect(res.status).toBe(404); }); it('should return 400 when content is empty', async () => { const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: '' }); expect(res.status).toBe(400); expect(res.body.error.message).toContain('content is required'); }); }); // ===== PHASE 1: 7-STATE WORKFLOW ===== describe('Phase 1: 7-State Workflow Transitions', () => { it('should allow backlog → assigned', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24275 }); // backlog mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24275, new_state: 43436, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'assigned' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('assigned'); }); it('should allow assigned → in_progress (agent picks up)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 43436 }); // assigned mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43436, new_state: 24276, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'in_progress' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('in_progress'); }); it('should allow review → control (QA passes)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24277 }); // review mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24277, new_state: 43437, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'control' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('control'); }); it('should allow control → done (human approves)', async () => { // Human user (default in createApp) mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('done'); }); it('should allow control → rejected (human rejects)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 43438, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'rejected' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('rejected'); }); it('should allow rejected → in_progress (agent reworks)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 43438 }); // rejected mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43438, new_state: 24276, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'in_progress' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('in_progress'); }); it('should reject review → done (must go through control)', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24277 }); // review const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(400); expect(res.body.error.code).toBe('INVALID_TRANSITION'); }); }); // ===== PHASE 1: CONTROL GATE ===== describe('Phase 1: Control Gate (Human Only)', () => { it('should reject agent attempting control → done with 403 CONTROL_GATE', async () => { // Create app with agent user const agentApp = createApp({ user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control const res = await request(agentApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(403); expect(res.body.error.code).toBe('CONTROL_GATE'); expect(res.body.error.message).toContain('Only human users'); }); it('should reject agent attempting control → rejected with 403 CONTROL_GATE', async () => { const agentApp = createApp({ user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control const res = await request(agentApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'rejected' }); expect(res.status).toBe(403); expect(res.body.error.code).toBe('CONTROL_GATE'); }); it('should allow human to transition from control → done', async () => { // Human user (default) mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('done'); }); it('should allow human to transition from control → rejected', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 43438, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'rejected' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('rejected'); }); }); // ===== ADR-109: SUPERVISOR BYPASS ===== describe('ADR-109: Supervisor Bypass in Control Gate', () => { it('should export SUPERVISOR_AGENT_IDS containing supervisor user ID (53)', () => { expect(SUPERVISOR_AGENT_IDS).toBeDefined(); expect(SUPERVISOR_AGENT_IDS).toBeInstanceOf(Set); expect(SUPERVISOR_AGENT_IDS.has(53)).toBe(true); }); it('should allow supervisor agent (id=53) to transition control → done', async () => { // Supervisor agent: user_type='agent', id=53 const supervisorApp = createApp({ id: 53, user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); const res = await request(supervisorApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('done'); }); it('should allow supervisor agent (id=53) to transition control → rejected', async () => { const supervisorApp = createApp({ id: 53, user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 43438, chain_id: null, }); const res = await request(supervisorApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'rejected' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('rejected'); }); it('should still block regular agents (non-supervisor) from control → done', async () => { // Regular agent: user_type='agent', id=19 (developer-ralph) const agentApp = createApp({ id: 19, user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control const res = await request(agentApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(403); expect(res.body.error.code).toBe('CONTROL_GATE'); }); it('should still allow human users (regression test)', async () => { // Human user (default) mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); expect(res.status).toBe(200); expect(res.body.data.new_state).toBe('done'); }); it('should log supervisor bypass to activity table', async () => { const supervisorApp = createApp({ id: 53, user_type: 'agent' }); mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); await request(supervisorApp) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); // Verify logActivity was called with supervisor_bypass expect(mockLogActivity).toHaveBeenCalledWith( expect.objectContaining({ action: 'supervisor_bypass', agent_id: 53, ticket_id: 100, }) ); }); it('should NOT log bypass for human users transitioning from control', async () => { // Human user (default, id=1) mockGetTicket.mockResolvedValue({ id: 100, state: 43437 }); // control mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 43437, new_state: 24278, chain_id: null, }); await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'done' }); // logActivity should NOT have been called with supervisor_control_bypass const bypassCalls = mockLogActivity.mock.calls.filter( call => call[0]?.action === 'supervisor_control_bypass' ); expect(bypassCalls).toHaveLength(0); }); }); // ===== PHASE 1: CASCADE UPDATES ===== describe('Phase 1: Cascade Updates', () => { it('should include cascade results in status change response', async () => { mockGetTicket.mockResolvedValue({ id: 100, state: 24276, what: 'Test task', _chain: { chain_id: 'chain-abc' }, }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: 24276, new_state: 24277, chain_id: 'chain-abc', }); // Mock for cascade chain status check const { default: ChainHandoffService } = await import('../../../services/ChainHandoffService.js'); ChainHandoffService.getChainStatus = vi.fn().mockResolvedValue({ progress: { percent_complete: 50 }, }); const res = await request(app) .patch('/api/v3/tickets/100/status') .send({ new_state: 'review' }); expect(res.status).toBe(200); expect(res.body.data.cascade).toBeDefined(); expect(Array.isArray(res.body.data.cascade)).toBe(true); }); }); // ===== ADR Status Cascade (Phase 2 — AC10 Level 3) ===== describe('ADR Status Cascade (Phase 2)', () => { it('should trigger ADR status recalc when ticket has adr_ref', async () => { // Ticket with adr_ref (numeric row ID of ADR document, as stored by select column) mockGetTicket.mockResolvedValue({ id: 200, state: 24277, what: 'Build feature', adr_ref: 74360, _chain: { chain_id: 'chain-xyz' }, }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 200, old_state: 24277, new_state: 43437, chain_id: 'chain-xyz', }); // Mock dbAll for finding tickets with adr_ref mockDbAll.mockResolvedValue([ { id: 200, data: JSON.stringify({ adr_ref: 74360, state: 24278 }) }, // done { id: 201, data: JSON.stringify({ adr_ref: 74360, state: 24278 }) }, // done ]); // Mock dbGet for finding ADR document (now looked up by row ID, not slug) mockDbGet.mockImplementation(async (query, params) => { // For getTicket (table 1708) if (String(query).includes('table_id') && String(params).includes('200')) { return { id: 200, data: JSON.stringify({ state: 24277, what: 'Build feature', adr_ref: 74360, _chain: { chain_id: 'chain-xyz' } }) }; } // For ADR document lookup (table 2197, by row ID) if (String(query).includes('2197') || String(params).includes('74360')) { return { id: 74360, data: JSON.stringify({ slug: 'adr-098', status: 'PROPOSED' }) }; } return null; }); mockDbRun.mockResolvedValue({}); const res = await request(app) .patch('/api/v3/tickets/200/status') .send({ new_state: 'control' }); expect(res.status).toBe(200); // Cascade should include level 3 ADR recalc const cascade = res.body.data.cascade; expect(cascade).toBeDefined(); const level3 = cascade.find(l => l.level === 3); expect(level3).toBeDefined(); expect(level3.action).toBe('adr_status_recalculated'); expect(level3.adr_ref).toBe(74360); }); it('should skip ADR cascade when ticket has no adr_ref', async () => { mockGetTicket.mockResolvedValue({ id: 300, state: 24276, what: 'Simple task', _chain: {}, }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 300, old_state: 24276, new_state: 24277, }); const res = await request(app) .patch('/api/v3/tickets/300/status') .send({ new_state: 'review' }); expect(res.status).toBe(200); const cascade = res.body.data.cascade; const level3 = cascade?.find(l => l.level === 3); expect(level3).toBeUndefined(); // No ADR cascade without adr_ref }); }); // ===== POST /tickets/:id/invoke-agent (ADR-077 Task #12) ===== describe('POST /api/v3/tickets/:id/invoke-agent', () => { it('should invoke agent for a valid ticket and return job info', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Implement feature X', state: 24275, // backlog assigned_to: 19, // developer-ralph }); mockResolveAgentUser.mockResolvedValue({ id: 19, name: 'developer-ralph', managed_by_agent_row_id: 42, _agentConfig: { row_id: 42, name: 'developer-ralph' }, }); // Existing conversation found mockDbGet.mockResolvedValue({ id: 55 }); mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-abc-123', id: 10 }); const res = await request(app) .post('/api/v3/tickets/100/invoke-agent') .send({ message: 'Please start working on this' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.data.job_id).toBe('job-abc-123'); expect(res.body.data.conversation_id).toBe(55); expect(res.body.data.agent_name).toBe('developer-ralph'); expect(res.body.data.ticket_id).toBe(100); }); it('should create conversation if none exists', async () => { mockGetTicket.mockResolvedValue({ id: 101, what: 'Build API', state: 24276, // in_progress assigned_to: 20, }); mockResolveAgentUser.mockResolvedValue({ id: 20, name: 'developer', managed_by_agent_row_id: 43, _agentConfig: { row_id: 43, name: 'developer' }, }); mockDbGet.mockResolvedValue(null); // No existing conversation mockDbRun.mockResolvedValue({ lastInsertRowid: 77 }); // New conversation mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-def-456', id: 11 }); const res = await request(app) .post('/api/v3/tickets/101/invoke-agent') .send({}); expect(res.status).toBe(200); expect(res.body.data.conversation_id).toBe(77); expect(res.body.data.job_id).toBe('job-def-456'); }); it('should return 404 for non-existent ticket', async () => { mockGetTicket.mockResolvedValue(null); const res = await request(app) .post('/api/v3/tickets/99999/invoke-agent') .send({}); expect(res.status).toBe(404); expect(res.body.error.code).toBe('TICKET_NOT_FOUND'); }); it('should return 400 if ticket has no assigned_to', async () => { mockGetTicket.mockResolvedValue({ id: 102, what: 'Unassigned task', state: 24275, assigned_to: null, }); const res = await request(app) .post('/api/v3/tickets/102/invoke-agent') .send({}); expect(res.status).toBe(400); expect(res.body.error.message).toContain('assigned_to'); }); it('should return 400 if ticket is in done (terminal) state', async () => { mockGetTicket.mockResolvedValue({ id: 103, what: 'Completed task', state: 24278, // done assigned_to: 19, }); const res = await request(app) .post('/api/v3/tickets/103/invoke-agent') .send({}); expect(res.status).toBe(400); expect(res.body.error.code).toBe('TICKET_TERMINAL'); }); it('should return 400 if agent user cannot be resolved', async () => { mockGetTicket.mockResolvedValue({ id: 104, what: 'Task for unknown agent', state: 24275, assigned_to: 999, }); mockResolveAgentUser.mockResolvedValue(null); const res = await request(app) .post('/api/v3/tickets/104/invoke-agent') .send({}); expect(res.status).toBe(400); expect(res.body.error.code).toBe('AGENT_NOT_FOUND'); }); it('should use ticket what as default message if none provided', async () => { mockGetTicket.mockResolvedValue({ id: 105, what: 'Implement the login page', state: 24275, assigned_to: 21, }); mockResolveAgentUser.mockResolvedValue({ id: 21, name: 'frontend', managed_by_agent_row_id: 44, _agentConfig: { row_id: 44, name: 'frontend' }, }); mockDbGet.mockResolvedValue({ id: 60 }); mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-ghi-789', id: 12 }); await request(app) .post('/api/v3/tickets/105/invoke-agent') .send({}); expect(mockCreateAndDispatchJob).toHaveBeenCalledWith( expect.objectContaining({ messageContent: expect.stringContaining('Implement the login page'), }) ); }); it('should pass ticketId to createAndDispatchJob', async () => { mockGetTicket.mockResolvedValue({ id: 106, what: 'Build widget', state: 24276, assigned_to: 19, }); mockResolveAgentUser.mockResolvedValue({ id: 19, name: 'developer-ralph', managed_by_agent_row_id: 42, _agentConfig: { row_id: 42, name: 'developer-ralph' }, }); mockDbGet.mockResolvedValue({ id: 70 }); mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-jkl', id: 13 }); await request(app) .post('/api/v3/tickets/106/invoke-agent') .send({ message: 'Work on this' }); expect(mockCreateAndDispatchJob).toHaveBeenCalledWith( expect.objectContaining({ conversationId: 70, ticketId: 106, triggeredByUserId: 1, }) ); }); it('should update ticket state to in_progress if currently backlog', async () => { mockGetTicket.mockResolvedValue({ id: 107, what: 'Start this task', state: 24275, // backlog assigned_to: 19, }); mockResolveAgentUser.mockResolvedValue({ id: 19, name: 'developer-ralph', managed_by_agent_row_id: 42, _agentConfig: { row_id: 42, name: 'developer-ralph' }, }); mockDbGet.mockResolvedValue({ id: 80 }); mockCreateAndDispatchJob.mockResolvedValue({ jobId: 'job-mno', id: 14 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 107, old_state: 24275, new_state: 24276, }); const res = await request(app) .post('/api/v3/tickets/107/invoke-agent') .send({}); expect(res.status).toBe(200); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ ticket_id: 107, new_state: 24276, // in_progress }) ); }); }); // ===== ADR-077 TASK #7: STATUS AUTO-UPDATE FROM MESSAGE ===== describe('parseStatusDirective()', () => { it('should parse "Status: Done" and return review state', () => { const result = parseStatusDirective('I finished the work.\n\nStatus: Done'); expect(result).toEqual({ targetState: 'review', rawStatus: 'done' }); }); it('should parse "Status: Review" and return review state', () => { const result = parseStatusDirective('Submitting for review.\n\nStatus: Review'); expect(result).toEqual({ targetState: 'review', rawStatus: 'review' }); }); it('should parse "Status: In Progress" and return in_progress state', () => { const result = parseStatusDirective('Starting work.\n\nStatus: In Progress'); expect(result).toEqual({ targetState: 'in_progress', rawStatus: 'in progress' }); }); it('should parse "Status: Backlog" and return backlog state', () => { const result = parseStatusDirective('Pausing work.\n\nStatus: Backlog'); expect(result).toEqual({ targetState: 'backlog', rawStatus: 'backlog' }); }); it('should be case-insensitive', () => { const result = parseStatusDirective('status: DONE'); expect(result).toEqual({ targetState: 'review', rawStatus: 'done' }); }); it('should return null when no status directive found', () => { const result = parseStatusDirective('Just a normal progress update'); expect(result).toBeNull(); }); it('should return null for empty content', () => { expect(parseStatusDirective('')).toBeNull(); expect(parseStatusDirective(null)).toBeNull(); expect(parseStatusDirective(undefined)).toBeNull(); }); it('should handle "Status: done" at end of long message', () => { const content = 'Here is my implementation:\n\n1. Added feature X\n2. Wrote tests\n3. All passing\n\nStatus: done'; const result = parseStatusDirective(content); expect(result).toEqual({ targetState: 'review', rawStatus: 'done' }); }); it('should handle COMPLETE tag', () => { const content = 'All acceptance criteria met.\n\nCOMPLETE'; const result = parseStatusDirective(content); expect(result).toEqual({ targetState: 'review', rawStatus: 'complete' }); }); it('should handle ADR IMPLEMENTATION COMPLETE tag', () => { const content = 'All done.\n\nADR IMPLEMENTATION COMPLETE'; const result = parseStatusDirective(content); expect(result).toEqual({ targetState: 'review', rawStatus: 'complete' }); }); }); describe('POST /api/v3/tickets/:id/message — auto-status-update', () => { it('should auto-update ticket status to review when message contains "Status: Done"', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test task', state: STATE_MAP.in_progress, _chain: { chain_id: 'chain-abc' }, }); mockDbGet.mockResolvedValue({ id: 42 }); // existing conversation mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); // new message mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: STATE_MAP.in_progress, new_state: STATE_MAP.review, chain_id: 'chain-abc', }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'All tests pass.\n\nStatus: Done' }); expect(res.status).toBe(201); expect(res.body.data.auto_status_update).toBeDefined(); expect(res.body.data.auto_status_update.new_state).toBe('review'); expect(mockUpdateTicketStatus).toHaveBeenCalledWith( expect.objectContaining({ ticket_id: 100, new_state: STATE_MAP.review, }) ); }); it('should NOT auto-update when message has no status directive', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test', state: STATE_MAP.in_progress }); mockDbGet.mockResolvedValue({ id: 42 }); mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Just a progress update' }); expect(res.status).toBe(201); expect(res.body.data.auto_status_update).toBeUndefined(); expect(mockUpdateTicketStatus).not.toHaveBeenCalled(); }); it('should skip auto-update when transition is invalid (e.g. ticket already done)', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test', state: STATE_MAP.done }); mockDbGet.mockResolvedValue({ id: 42 }); mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Status: Done' }); expect(res.status).toBe(201); expect(res.body.data.auto_status_update).toBeUndefined(); expect(mockUpdateTicketStatus).not.toHaveBeenCalled(); }); it('should skip auto-update when transition is not allowed by state machine', async () => { // Ticket in backlog; review is not allowed (must go through assigned/in_progress first) mockGetTicket.mockResolvedValue({ id: 100, what: 'Test', state: STATE_MAP.backlog }); mockDbGet.mockResolvedValue({ id: 42 }); mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Status: Done' }); expect(res.status).toBe(201); // backlog to review is not in TRANSITIONS, so auto-update should be skipped expect(mockUpdateTicketStatus).not.toHaveBeenCalled(); }); it('should auto-update to in_progress when message contains "Status: In Progress"', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test task', state: STATE_MAP.assigned, }); mockDbGet.mockResolvedValue({ id: 42 }); mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: STATE_MAP.assigned, new_state: STATE_MAP.in_progress, chain_id: null, }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'Starting work now.\n\nStatus: In Progress' }); expect(res.status).toBe(201); expect(res.body.data.auto_status_update).toBeDefined(); expect(res.body.data.auto_status_update.new_state).toBe('in_progress'); }); it('should handle COMPLETE tag as Status: Done', async () => { mockGetTicket.mockResolvedValue({ id: 100, what: 'Test task', state: STATE_MAP.in_progress, }); mockDbGet.mockResolvedValue({ id: 42 }); mockDbRun.mockResolvedValue({ lastInsertRowid: 500 }); mockUpdateTicketStatus.mockResolvedValue({ ticket_id: 100, old_state: STATE_MAP.in_progress, new_state: STATE_MAP.review, chain_id: null, }); const res = await request(app) .post('/api/v3/tickets/100/message') .send({ content: 'All done.\n\nCOMPLETE' }); expect(res.status).toBe(201); expect(res.body.data.auto_status_update).toBeDefined(); expect(res.body.data.auto_status_update.new_state).toBe('review'); }); }); });