Files
Codewalkers/apps/server/trpc/routers/initiative.test.ts
2026-03-07 00:34:07 +01:00

194 lines
8.0 KiB
TypeScript

/**
* 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
});
});