194 lines
8.0 KiB
TypeScript
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
|
|
});
|
|
});
|