test: add unit tests for cmdProposal

Refactor `src/cmd-proposal.mjs` to use dependency injection for testability.
Add `test/cmd-proposal.test.mjs` to verify all subcommands (`create`, `list`, `apply`, `reject`, `show`) and error handling.
Fix indentation in `src/cmd-proposal.mjs`.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-21 18:36:35 +00:00
1 parent 79e261d7b2
commit 940bc9ca66
4 files changed
+7845 -37

No files matched your search

+54 -37
View File
@@ -1,99 +1,116 @@
import { createProposal, listProposals, applyProposal, rejectProposal, getProposal } from './services/governance/index.js';
import {
createProposal as _createProposal,
listProposals as _listProposals,
applyProposal as _applyProposal,
rejectProposal as _rejectProposal,
getProposal as _getProposal
} from './services/governance/index.js';
import { ExitError } from './errors.mjs';
import fs from 'node:fs/promises';
export async function cmdProposal(subcommand, args = {}) {
export async function cmdProposal(subcommand, args = {}, deps = {}) {
const {
createProposal = _createProposal,
listProposals = _listProposals,
applyProposal = _applyProposal,
rejectProposal = _rejectProposal,
getProposal = _getProposal,
readFile = fs.readFile,
log = console.log,
error = console.error
} = deps;
switch (subcommand) {
case 'create':
return await handleCreate(args);
return await handleCreate(args, { createProposal, readFile, log, error });
case 'list':
return await handleList(args);
return await handleList(args, { listProposals, log, error });
case 'apply':
return await handleApply(args);
return await handleApply(args, { applyProposal, log, error });
case 'reject':
return await handleReject(args);
return await handleReject(args, { rejectProposal, log, error });
case 'show':
return await handleShow(args);
return await handleShow(args, { getProposal, log, error });
default:
console.error(`Unknown proposal subcommand: ${subcommand}`);
error(`Unknown proposal subcommand: ${subcommand}`);
throw new ExitError(1, 'Unknown subcommand');
}
}
async function handleCreate({ agent, target, contentFile, reason }) {
async function handleCreate({ agent, target, contentFile, reason }, { createProposal, readFile, log, error }) {
if (!agent || !target || !contentFile || !reason) {
console.error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
throw new ExitError(1, 'Missing arguments');
}
let newContent;
try {
newContent = await fs.readFile(contentFile, 'utf8');
newContent = await readFile(contentFile, 'utf8');
} catch (_e) {
console.error(`Failed to read content file: ${contentFile}`);
error(`Failed to read content file: ${contentFile}`);
throw new ExitError(1, 'File read error');
}
try {
const result = await createProposal({ agent, target, newContent, reason });
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to create proposal: ${e.message}`);
error(`Failed to create proposal: ${e.message}`);
throw new ExitError(1, 'Proposal creation failed');
}
}
async function handleList({ status }) {
async function handleList({ status }, { listProposals, log, error }) {
try {
const proposals = await listProposals();
const filtered = status ? proposals.filter(p => p.status === status) : proposals;
console.log(JSON.stringify(filtered, null, 2));
log(JSON.stringify(filtered, null, 2));
} catch (e) {
console.error(`Failed to list proposals: ${e.message}`);
error(`Failed to list proposals: ${e.message}`);
throw new ExitError(1, 'List failed');
}
}
async function handleApply({ id }) {
async function handleApply({ id }, { applyProposal, log, error }) {
if (!id) {
console.error('Usage: torch-lock proposal apply --id <proposal-id>');
error('Usage: torch-lock proposal apply --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const result = await applyProposal(id);
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to apply proposal: ${e.message}`);
error(`Failed to apply proposal: ${e.message}`);
throw new ExitError(1, 'Apply failed');
}
}
async function handleReject({ id, reason }) {
async function handleReject({ id, reason }, { rejectProposal, log, error }) {
if (!id || !reason) {
console.error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
throw new ExitError(1, 'Missing arguments');
}
try {
const result = await rejectProposal(id, reason);
console.log(JSON.stringify(result, null, 2));
log(JSON.stringify(result, null, 2));
} catch (e) {
console.error(`Failed to reject proposal: ${e.message}`);
error(`Failed to reject proposal: ${e.message}`);
throw new ExitError(1, 'Reject failed');
}
}
async function handleShow({ id }) {
if (!id) {
console.error('Usage: torch-lock proposal show --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const proposal = await getProposal(id);
console.log(JSON.stringify(proposal, null, 2));
} catch (e) {
console.error(`Failed to show proposal: ${e.message}`);
throw new ExitError(1, 'Show failed');
}
async function handleShow({ id }, { getProposal, log, error }) {
if (!id) {
error('Usage: torch-lock proposal show --id <proposal-id>');
throw new ExitError(1, 'Missing id');
}
try {
const proposal = await getProposal(id);
log(JSON.stringify(proposal, null, 2));
} catch (e) {
error(`Failed to show proposal: ${e.message}`);
throw new ExitError(1, 'Show failed');
}
}
+316
View File
@@ -0,0 +1,316 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { cmdProposal } from '../src/cmd-proposal.mjs';
import { ExitError } from '../src/errors.mjs';
describe('cmdProposal', () => {
const noop = () => {};
describe('create', () => {
it('should create a proposal successfully', async () => {
const logs = [];
const errors = [];
const mockCreate = async (args) => {
return { id: 'prop-123', status: 'open', ...args };
};
const mockReadFile = async () => 'file-content';
const deps = {
createProposal: mockCreate,
readFile: mockReadFile,
log: (msg) => logs.push(msg),
error: (msg) => errors.push(msg),
};
const args = {
agent: 'test-agent',
target: 'src/foo.js',
contentFile: 'new-content.txt',
reason: 'fix bug',
};
await cmdProposal('create', args, deps);
assert.strictEqual(logs.length, 1);
assert.strictEqual(errors.length, 0);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.id, 'prop-123');
assert.strictEqual(output.newContent, 'file-content');
assert.strictEqual(output.agent, 'test-agent');
});
it('should throw if arguments are missing', async () => {
const errors = [];
const deps = {
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('create', {}, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing arguments'
);
assert.ok(errors[0].includes('Usage:'));
});
it('should throw if file read fails', async () => {
const errors = [];
const mockReadFile = async () => { throw new Error('File not found'); };
const deps = {
readFile: mockReadFile,
log: noop,
error: (msg) => errors.push(msg),
};
const args = {
agent: 'test-agent',
target: 'src/foo.js',
contentFile: 'missing.txt',
reason: 'fix bug',
};
await assert.rejects(
() => cmdProposal('create', args, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'File read error'
);
assert.ok(errors[0].includes('Failed to read content file'));
});
it('should throw if createProposal fails', async () => {
const errors = [];
const mockCreate = async () => { throw new Error('DB error'); };
const mockReadFile = async () => 'content';
const deps = {
createProposal: mockCreate,
readFile: mockReadFile,
log: noop,
error: (msg) => errors.push(msg),
};
const args = {
agent: 'test-agent',
target: 'src/foo.js',
contentFile: 'content.txt',
reason: 'fix bug',
};
await assert.rejects(
() => cmdProposal('create', args, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Proposal creation failed'
);
assert.ok(errors[0].includes('Failed to create proposal'));
});
});
describe('list', () => {
it('should list all proposals', async () => {
const logs = [];
const mockList = async () => [{ id: '1', status: 'open' }, { id: '2', status: 'closed' }];
const deps = {
listProposals: mockList,
log: (msg) => logs.push(msg),
error: noop,
};
await cmdProposal('list', {}, deps);
assert.strictEqual(logs.length, 1);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.length, 2);
});
it('should filter proposals by status', async () => {
const logs = [];
const mockList = async () => [{ id: '1', status: 'open' }, { id: '2', status: 'closed' }];
const deps = {
listProposals: mockList,
log: (msg) => logs.push(msg),
error: noop,
};
await cmdProposal('list', { status: 'open' }, deps);
assert.strictEqual(logs.length, 1);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.length, 1);
assert.strictEqual(output[0].id, '1');
});
it('should handle list failure', async () => {
const errors = [];
const mockList = async () => { throw new Error('List error'); };
const deps = {
listProposals: mockList,
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('list', {}, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'List failed'
);
assert.ok(errors[0].includes('Failed to list proposals'));
});
});
describe('apply', () => {
it('should apply a proposal', async () => {
const logs = [];
const mockApply = async (id) => ({ id, status: 'applied' });
const deps = {
applyProposal: mockApply,
log: (msg) => logs.push(msg),
error: noop,
};
await cmdProposal('apply', { id: 'prop-1' }, deps);
assert.strictEqual(logs.length, 1);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.status, 'applied');
assert.strictEqual(output.id, 'prop-1');
});
it('should throw if id is missing', async () => {
const errors = [];
const deps = {
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('apply', {}, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing id'
);
assert.ok(errors[0].includes('Usage:'));
});
it('should handle apply failure', async () => {
const errors = [];
const mockApply = async () => { throw new Error('Apply error'); };
const deps = {
applyProposal: mockApply,
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('apply', { id: 'prop-1' }, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Apply failed'
);
assert.ok(errors[0].includes('Failed to apply proposal'));
});
});
describe('reject', () => {
it('should reject a proposal', async () => {
const logs = [];
const mockReject = async (id, reason) => ({ id, status: 'rejected', reason });
const deps = {
rejectProposal: mockReject,
log: (msg) => logs.push(msg),
error: noop,
};
await cmdProposal('reject', { id: 'prop-1', reason: 'bad code' }, deps);
assert.strictEqual(logs.length, 1);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.status, 'rejected');
assert.strictEqual(output.reason, 'bad code');
});
it('should throw if arguments are missing', async () => {
const errors = [];
const deps = {
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('reject', { id: 'prop-1' }, deps), // missing reason
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing arguments'
);
});
it('should handle reject failure', async () => {
const errors = [];
const mockReject = async () => { throw new Error('Reject error'); };
const deps = {
rejectProposal: mockReject,
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('reject', { id: 'prop-1', reason: 'bad' }, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Reject failed'
);
assert.ok(errors[0].includes('Failed to reject proposal'));
});
});
describe('show', () => {
it('should show a proposal', async () => {
const logs = [];
const mockGet = async (id) => ({ id, content: 'stuff' });
const deps = {
getProposal: mockGet,
log: (msg) => logs.push(msg),
error: noop,
};
await cmdProposal('show', { id: 'prop-1' }, deps);
assert.strictEqual(logs.length, 1);
const output = JSON.parse(logs[0]);
assert.strictEqual(output.id, 'prop-1');
assert.strictEqual(output.content, 'stuff');
});
it('should throw if id is missing', async () => {
const errors = [];
const deps = {
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('show', {}, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing id'
);
});
it('should handle show failure', async () => {
const errors = [];
const mockGet = async () => { throw new Error('Show error'); };
const deps = {
getProposal: mockGet,
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('show', { id: 'prop-1' }, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Show failed'
);
assert.ok(errors[0].includes('Failed to show proposal'));
});
});
describe('unknown subcommand', () => {
it('should throw ExitError for unknown subcommand', async () => {
const errors = [];
const deps = {
log: noop,
error: (msg) => errors.push(msg),
};
await assert.rejects(
() => cmdProposal('foo', {}, deps),
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Unknown subcommand'
);
assert.ok(errors[0].includes('Unknown proposal subcommand'));
});
});
});
+3738
View File
File diff suppressed because it is too large. Load diff
+3737
View File
File diff suppressed because it is too large. Load diff