config-audit/tests/lib/campaign-export.test.mjs
Kjell Tore Guttormsen 319e5541c9 feat(campaign): plan export + execution-by-reuse (v5.7 Fase 2 Block 4c)
Completes Block 4 (4b backlog + 4c export/execution). Asymmetric: plan
export is new testable code; execution is pure reuse of the existing
per-repo implement/rollback (no new execution machinery), per the plan's
"reuse existing backup/rollback".

Plan export ("planer følger arbeidsstedet"):
- scanners/lib/campaign-export.mjs (pure, now injected, 8 tests):
  planExportPath(repo,sessionId) -> <repo>/docs/config-audit-plan-<sessionId>.md
  (sessionId-keyed so same-day re-audits never collide);
  buildPlanExportDocument({...,now}) -> provenance header + verbatim plan.
- scanners/campaign-export-cli.mjs (-cli, read-only by default, 10 tests):
  --repo resolves the repo's linked session, reads its action-plan.md,
  assembles the doc, emits {exportable,problems,targetPath,document}. Two
  gates -> exit 1 advisory: no-session-linked / no-action-plan. Writes the
  file ONLY under opt-in --write (byte-faithful copy; the LLM never re-types
  a 200-line plan). --sessions-dir override for hermetic tests; exit 0/1/3.

Command: commands/campaign.md gains an `export <path>` mode (Step 6:
preview -> approve -> --write), then routes the user to the existing
/config-audit implement (backup + verify) + rollback + set-status
implemented. Nothing auto-written (Verifiseringsplikt).

Byte-stable: lib + -cli + command-doc only -> scanner count stays 15,
agents 7, commands 21 (export is a mode, not a new command), SC-5 +
backcompat suite untouched. suite 1150->1168. Block 4a (migrateLedger)
still deferred to the first breaking schema change.

Docs: CLAUDE.md section + badge 1150->1168/65->67 files; README badge +
campaign row + Testing prose (fixed stale 1055/59 -> true 1168/67).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:08:04 +02:00

72 lines
2.8 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { planExportPath, buildPlanExportDocument } from '../../scanners/lib/campaign-export.mjs';
const NOW = '2026-06-23';
const SESSION = '20260623_101500';
describe('planExportPath', () => {
it('targets <repo>/docs/config-audit-plan-<sessionId>.md', () => {
assert.equal(
planExportPath('/Users/ktg/repos/foo', SESSION),
`/Users/ktg/repos/foo/docs/config-audit-plan-${SESSION}.md`,
);
});
it('keys on sessionId (not date) so same-day audits do not collide', () => {
const a = planExportPath('/r/x', '20260623_090000');
const b = planExportPath('/r/x', '20260623_180000');
assert.notEqual(a, b);
});
it('throws on a missing/blank repoPath or sessionId (programmer error)', () => {
assert.throws(() => planExportPath('', SESSION), TypeError);
assert.throws(() => planExportPath('/r/x', ' '), TypeError);
assert.throws(() => planExportPath('/r/x'), TypeError);
});
});
describe('buildPlanExportDocument', () => {
const base = {
repoName: 'foo',
repoPath: '/Users/ktg/repos/foo',
sessionId: SESSION,
planMarkdown: '## Action 1\nDo the thing.\n',
now: NOW,
};
it('prepends a provenance header, then the verbatim plan body', () => {
const doc = buildPlanExportDocument(base);
assert.match(doc, /^# Config-Audit Action Plan — foo\n/);
assert.ok(doc.includes(`on ${NOW}.`));
assert.ok(doc.includes('`/Users/ktg/repos/foo`'));
assert.ok(doc.includes(`\`${SESSION}\``));
// body is present verbatim, after the --- separator
const [, body] = doc.split('\n---\n');
assert.ok(body.includes('## Action 1\nDo the thing.'));
});
it('points at the existing execute/undo/record machinery (reuse, not reinvent)', () => {
const doc = buildPlanExportDocument(base);
assert.ok(doc.includes('/config-audit implement'));
assert.ok(doc.includes('/config-audit rollback'));
assert.ok(doc.includes(`/config-audit campaign set-status ${base.repoPath} implemented`));
});
it('is deterministic — same inputs produce identical bytes', () => {
assert.equal(buildPlanExportDocument(base), buildPlanExportDocument({ ...base }));
});
it('trims trailing whitespace on the body and ends with exactly one newline', () => {
const doc = buildPlanExportDocument({ ...base, planMarkdown: 'body\n\n\n' });
assert.ok(doc.endsWith('body\n'));
assert.ok(!doc.endsWith('body\n\n'));
});
it('throws when any required field is missing or blank', () => {
for (const k of ['repoName', 'repoPath', 'sessionId', 'planMarkdown', 'now']) {
assert.throws(() => buildPlanExportDocument({ ...base, [k]: '' }), TypeError, `blank ${k}`);
}
assert.throws(() => buildPlanExportDocument(), TypeError);
});
});