/** * Integration tests for getPhaseReviewDiff and getFileDiff tRPC procedures. * * Uses real git repos on disk (no cassettes) + an in-memory SQLite database. * No network calls — purely local git operations. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { simpleGit } from 'simple-git'; import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js'; import { phaseProcedures } from '../../trpc/routers/phase.js'; import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; import { DrizzleInitiativeRepository, DrizzlePhaseRepository, DrizzleProjectRepository, } from '../../db/repositories/drizzle/index.js'; import { SimpleGitBranchManager } from '../../git/simple-git-branch-manager.js'; import { getProjectCloneDir } from '../../git/project-clones.js'; import type { TRPCContext } from '../../trpc/context.js'; // ============================================================================ // Test router & caller factory // ============================================================================ const testRouter = router({ ...phaseProcedures(publicProcedure), }); const createCaller = createCallerFactory(testRouter); // ============================================================================ // Shared test state (set up once for the whole suite) // ============================================================================ let workspaceRoot: string; let cleanup: () => Promise; let phaseId: string; let pendingPhaseId: string; /** * Build the test git repo with the required branches and files. * * Repo layout on the phase branch vs main: * file1.txt through file5.txt — added (10–20 lines each) * photo.bin — binary file (Buffer.alloc(32)) * gone.txt — deleted (existed on main) * has space.txt — added (contains text) */ async function setupGitRepo(clonePath: string): Promise { await mkdir(clonePath, { recursive: true }); const git = simpleGit(clonePath); await git.init(); await git.addConfig('user.email', 'test@example.com'); await git.addConfig('user.name', 'Test'); // Commit gone.txt on main so it can be deleted on the phase branch await writeFile(path.join(clonePath, 'gone.txt'), Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join('\n') + '\n'); await git.add('gone.txt'); await git.commit('Initial commit on main'); // Create phase branch from main // phaseBranchName('main', 'test-phase') => 'main-phase-test-phase' await git.checkoutLocalBranch('main-phase-test-phase'); // Add 5 text files for (let i = 1; i <= 5; i++) { const lines = Array.from({ length: 15 }, (_, j) => `Line ${j + 1} in file${i}`).join('\n') + '\n'; await writeFile(path.join(clonePath, `file${i}.txt`), lines); } await git.add(['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt']); // Add binary file await writeFile(path.join(clonePath, 'photo.bin'), Buffer.alloc(32)); await git.add('photo.bin'); // Delete gone.txt await git.rm(['gone.txt']); // Add file with space in name await writeFile(path.join(clonePath, 'has space.txt'), 'content with spaces\n'); await git.add('has space.txt'); await git.commit('Phase branch changes'); } beforeAll(async () => { // Create workspace root temp dir workspaceRoot = await mkdtemp(path.join(tmpdir(), 'cw-phase-diff-test-')); cleanup = async () => { await rm(workspaceRoot, { recursive: true, force: true }); }; // Set up in-memory database const db = createTestDatabase(); const initiativeRepo = new DrizzleInitiativeRepository(db); const phaseRepo = new DrizzlePhaseRepository(db); const projectRepo = new DrizzleProjectRepository(db); // Create initiative with branch='main' const initiative = await initiativeRepo.create({ name: 'Test Initiative', branch: 'main', }); // Create project — we'll set up the git repo at the expected clone path const project = await projectRepo.create({ name: 'test-repo', url: 'file:///dev/null', // won't be cloned — we create the repo directly defaultBranch: 'main', }); // Link project to initiative await projectRepo.addProjectToInitiative(initiative.id, project.id); // Set up git repo at the expected clone path const relPath = getProjectCloneDir(project.name, project.id); const clonePath = path.join(workspaceRoot, relPath); await setupGitRepo(clonePath); // Create reviewable phase (pending_review) const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'test-phase', status: 'pending_review', }); phaseId = phase.id; // Create a non-reviewable phase (pending) for error test const pendingPhase = await phaseRepo.create({ initiativeId: initiative.id, name: 'pending-phase', status: 'pending', }); pendingPhaseId = pendingPhase.id; // Store db and repos so the caller can use them // (stored in module-level vars to be accessed in test helper) Object.assign(sharedCtx, { initiativeRepository: initiativeRepo, phaseRepository: phaseRepo, projectRepository: projectRepo, branchManager: new SimpleGitBranchManager(), workspaceRoot, }); }); afterAll(async () => { await cleanup?.(); }); // ============================================================================ // Shared context (filled in beforeAll) // ============================================================================ const sharedCtx: Partial = { eventBus: { emit: () => {}, on: () => {}, off: () => {}, once: () => {} } as any, serverStartedAt: null, processCount: 0, }; function getCaller() { return createCaller(sharedCtx as TRPCContext); } // ============================================================================ // Tests: getPhaseReviewDiff // ============================================================================ describe('getPhaseReviewDiff', () => { it('returns files array with correct metadata and no rawDiff field', async () => { const start = Date.now(); const result = await getCaller().getPhaseReviewDiff({ phaseId }); const elapsed = Date.now() - start; expect(result).not.toHaveProperty('rawDiff'); expect(result.files).toBeInstanceOf(Array); // 5 text + 1 binary + 1 deleted + 1 spaced = 8 files expect(result.files.length).toBeGreaterThanOrEqual(7); expect(elapsed).toBeLessThan(3000); }); it('includes binary file with status=binary, additions=0, deletions=0', async () => { const result = await getCaller().getPhaseReviewDiff({ phaseId }); const bin = result.files.find((f) => f.path === 'photo.bin'); expect(bin?.status).toBe('binary'); expect(bin?.additions).toBe(0); expect(bin?.deletions).toBe(0); }); it('includes deleted file with status=deleted and nonzero deletions', async () => { const result = await getCaller().getPhaseReviewDiff({ phaseId }); const del = result.files.find((f) => f.path === 'gone.txt'); expect(del?.status).toBe('deleted'); expect(del?.deletions).toBeGreaterThan(0); }); it('computes totalAdditions and totalDeletions as sums over files', async () => { const result = await getCaller().getPhaseReviewDiff({ phaseId }); const sumAdd = result.files.reduce((s, f) => s + f.additions, 0); const sumDel = result.files.reduce((s, f) => s + f.deletions, 0); expect(result.totalAdditions).toBe(sumAdd); expect(result.totalDeletions).toBe(sumDel); }); it('throws NOT_FOUND for unknown phaseId', async () => { const err = await getCaller().getPhaseReviewDiff({ phaseId: 'nonexistent' }).catch((e) => e); expect(err.code).toBe('NOT_FOUND'); }); it('throws BAD_REQUEST for phase not in reviewable status', async () => { const err = await getCaller().getPhaseReviewDiff({ phaseId: pendingPhaseId }).catch((e) => e); expect(err.code).toBe('BAD_REQUEST'); }); }); // ============================================================================ // Tests: getFileDiff // ============================================================================ describe('getFileDiff', () => { it('returns rawDiff with unified diff for a normal file, under 1 second', async () => { const start = Date.now(); const result = await getCaller().getFileDiff({ phaseId, filePath: 'file1.txt' }); const elapsed = Date.now() - start; expect(result.binary).toBe(false); expect(result.rawDiff).toContain('+'); expect(elapsed).toBeLessThan(1000); }); it('returns binary=true and rawDiff="" for binary file', async () => { const result = await getCaller().getFileDiff({ phaseId, filePath: 'photo.bin' }); expect(result.binary).toBe(true); expect(result.rawDiff).toBe(''); }); it('returns removal hunks for a deleted file', async () => { const result = await getCaller().getFileDiff({ phaseId, filePath: 'gone.txt' }); expect(result.binary).toBe(false); expect(result.rawDiff).toContain('-'); }); it('handles URL-encoded file path with space', async () => { const result = await getCaller().getFileDiff({ phaseId, filePath: encodeURIComponent('has space.txt'), }); expect(result.binary).toBe(false); expect(result.rawDiff).toContain('has space.txt'); }); });