test: add unit tests for pipeline routes and service functionality

- Introduced comprehensive unit tests for the pipeline routes, covering handlers for getting, saving, adding, updating, deleting, and reordering steps.
- Added tests for the pipeline service, ensuring correct behavior for methods like getting and saving pipeline configurations, adding, updating, and deleting steps, as well as reordering them.
- Implemented error handling tests to verify graceful degradation in case of missing parameters or service failures.
- Enhanced test coverage for the `getNextStatus` and `getStep` methods to ensure accurate status transitions and step retrieval.

These tests improve the reliability of the pipeline feature by ensuring that all critical functionalities are validated against expected behaviors.
This commit is contained in:
Test User
2025-12-28 00:05:23 -05:00
parent e9b366fa18
commit 15dca79fb7
2 changed files with 1359 additions and 0 deletions

View File

@@ -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',
});
});
});
});

View File

@@ -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();
});
});
});