From 6ed5b015070e0d427de2d8e02ed21ba0d846b188 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Wed, 25 Mar 2026 00:51:55 -0500 Subject: [PATCH] test(gsd): add tests for v3 reopen tools, unit ownership, and projection regression 37 new tests across 4 files covering v3 features that had no test coverage, plus regression tests for the projection bug fixes: - reopen-task.test.ts (8): success path (reset to pending, no side effects on other tasks) + 6 failure paths (empty ID, missing milestone/slice/task, closed parents, already pending) - reopen-slice.test.ts (7): success path (reset slice + all tasks, single task variant) + 5 failure paths (empty ID, missing entities, closed milestone, already in_progress) - unit-ownership.test.ts (14): key builders, claim/get/release CRUD, overwrite semantics, multi-unit independence, checkOwnership (opt-in when no actorName, null when unclaimed, pass when owner matches, error when mismatch) - projection-regression.test.ts (8): renderPlanContent checkbox for "complete"/"done"/"pending" status + mixed, parsePlan-compatible bold format, renderRoadmapContent status icons All 37 tests pass. Zero regressions. --- .../gsd/tests/projection-regression.test.ts | 173 +++++++++++++++++ .../extensions/gsd/tests/reopen-slice.test.ts | 155 ++++++++++++++++ .../extensions/gsd/tests/reopen-task.test.ts | 165 +++++++++++++++++ .../gsd/tests/unit-ownership.test.ts | 175 ++++++++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/projection-regression.test.ts create mode 100644 src/resources/extensions/gsd/tests/reopen-slice.test.ts create mode 100644 src/resources/extensions/gsd/tests/reopen-task.test.ts create mode 100644 src/resources/extensions/gsd/tests/unit-ownership.test.ts diff --git a/src/resources/extensions/gsd/tests/projection-regression.test.ts b/src/resources/extensions/gsd/tests/projection-regression.test.ts new file mode 100644 index 000000000..f7bf2c5c4 --- /dev/null +++ b/src/resources/extensions/gsd/tests/projection-regression.test.ts @@ -0,0 +1,173 @@ +// GSD — projection renderer regression tests +// Verifies that "done" vs "complete" status mismatch doesn't recur. +// Copyright (c) 2026 Jeremy McSpadden + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { renderPlanContent, renderRoadmapContent } from '../workflow-projections.ts'; +import type { SliceRow, TaskRow } from '../gsd-db.ts'; + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function makeSliceRow(overrides?: Partial): SliceRow { + return { + milestone_id: 'M001', + id: 'S01', + title: 'Test Slice', + status: 'pending', + risk: 'medium', + depends: [], + demo: 'Demo.', + created_at: '2026-01-01T00:00:00Z', + completed_at: null, + full_summary_md: '', + full_uat_md: '', + goal: 'Test goal', + success_criteria: '', + proof_level: '', + integration_closure: '', + observability_impact: '', + sequence: 0, + replan_triggered_at: null, + ...overrides, + }; +} + +function makeTaskRow(overrides?: Partial): TaskRow { + return { + milestone_id: 'M001', + slice_id: 'S01', + id: 'T01', + title: 'Test Task', + status: 'pending', + one_liner: '', + narrative: '', + verification_result: '', + duration: '', + completed_at: null, + blocker_discovered: false, + deviations: '', + known_issues: '', + key_files: [], + key_decisions: [], + full_summary_md: '', + description: 'Test description', + estimate: '30m', + files: ['src/test.ts'], + verify: 'npm test', + inputs: [], + expected_output: [], + observability_impact: '', + sequence: 0, + ...overrides, + }; +} + +function makeMilestoneRow() { + return { + id: 'M001', + title: 'Test Milestone', + status: 'active', + depends_on: [], + created_at: '2026-01-01T00:00:00Z', + completed_at: null, + vision: 'Test vision', + success_criteria: [], + key_risks: [], + proof_strategy: [], + verification_contract: '', + verification_integration: '', + verification_operational: '', + verification_uat: '', + definition_of_done: [], + requirement_coverage: '', + boundary_map_markdown: '', + }; +} + +// ─── renderPlanContent: checkbox regression ────────────────────────────── + +test('renderPlanContent: task with status "complete" renders [x] checkbox', () => { + const slice = makeSliceRow(); + const tasks = [makeTaskRow({ id: 'T01', status: 'complete', title: 'Completed Task' })]; + + const content = renderPlanContent(slice, tasks); + + assert.match(content, /\[x\]\s+\*\*T01:/, 'complete task should have [x] checkbox'); +}); + +test('renderPlanContent: task with status "done" renders [x] checkbox', () => { + const slice = makeSliceRow(); + const tasks = [makeTaskRow({ id: 'T01', status: 'done', title: 'Done Task' })]; + + const content = renderPlanContent(slice, tasks); + + assert.match(content, /\[x\]\s+\*\*T01:/, 'done task should have [x] checkbox'); +}); + +test('renderPlanContent: task with status "pending" renders [ ] checkbox', () => { + const slice = makeSliceRow(); + const tasks = [makeTaskRow({ id: 'T01', status: 'pending', title: 'Pending Task' })]; + + const content = renderPlanContent(slice, tasks); + + assert.match(content, /\[ \]\s+\*\*T01:/, 'pending task should have [ ] checkbox'); +}); + +test('renderPlanContent: mixed statuses render correct checkboxes', () => { + const slice = makeSliceRow(); + const tasks = [ + makeTaskRow({ id: 'T01', status: 'complete', title: 'Done One' }), + makeTaskRow({ id: 'T02', status: 'pending', title: 'Pending One' }), + makeTaskRow({ id: 'T03', status: 'done', title: 'Done Two' }), + ]; + + const content = renderPlanContent(slice, tasks); + + assert.match(content, /\[x\]\s+\*\*T01:/, 'T01 (complete) should be checked'); + assert.match(content, /\[ \]\s+\*\*T02:/, 'T02 (pending) should be unchecked'); + assert.match(content, /\[x\]\s+\*\*T03:/, 'T03 (done) should be checked'); +}); + +// ─── renderPlanContent: format regression (parsePlan compatibility) ────── + +test('renderPlanContent: format matches parsePlan regex **ID: title**', () => { + const slice = makeSliceRow(); + const tasks = [makeTaskRow({ id: 'T01', status: 'pending', title: 'My Task' })]; + + const content = renderPlanContent(slice, tasks); + + // parsePlan expects: **T01: My Task** (both ID and title inside bold) + // NOT: **T01:** My Task (only ID in bold) + assert.match(content, /\*\*T01: My Task\*\*/, 'ID and title should both be inside bold markers'); +}); + +// ─── renderRoadmapContent: status regression ───────────────────────────── + +test('renderRoadmapContent: slice with status "complete" shows ✅', () => { + const milestone = makeMilestoneRow(); + const slices = [makeSliceRow({ id: 'S01', status: 'complete' })]; + + const content = renderRoadmapContent(milestone, slices); + + assert.ok(content.includes('✅'), 'complete slice should show ✅'); +}); + +test('renderRoadmapContent: slice with status "done" shows ✅', () => { + const milestone = makeMilestoneRow(); + const slices = [makeSliceRow({ id: 'S01', status: 'done' })]; + + const content = renderRoadmapContent(milestone, slices); + + assert.ok(content.includes('✅'), 'done slice should show ✅'); +}); + +test('renderRoadmapContent: slice with status "pending" shows ⬜', () => { + const milestone = makeMilestoneRow(); + const slices = [makeSliceRow({ id: 'S01', status: 'pending' })]; + + const content = renderRoadmapContent(milestone, slices); + + assert.ok(content.includes('⬜'), 'pending slice should show ⬜'); +}); diff --git a/src/resources/extensions/gsd/tests/reopen-slice.test.ts b/src/resources/extensions/gsd/tests/reopen-slice.test.ts new file mode 100644 index 000000000..eec8d5207 --- /dev/null +++ b/src/resources/extensions/gsd/tests/reopen-slice.test.ts @@ -0,0 +1,155 @@ +// GSD — reopen-slice handler tests +// Copyright (c) 2026 Jeremy McSpadden + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + getSlice, + getSliceTasks, +} from '../gsd-db.ts'; +import { handleReopenSlice } from '../tools/reopen-slice.ts'; + +function makeTmpBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-reopen-slice-')); + mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { closeDatabase(); } catch { /* noop */ } + try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ } +} + +function seedCompleteSlice(): void { + insertMilestone({ id: 'M001', title: 'Test Milestone', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'complete' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task One', status: 'complete' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Task Two', status: 'complete' }); +} + +// ─── Success path ──────────────────────────────────────────────────────── + +test('handleReopenSlice: resets a complete slice to in_progress and all tasks to pending', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + seedCompleteSlice(); + + const result = await handleReopenSlice({ + milestoneId: 'M001', + sliceId: 'S01', + reason: 'need to redo after requirements change', + }, base); + + assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`); + assert.equal(result.sliceId, 'S01'); + assert.equal(result.tasksReset, 2, 'should report 2 tasks reset'); + + const slice = getSlice('M001', 'S01'); + assert.ok(slice, 'slice should still exist'); + assert.equal(slice!.status, 'in_progress', 'slice status should be in_progress'); + + const tasks = getSliceTasks('M001', 'S01'); + assert.equal(tasks.length, 2, 'both tasks should still exist'); + assert.ok(tasks.every(t => t.status === 'pending'), 'all tasks should be pending'); + } finally { + cleanup(base); + } +}); + +test('handleReopenSlice: works with a single task', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Test', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' }); + + const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base); + + assert.ok(!('error' in result)); + assert.equal(result.tasksReset, 1); + } finally { + cleanup(base); + } +}); + +// ─── Failure paths ─────────────────────────────────────────────────────── + +test('handleReopenSlice: rejects empty sliceId', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: '' }, base); + assert.ok('error' in result); + assert.match(result.error, /sliceId/); + } finally { + cleanup(base); + } +}); + +test('handleReopenSlice: rejects non-existent milestone', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + const result = await handleReopenSlice({ milestoneId: 'M999', sliceId: 'S01' }, base); + assert.ok('error' in result); + assert.match(result.error, /milestone not found/); + } finally { + cleanup(base); + } +}); + +test('handleReopenSlice: rejects slice in a closed milestone', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Done', status: 'complete' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' }); + + const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base); + assert.ok('error' in result); + assert.match(result.error, /closed milestone/); + } finally { + cleanup(base); + } +}); + +test('handleReopenSlice: rejects reopening a slice that is not complete', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Active', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'in_progress' }); + + const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S01' }, base); + assert.ok('error' in result); + assert.match(result.error, /not complete/); + } finally { + cleanup(base); + } +}); + +test('handleReopenSlice: rejects non-existent slice', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Active', status: 'active' }); + + const result = await handleReopenSlice({ milestoneId: 'M001', sliceId: 'S99' }, base); + assert.ok('error' in result); + assert.match(result.error, /slice not found/); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/reopen-task.test.ts b/src/resources/extensions/gsd/tests/reopen-task.test.ts new file mode 100644 index 000000000..aa43c3f5f --- /dev/null +++ b/src/resources/extensions/gsd/tests/reopen-task.test.ts @@ -0,0 +1,165 @@ +// GSD — reopen-task handler tests +// Copyright (c) 2026 Jeremy McSpadden + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + getTask, +} from '../gsd-db.ts'; +import { handleReopenTask } from '../tools/reopen-task.ts'; + +function makeTmpBase(): string { + const base = mkdtempSync(join(tmpdir(), 'gsd-reopen-task-')); + mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { closeDatabase(); } catch { /* noop */ } + try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ } +} + +function seedCompleteTask(): void { + insertMilestone({ id: 'M001', title: 'Test Milestone', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'in_progress' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Task One', status: 'complete' }); + insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Task Two', status: 'pending' }); +} + +// ─── Success path ──────────────────────────────────────────────────────── + +test('handleReopenTask: resets a complete task to pending', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + seedCompleteTask(); + + const result = await handleReopenTask({ + milestoneId: 'M001', + sliceId: 'S01', + taskId: 'T01', + reason: 'verification failed after merge', + }, base); + + assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`); + assert.equal(result.taskId, 'T01'); + + const task = getTask('M001', 'S01', 'T01'); + assert.ok(task, 'task should still exist'); + assert.equal(task!.status, 'pending', 'task status should be reset to pending'); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: does not affect other tasks in the slice', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + seedCompleteTask(); + + await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base); + + const t02 = getTask('M001', 'S01', 'T02'); + assert.ok(t02, 'T02 should still exist'); + assert.equal(t02!.status, 'pending', 'T02 status should be unchanged'); + } finally { + cleanup(base); + } +}); + +// ─── Failure paths ─────────────────────────────────────────────────────── + +test('handleReopenTask: rejects empty taskId', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: '' }, base); + assert.ok('error' in result); + assert.match(result.error, /taskId/); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: rejects non-existent milestone', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + const result = await handleReopenTask({ milestoneId: 'M999', sliceId: 'S01', taskId: 'T01' }, base); + assert.ok('error' in result); + assert.match(result.error, /milestone not found/); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: rejects task in a closed milestone', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Done', status: 'complete' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' }); + + const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base); + assert.ok('error' in result); + assert.match(result.error, /closed milestone/); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: rejects task inside a closed slice', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Active', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'complete' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete' }); + + const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T01' }, base); + assert.ok('error' in result); + assert.match(result.error, /closed slice/); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: rejects reopening a task that is not complete', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + seedCompleteTask(); + + const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T02' }, base); + assert.ok('error' in result); + assert.match(result.error, /not complete/); + } finally { + cleanup(base); + } +}); + +test('handleReopenTask: rejects non-existent task', async () => { + const base = makeTmpBase(); + openDatabase(join(base, '.gsd', 'gsd.db')); + try { + insertMilestone({ id: 'M001', title: 'Active', status: 'active' }); + insertSlice({ id: 'S01', milestoneId: 'M001', status: 'in_progress' }); + + const result = await handleReopenTask({ milestoneId: 'M001', sliceId: 'S01', taskId: 'T99' }, base); + assert.ok('error' in result); + assert.match(result.error, /task not found/); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/unit-ownership.test.ts b/src/resources/extensions/gsd/tests/unit-ownership.test.ts new file mode 100644 index 000000000..fd062c9c8 --- /dev/null +++ b/src/resources/extensions/gsd/tests/unit-ownership.test.ts @@ -0,0 +1,175 @@ +// GSD — unit-ownership tests +// Copyright (c) 2026 Jeremy McSpadden + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + claimUnit, + releaseUnit, + getOwner, + checkOwnership, + taskUnitKey, + sliceUnitKey, +} from '../unit-ownership.ts'; + +function makeTmpBase(): string { + return mkdtempSync(join(tmpdir(), 'gsd-ownership-')); +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ } +} + +// ─── Key builders ──────────────────────────────────────────────────────── + +test('taskUnitKey: builds correct key', () => { + assert.equal(taskUnitKey('M001', 'S01', 'T01'), 'M001/S01/T01'); +}); + +test('sliceUnitKey: builds correct key', () => { + assert.equal(sliceUnitKey('M001', 'S01'), 'M001/S01'); +}); + +// ─── Claim / get / release ─────────────────────────────────────────────── + +test('claimUnit: creates claim file and records agent', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'executor-01'); + + assert.ok(existsSync(join(base, '.gsd', 'unit-claims.json')), 'claim file should exist'); + assert.equal(getOwner(base, 'M001/S01/T01'), 'executor-01'); + } finally { + cleanup(base); + } +}); + +test('claimUnit: overwrites existing claim (last writer wins)', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'executor-01'); + claimUnit(base, 'M001/S01/T01', 'executor-02'); + + assert.equal(getOwner(base, 'M001/S01/T01'), 'executor-02'); + } finally { + cleanup(base); + } +}); + +test('claimUnit: multiple units can be claimed independently', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + claimUnit(base, 'M001/S01/T02', 'agent-b'); + + assert.equal(getOwner(base, 'M001/S01/T01'), 'agent-a'); + assert.equal(getOwner(base, 'M001/S01/T02'), 'agent-b'); + } finally { + cleanup(base); + } +}); + +test('getOwner: returns null when no claim file exists', () => { + const base = makeTmpBase(); + try { + assert.equal(getOwner(base, 'M001/S01/T01'), null); + } finally { + cleanup(base); + } +}); + +test('getOwner: returns null for unclaimed unit', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + assert.equal(getOwner(base, 'M001/S01/T99'), null); + } finally { + cleanup(base); + } +}); + +test('releaseUnit: removes claim', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + releaseUnit(base, 'M001/S01/T01'); + + assert.equal(getOwner(base, 'M001/S01/T01'), null); + } finally { + cleanup(base); + } +}); + +test('releaseUnit: no-op for non-existent claim', () => { + const base = makeTmpBase(); + try { + // Should not throw + releaseUnit(base, 'M001/S01/T01'); + } finally { + cleanup(base); + } +}); + +// ─── checkOwnership ────────────────────────────────────────────────────── + +test('checkOwnership: returns null when no actorName provided (opt-in)', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + + // No actorName → ownership not enforced + assert.equal(checkOwnership(base, 'M001/S01/T01', undefined), null); + } finally { + cleanup(base); + } +}); + +test('checkOwnership: returns null when no claim file exists', () => { + const base = makeTmpBase(); + try { + assert.equal(checkOwnership(base, 'M001/S01/T01', 'agent-a'), null); + } finally { + cleanup(base); + } +}); + +test('checkOwnership: returns null when unit is unclaimed', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + + // Different unit, unclaimed + assert.equal(checkOwnership(base, 'M001/S01/T99', 'agent-b'), null); + } finally { + cleanup(base); + } +}); + +test('checkOwnership: returns null when actor matches owner', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + + assert.equal(checkOwnership(base, 'M001/S01/T01', 'agent-a'), null); + } finally { + cleanup(base); + } +}); + +test('checkOwnership: returns error string when actor does not match owner', () => { + const base = makeTmpBase(); + try { + claimUnit(base, 'M001/S01/T01', 'agent-a'); + + const err = checkOwnership(base, 'M001/S01/T01', 'agent-b'); + assert.ok(err !== null, 'should return error'); + assert.match(err!, /owned by agent-a/); + assert.match(err!, /not agent-b/); + } finally { + cleanup(base); + } +});