/** * Integration tests for initiative tRPC router — qualityReview field and listInitiatives sort. * * Verifies that updateInitiativeConfig accepts and persists qualityReview, * and that listInitiatives returns initiatives in priority-sorted order. */ import { describe, it, expect, vi } from 'vitest'; import { eq } from 'drizzle-orm'; import { router, publicProcedure, createCallerFactory } from '../trpc.js'; import { initiativeProcedures } from './initiative.js'; import type { TRPCContext } from '../context.js'; import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; import { DrizzleInitiativeRepository, DrizzlePhaseRepository } from '../../db/repositories/drizzle/index.js'; import { initiatives as initiativesTable } from '../../db/schema.js'; // ============================================================================= // Mock ensureProjectClone — prevents actual git cloning // ============================================================================= vi.mock('../../git/project-clones.js', () => ({ ensureProjectClone: vi.fn().mockResolvedValue('/fake/clone/path'), getProjectCloneDir: vi.fn().mockReturnValue('repos/fake-project-id'), })); // ============================================================================= // Test router // ============================================================================= const testRouter = router({ ...initiativeProcedures(publicProcedure), }); const createCaller = createCallerFactory(testRouter); // ============================================================================= // Setup helper // ============================================================================= function createMockEventBus(): TRPCContext['eventBus'] { return { emit: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } async function setup() { const db = createTestDatabase(); const initiativeRepo = new DrizzleInitiativeRepository(db); const ctx: TRPCContext = { eventBus: createMockEventBus(), serverStartedAt: null, processCount: 0, initiativeRepository: initiativeRepo, }; const caller = createCaller(ctx); const initiative = await initiativeRepo.create({ name: 'Test Initiative' }); return { caller, initiativeRepo, initiative }; } // ============================================================================= // Tests // ============================================================================= // ============================================================================= // listInitiatives sort order tests // ============================================================================= describe('listInitiatives — sort order', () => { async function setupWithPhaseRepo() { const db = createTestDatabase(); const initiativeRepo = new DrizzleInitiativeRepository(db); const phaseRepo = new DrizzlePhaseRepository(db); const ctx: TRPCContext = { eventBus: createMockEventBus(), serverStartedAt: null, processCount: 0, initiativeRepository: initiativeRepo, phaseRepository: phaseRepo, }; const caller = createCaller(ctx); return { caller, initiativeRepo, phaseRepo, db }; } async function setupNoPhaseRepo() { const db = createTestDatabase(); const initiativeRepo = new DrizzleInitiativeRepository(db); const ctx: TRPCContext = { eventBus: createMockEventBus(), serverStartedAt: null, processCount: 0, initiativeRepository: initiativeRepo, }; const caller = createCaller(ctx); return { caller, initiativeRepo, db }; } it('executing appears before idle/planning (state priority 0 < 1)', async () => { const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo(); const executingInit = await initiativeRepo.create({ name: 'Executing' }); const planningInit = await initiativeRepo.create({ name: 'Planning' }); await phaseRepo.create({ initiativeId: executingInit.id, name: 'Phase 1', status: 'in_progress' }); // planningInit has no phases → derives 'idle' (priority 1 via fallback) // executingInit has in_progress phase → derives 'executing' (priority 0) const result = await caller.listInitiatives({}); expect(result[0].id).toBe(executingInit.id); }); it('ready appears before blocked (priority 1 < 2)', async () => { const { caller, initiativeRepo, phaseRepo } = await setupWithPhaseRepo(); const blockedInit = await initiativeRepo.create({ name: 'Blocked' }); const readyInit = await initiativeRepo.create({ name: 'Ready' }); await phaseRepo.create({ initiativeId: blockedInit.id, name: 'Blocked Phase', status: 'blocked' }); await phaseRepo.create({ initiativeId: readyInit.id, name: 'Ready Phase', status: 'approved' }); const result = await caller.listInitiatives({}); expect(result[0].id).toBe(readyInit.id); }); it('same state — most recently updated comes first', async () => { const { caller, initiativeRepo, db } = await setupNoPhaseRepo(); const older = await initiativeRepo.create({ name: 'Older' }); const newer = await initiativeRepo.create({ name: 'Newer' }); // Set different updatedAt values via raw DB (SQLite stores timestamps at second precision) const olderDate = new Date('2024-01-01T00:00:00.000Z'); const newerDate = new Date('2024-01-02T00:00:00.000Z'); await db.update(initiativesTable).set({ updatedAt: olderDate }).where(eq(initiativesTable.id, older.id)); await db.update(initiativesTable).set({ updatedAt: newerDate }).where(eq(initiativesTable.id, newer.id)); const result = await caller.listInitiatives({}); expect(result[0].id).toBe(newer.id); }); it('same state and updatedAt — lexicographically smaller id appears first', async () => { const { caller, initiativeRepo, db } = await setupNoPhaseRepo(); const init1 = await initiativeRepo.create({ name: 'Init A' }); const init2 = await initiativeRepo.create({ name: 'Init B' }); const fixedDate = new Date('2024-01-01T00:00:00.000Z'); await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init1.id)); await db.update(initiativesTable).set({ updatedAt: fixedDate }).where(eq(initiativesTable.id, init2.id)); const result = await caller.listInitiatives({}); const expectedFirst = init1.id < init2.id ? init1.id : init2.id; expect(result[0].id).toBe(expectedFirst); }); it('empty list returns []', async () => { const { caller } = await setupNoPhaseRepo(); const result = await caller.listInitiatives({}); expect(result).toEqual([]); }); it('single item returns array of length 1', async () => { const { caller, initiativeRepo } = await setupNoPhaseRepo(); await initiativeRepo.create({ name: 'Only' }); const result = await caller.listInitiatives({}); expect(result).toHaveLength(1); }); }); describe('updateInitiativeConfig — qualityReview', () => { it('sets qualityReview to true', async () => { const { caller, initiative } = await setup(); const result = await caller.updateInitiativeConfig({ initiativeId: initiative.id, qualityReview: true, }); expect(result.qualityReview).toBe(true); }); it('sets qualityReview to false', async () => { const { caller, initiative, initiativeRepo } = await setup(); // First set it to true await initiativeRepo.update(initiative.id, { qualityReview: true }); // Now flip it back const result = await caller.updateInitiativeConfig({ initiativeId: initiative.id, qualityReview: false, }); expect(result.qualityReview).toBe(false); }); it('does not change qualityReview when omitted', async () => { const { caller, initiative, initiativeRepo } = await setup(); // Set to true first await initiativeRepo.update(initiative.id, { qualityReview: true }); // Update without qualityReview const result = await caller.updateInitiativeConfig({ initiativeId: initiative.id, executionMode: 'yolo', }); expect(result.qualityReview).toBe(true); // unchanged }); });