-
Notifications
You must be signed in to change notification settings - Fork 0
feat: final test coverage gaps for #54 #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| /** | ||
| * Unit tests for SQLiteOutputRepository | ||
| * | ||
| * ARCHITECTURE: Tests output persistence with real Database + SQLite | ||
| * Pattern: Mirrors task-repository.test.ts — real DB, no mocks | ||
| * Note: task_output has FK to tasks — must insert task rows first | ||
| */ | ||
|
|
||
| import fs from 'node:fs'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import { type Configuration, ConfigurationSchema } from '../../../src/core/configuration.js'; | ||
| import { TaskId } from '../../../src/core/domain.js'; | ||
| import { Database } from '../../../src/implementations/database.js'; | ||
| import { SQLiteOutputRepository } from '../../../src/implementations/output-repository.js'; | ||
| import { SQLiteTaskRepository } from '../../../src/implementations/task-repository.js'; | ||
| import { createTestTask } from '../../fixtures/test-data.js'; | ||
|
|
||
| describe('SQLiteOutputRepository', () => { | ||
| let database: Database; | ||
| let repo: SQLiteOutputRepository; | ||
| let taskRepo: SQLiteTaskRepository; | ||
| const taskId = TaskId('test-task-1'); | ||
|
|
||
| beforeEach(async () => { | ||
| database = new Database(':memory:'); | ||
| const config: Configuration = ConfigurationSchema.parse({ | ||
| fileStorageThresholdBytes: 1024, // 1KB threshold for tests | ||
| }); | ||
| repo = new SQLiteOutputRepository(config, database); | ||
| taskRepo = new SQLiteTaskRepository(database); | ||
|
|
||
| // Insert a task row to satisfy FK constraint | ||
| await taskRepo.save(createTestTask({ id: taskId })); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| database.close(); | ||
| // Clean up ./output/ directory created by file-backed storage with :memory: DB | ||
| fs.rmSync('output', { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('save and get', () => { | ||
| it('should save small output to DB and retrieve it', async () => { | ||
| const output = { | ||
| taskId, | ||
| stdout: ['hello world'], | ||
| stderr: [], | ||
| totalSize: 11, | ||
| }; | ||
|
|
||
| const saveResult = await repo.save(taskId, output); | ||
| expect(saveResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(taskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value).not.toBeNull(); | ||
| expect(getResult.value!.stdout).toEqual(['hello world']); | ||
| expect(getResult.value!.stderr).toEqual([]); | ||
| }); | ||
|
|
||
| it('should save large output to file (above fileStorageThreshold)', async () => { | ||
| const largeData = 'x'.repeat(2048); // 2KB > 1KB threshold | ||
| const output = { | ||
| taskId, | ||
| stdout: [largeData], | ||
| stderr: [], | ||
| totalSize: 2048, | ||
| }; | ||
|
|
||
| const saveResult = await repo.save(taskId, output); | ||
| expect(saveResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(taskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value).not.toBeNull(); | ||
| expect(getResult.value!.stdout).toEqual([largeData]); | ||
| }); | ||
|
|
||
| it('should return null for missing task', async () => { | ||
| const result = await repo.get(TaskId('nonexistent')); | ||
| expect(result.ok).toBe(true); | ||
| if (!result.ok) return; | ||
| expect(result.value).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('append', () => { | ||
| it('should append to existing output', async () => { | ||
| const output = { | ||
| taskId, | ||
| stdout: ['line1'], | ||
| stderr: [], | ||
| totalSize: 5, | ||
| }; | ||
| await repo.save(taskId, output); | ||
|
|
||
| const appendResult = await repo.append(taskId, 'stdout', 'line2'); | ||
| expect(appendResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(taskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value!.stdout).toEqual(['line1', 'line2']); | ||
| }); | ||
|
|
||
| it('should create new output if none exists', async () => { | ||
| const newTaskId = TaskId('new-task'); | ||
| // Insert task row for FK | ||
| await taskRepo.save(createTestTask({ id: newTaskId })); | ||
|
|
||
| const appendResult = await repo.append(newTaskId, 'stderr', 'error msg'); | ||
| expect(appendResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(newTaskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value!.stderr).toEqual(['error msg']); | ||
| expect(getResult.value!.stdout).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('delete', () => { | ||
| it('should delete DB entry', async () => { | ||
| const output = { | ||
| taskId, | ||
| stdout: ['data'], | ||
| stderr: [], | ||
| totalSize: 4, | ||
| }; | ||
| await repo.save(taskId, output); | ||
|
|
||
| const deleteResult = await repo.delete(taskId); | ||
| expect(deleteResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(taskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value).toBeNull(); | ||
|
Comment on lines
+123
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File-backed delete test doesn't assert physical file removal The "should delete file-backed output and clean up file" test verifies the DB entry is gone (via Consider asserting the file no longer exists on the filesystem after // After deleteResult assertion
const outputDir = path.join(process.cwd(), 'output');
const filePath = path.join(outputDir, `${taskId}.json`);
expect(fs.existsSync(filePath)).toBe(false);This would require importing |
||
| }); | ||
|
|
||
| it('should delete file-backed output and clean up file', async () => { | ||
| const largeData = 'x'.repeat(2048); | ||
| const output = { | ||
| taskId, | ||
| stdout: [largeData], | ||
| stderr: [], | ||
| totalSize: 2048, | ||
| }; | ||
| await repo.save(taskId, output); | ||
|
|
||
| const deleteResult = await repo.delete(taskId); | ||
| expect(deleteResult.ok).toBe(true); | ||
|
|
||
| const getResult = await repo.get(taskId); | ||
| expect(getResult.ok).toBe(true); | ||
| if (!getResult.ok) return; | ||
| expect(getResult.value).toBeNull(); | ||
| }); | ||
|
|
||
| it('should succeed when deleting non-existent task', async () => { | ||
| const result = await repo.delete(TaskId('nonexistent')); | ||
| expect(result.ok).toBe(true); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,145 @@ | ||||||||||||||
| /** | ||||||||||||||
| * Unit tests for ProcessConnector | ||||||||||||||
| * | ||||||||||||||
| * ARCHITECTURE: Tests stream wiring, exit handling, and double-exit guard | ||||||||||||||
| * Pattern: Mock ChildProcess (EventEmitter) + mock OutputCapture + TestLogger | ||||||||||||||
| */ | ||||||||||||||
|
|
||||||||||||||
| import { EventEmitter } from 'events'; | ||||||||||||||
| import { describe, expect, it, vi } from 'vitest'; | ||||||||||||||
| import type { TaskId } from '../../../src/core/domain.js'; | ||||||||||||||
| import type { Logger, OutputCapture } from '../../../src/core/interfaces.js'; | ||||||||||||||
| import { ok } from '../../../src/core/result.js'; | ||||||||||||||
| import { ProcessConnector } from '../../../src/services/process-connector.js'; | ||||||||||||||
|
|
||||||||||||||
| function createMockProcess(): EventEmitter & { | ||||||||||||||
| stdout: EventEmitter | null; | ||||||||||||||
| stderr: EventEmitter | null; | ||||||||||||||
| } { | ||||||||||||||
| const proc = new EventEmitter() as EventEmitter & { | ||||||||||||||
| stdout: EventEmitter | null; | ||||||||||||||
| stderr: EventEmitter | null; | ||||||||||||||
| }; | ||||||||||||||
| proc.stdout = new EventEmitter(); | ||||||||||||||
| proc.stderr = new EventEmitter(); | ||||||||||||||
| return proc; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function createTestLogger(): Logger { | ||||||||||||||
| return { | ||||||||||||||
| debug: vi.fn(), | ||||||||||||||
| info: vi.fn(), | ||||||||||||||
| warn: vi.fn(), | ||||||||||||||
| error: vi.fn(), | ||||||||||||||
| child: () => createTestLogger(), | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function createMockOutputCapture(): OutputCapture { | ||||||||||||||
| return { | ||||||||||||||
| capture: vi.fn().mockReturnValue(ok(undefined)), | ||||||||||||||
| getOutput: vi.fn().mockReturnValue(ok({ taskId: 'test', stdout: [], stderr: [], totalSize: 0 })), | ||||||||||||||
| clear: vi.fn().mockReturnValue(ok(undefined)), | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| describe('ProcessConnector', () => { | ||||||||||||||
| const taskId = 'task-1' as TaskId; | ||||||||||||||
|
|
||||||||||||||
| it('should capture stdout data', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
|
Comment on lines
+48
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unbranded string used for
While this doesn't cause a test failure (since
Suggested change
Note that |
||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.stdout!.emit('data', Buffer.from('hello')); | ||||||||||||||
|
|
||||||||||||||
| expect(capture.capture).toHaveBeenCalledWith(taskId, 'stdout', 'hello'); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should capture stderr data', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.stderr!.emit('data', Buffer.from('error output')); | ||||||||||||||
|
|
||||||||||||||
| expect(capture.capture).toHaveBeenCalledWith(taskId, 'stderr', 'error output'); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should call onExit with exit code on process exit', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.emit('exit', 42); | ||||||||||||||
|
|
||||||||||||||
| expect(onExit).toHaveBeenCalledWith(42); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should preserve exit code 0 (nullish coalescing)', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.emit('exit', 0); | ||||||||||||||
|
|
||||||||||||||
| expect(onExit).toHaveBeenCalledWith(0); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should capture error and call onExit(1) on process error', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.emit('error', new Error('spawn failed')); | ||||||||||||||
|
|
||||||||||||||
| expect(capture.capture).toHaveBeenCalledWith(taskId, 'stderr', 'Process error: spawn failed\n'); | ||||||||||||||
| expect(onExit).toHaveBeenCalledWith(1); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should prevent multiple onExit calls (double-exit guard)', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.emit('exit', 0); | ||||||||||||||
| proc.emit('exit', 1); // second exit should be ignored | ||||||||||||||
|
|
||||||||||||||
| expect(onExit).toHaveBeenCalledTimes(1); | ||||||||||||||
| expect(onExit).toHaveBeenCalledWith(0); | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| it('should handle process without stdout/stderr streams', () => { | ||||||||||||||
| const capture = createMockOutputCapture(); | ||||||||||||||
| const logger = createTestLogger(); | ||||||||||||||
| const connector = new ProcessConnector(capture, logger); | ||||||||||||||
| const proc = createMockProcess(); | ||||||||||||||
| proc.stdout = null; | ||||||||||||||
| proc.stderr = null; | ||||||||||||||
| const onExit = vi.fn(); | ||||||||||||||
|
|
||||||||||||||
| // Should not throw | ||||||||||||||
| connector.connect(proc as never, taskId, onExit); | ||||||||||||||
| proc.emit('exit', 0); | ||||||||||||||
|
|
||||||||||||||
| expect(onExit).toHaveBeenCalledWith(0); | ||||||||||||||
| }); | ||||||||||||||
| }); | ||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.