diff --git a/apps/server/tests/unit/routes/pipeline.test.ts b/apps/server/tests/unit/routes/pipeline.test.ts new file mode 100644 index 00000000..299fdca8 --- /dev/null +++ b/apps/server/tests/unit/routes/pipeline.test.ts @@ -0,0 +1,499 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js'; +import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js'; +import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js'; +import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js'; +import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js'; +import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js'; +import type { PipelineService } from '@/services/pipeline-service.js'; +import type { PipelineConfig, PipelineStep } from '@automaker/types'; +import { createMockExpressContext } from '../../utils/mocks.js'; + +describe('pipeline routes', () => { + let mockPipelineService: PipelineService; + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPipelineService = { + getPipelineConfig: vi.fn(), + savePipelineConfig: vi.fn(), + addStep: vi.fn(), + updateStep: vi.fn(), + deleteStep: vi.fn(), + reorderSteps: vi.fn(), + } as any; + + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + describe('get-config', () => { + it('should return pipeline config successfully', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config); + req.body = { projectPath: '/test/project' }; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + config, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = {}; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Read failed'); + vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error); + req.body = { projectPath: '/test/project' }; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Read failed', + }); + }); + }); + + describe('save-config', () => { + it('should save pipeline config successfully', async () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', config }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { config: { version: 1, steps: [] } }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if config is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'config is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Save failed'); + vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + config: { version: 1, steps: [] }, + }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Save failed', + }); + }); + }); + + describe('add-step', () => { + it('should add step successfully', async () => { + const stepData = { + name: 'New Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + }; + + const newStep: PipelineStep = { + ...stepData, + id: 'step1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep); + req.body = { projectPath: '/test/project', step: stepData }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData); + expect(res.json).toHaveBeenCalledWith({ + success: true, + step: newStep, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if step is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step is required', + }); + }); + + it('should return 400 if step.name is missing', async () => { + req.body = { + projectPath: '/test/project', + step: { order: 0, instructions: 'Do', colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step.name is required', + }); + }); + + it('should return 400 if step.instructions is missing', async () => { + req.body = { + projectPath: '/test/project', + step: { name: 'Step', order: 0, colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step.instructions is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Add failed'); + vi.mocked(mockPipelineService.addStep).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Add failed', + }); + }); + }); + + describe('update-step', () => { + it('should update step successfully', async () => { + const updates = { + name: 'Updated Name', + instructions: 'Updated instructions', + }; + + const updatedStep: PipelineStep = { + id: 'step1', + name: 'Updated Name', + order: 0, + instructions: 'Updated instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep); + req.body = { projectPath: '/test/project', stepId: 'step1', updates }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.updateStep).toHaveBeenCalledWith( + '/test/project', + 'step1', + updates + ); + expect(res.json).toHaveBeenCalledWith({ + success: true, + step: updatedStep, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepId: 'step1', updates: { name: 'New' } }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepId is missing', async () => { + req.body = { projectPath: '/test/project', updates: { name: 'New' } }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepId is required', + }); + }); + + it('should return 400 if updates is missing', async () => { + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'updates is required', + }); + }); + + it('should return 400 if updates is empty object', async () => { + req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'updates is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Update failed'); + vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + stepId: 'step1', + updates: { name: 'New' }, + }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Update failed', + }); + }); + }); + + describe('delete-step', () => { + it('should delete step successfully', async () => { + vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepId is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepId is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Delete failed'); + vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error); + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Delete failed', + }); + }); + }); + + describe('reorder-steps', () => { + it('should reorder steps successfully', async () => { + vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [ + 'step2', + 'step1', + 'step3', + ]); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepIds: ['step1', 'step2'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepIds is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepIds array is required', + }); + }); + + it('should return 400 if stepIds is not an array', async () => { + req.body = { projectPath: '/test/project', stepIds: 'not-an-array' }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepIds array is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Reorder failed'); + vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error); + req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Reorder failed', + }); + }); + }); +}); diff --git a/apps/server/tests/unit/services/pipeline-service.test.ts b/apps/server/tests/unit/services/pipeline-service.test.ts new file mode 100644 index 00000000..c8917c97 --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-service.test.ts @@ -0,0 +1,860 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { PipelineService } from '@/services/pipeline-service.js'; +import type { PipelineConfig, PipelineStep } from '@automaker/types'; + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), +})); + +// Mock ensureAutomakerDir +vi.mock('@automaker/platform', () => ({ + ensureAutomakerDir: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import { ensureAutomakerDir } from '@automaker/platform'; + +describe('pipeline-service.ts', () => { + let testProjectDir: string; + let pipelineService: PipelineService; + + beforeEach(async () => { + testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`); + await fs.mkdir(testProjectDir, { recursive: true }); + pipelineService = new PipelineService(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getPipelineConfig', () => { + it('should return default config when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValue(error); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(config).toEqual({ + version: 1, + steps: [], + }); + }); + + it('should read and return existing config', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json'); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8'); + expect(config).toEqual(existingConfig); + }); + + it('should merge with defaults for missing properties', async () => { + const partialConfig = { + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json'); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(config.version).toBe(1); + expect(config.steps).toHaveLength(1); + }); + + it('should handle read errors gracefully', async () => { + const error = new Error('Read error'); + vi.mocked(secureFs.readFile).mockRejectedValue(error); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + // Should return default config on error + expect(config).toEqual({ + version: 1, + steps: [], + }); + }); + }); + + describe('savePipelineConfig', () => { + it('should save config to file', async () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.savePipelineConfig(testProjectDir, config); + + expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir); + expect(secureFs.writeFile).toHaveBeenCalled(); + expect(secureFs.rename).toHaveBeenCalled(); + }); + + it('should use atomic write pattern', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.savePipelineConfig(testProjectDir, config); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const tempPath = writeCall[0] as string; + expect(tempPath).toContain('.tmp.'); + expect(tempPath).toContain('pipeline.json'); + }); + + it('should clean up temp file on write error', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed')); + vi.mocked(secureFs.unlink).mockResolvedValue(undefined); + + await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow( + 'Write failed' + ); + + expect(secureFs.unlink).toHaveBeenCalled(); + }); + }); + + describe('addStep', () => { + it('should add a new step to config', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValue(error); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + }; + + const newStep = await pipelineService.addStep(testProjectDir, stepData); + + expect(newStep.name).toBe('New Step'); + expect(newStep.id).toMatch(/^step_/); + expect(newStep.createdAt).toBeDefined(); + expect(newStep.updatedAt).toBeDefined(); + expect(newStep.createdAt).toBe(newStep.updatedAt); + }); + + it('should normalize order values after adding step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 5, // Out of order + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 10, // Out of order + instructions: 'Do something', + colorClass: 'red', + }; + + await pipelineService.addStep(testProjectDir, stepData); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + }); + + it('should sort steps by order before normalizing', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 2, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 0, + instructions: 'Do something else', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 1, + instructions: 'Do something', + colorClass: 'red', + }; + + await pipelineService.addStep(testProjectDir, stepData); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + // Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2) + expect(savedConfig.steps[0].id).toBe('step2'); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + expect(savedConfig.steps[2].id).toBe('step1'); + expect(savedConfig.steps[2].order).toBe(2); + }); + }); + + describe('updateStep', () => { + it('should update an existing step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Old Name', + order: 0, + instructions: 'Old instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const updates = { + name: 'New Name', + instructions: 'New instructions', + }; + + const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates); + + expect(updatedStep.name).toBe('New Name'); + expect(updatedStep.instructions).toBe('New instructions'); + expect(updatedStep.id).toBe('step1'); + expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z'); + expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should throw error if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect( + pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' }) + ).rejects.toThrow('Pipeline step not found: nonexistent'); + }); + + it('should preserve createdAt when updating', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', { + name: 'Updated', + }); + + expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z'); + }); + }); + + describe('deleteStep', () => { + it('should delete an existing step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.deleteStep(testProjectDir, 'step1'); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps).toHaveLength(1); + expect(savedConfig.steps[0].id).toBe('step2'); + expect(savedConfig.steps[0].order).toBe(0); // Normalized + }); + + it('should throw error if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow( + 'Pipeline step not found: nonexistent' + ); + }); + + it('should normalize order values after deletion', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 5, // Out of order + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 10, // Out of order + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.deleteStep(testProjectDir, 'step2'); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps).toHaveLength(2); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + }); + }); + + describe('reorderSteps', () => { + it('should reorder steps according to stepIds array', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].id).toBe('step3'); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].id).toBe('step1'); + expect(savedConfig.steps[1].order).toBe(1); + expect(savedConfig.steps[2].id).toBe('step2'); + expect(savedConfig.steps[2].order).toBe(2); + }); + + it('should update updatedAt timestamp for reordered steps', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should throw error if step ID not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect( + pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent']) + ).rejects.toThrow('Pipeline step not found: nonexistent'); + }); + + it('should allow partial reordering (filtering steps)', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step1']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + // Should only keep step1, effectively filtering out step2 + expect(savedConfig.steps).toHaveLength(1); + expect(savedConfig.steps[0].id).toBe('step1'); + expect(savedConfig.steps[0].order).toBe(0); + }); + }); + + describe('getNextStatus', () => { + it('should return waiting_approval when no pipeline and skipTests is true', () => { + const nextStatus = pipelineService.getNextStatus('in_progress', null, true); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should return verified when no pipeline and skipTests is false', () => { + const nextStatus = pipelineService.getNextStatus('in_progress', null, false); + expect(nextStatus).toBe('verified'); + }); + + it('should return first pipeline step when coming from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should go to next pipeline step when in middle of pipeline', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false); + expect(nextStatus).toBe('pipeline_step2'); + }); + + it('should go to final status when completing last pipeline step', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false); + expect(nextStatus).toBe('verified'); + }); + + it('should go to waiting_approval when completing last step with skipTests', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should handle invalid pipeline step ID gracefully', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false); + expect(nextStatus).toBe('verified'); + }); + + it('should preserve other statuses unchanged', () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog'); + expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe( + 'waiting_approval' + ); + expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified'); + expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed'); + }); + + it('should sort steps by order when determining next status', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false); + expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2 + }); + }); + + describe('getStep', () => { + it('should return step by ID', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const step = await pipelineService.getStep(testProjectDir, 'step1'); + + expect(step).not.toBeNull(); + expect(step?.id).toBe('step1'); + expect(step?.name).toBe('Step 1'); + }); + + it('should return null if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const step = await pipelineService.getStep(testProjectDir, 'nonexistent'); + + expect(step).toBeNull(); + }); + }); + + describe('isPipelineStatus', () => { + it('should return true for pipeline statuses', () => { + expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true); + expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true); + }); + + it('should return false for non-pipeline statuses', () => { + expect(pipelineService.isPipelineStatus('in_progress')).toBe(false); + expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false); + expect(pipelineService.isPipelineStatus('verified')).toBe(false); + expect(pipelineService.isPipelineStatus('backlog')).toBe(false); + expect(pipelineService.isPipelineStatus('completed')).toBe(false); + }); + }); + + describe('getStepIdFromStatus', () => { + it('should extract step ID from pipeline status', () => { + expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1'); + expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123'); + }); + + it('should return null for non-pipeline statuses', () => { + expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull(); + expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull(); + expect(pipelineService.getStepIdFromStatus('verified')).toBeNull(); + }); + }); +});