// tests/validators/session-state-validator.test.mjs // Unit + integration tests for lib/validators/session-state-validator.mjs. // Schema v1 contract — see docs/HANDOVER-CONTRACTS.md (Handover 7). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { validateSessionStateObject, validateSessionStateContent, validateSessionState, } from '../../lib/validators/session-state-validator.mjs'; function goodState() { return { schema_version: 1, project: '.claude/projects/2026-05-01-example', next_session_brief_path: '.claude/projects/2026-05-01-example/brief.md', next_session_label: 'Session 2: Implement validator + tests', status: 'in_progress', updated_at: '2026-05-01T18:00:00.000Z', }; } test('validateSessionStateObject — happy path returns valid', () => { const r = validateSessionStateObject(goodState()); assert.equal(r.valid, true); assert.deepEqual(r.errors, []); assert.deepEqual(r.warnings, []); }); test('validateSessionStateObject — missing project field', () => { const s = goodState(); delete s.project; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /project/.test(e.message))); }); test('validateSessionStateObject — missing status field', () => { const s = goodState(); delete s.status; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /status/.test(e.message))); }); test('validateSessionStateObject — missing next_session_brief_path', () => { const s = goodState(); delete s.next_session_brief_path; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /next_session_brief_path/.test(e.message))); }); test('validateSessionStateObject — missing next_session_label', () => { const s = goodState(); delete s.next_session_label; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /next_session_label/.test(e.message))); }); test('validateSessionStateObject — missing updated_at', () => { const s = goodState(); delete s.updated_at; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_MISSING_FIELD' && /updated_at/.test(e.message))); }); test('validateSessionStateObject — invalid status value rejected', () => { const s = goodState(); s.status = 'maybe'; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_INVALID_STATUS')); }); test('validateSessionStateObject — status completed valid but warns NOT_RESUMABLE', () => { const s = goodState(); s.status = 'completed'; const r = validateSessionStateObject(s); assert.equal(r.valid, true); assert.deepEqual(r.errors, []); assert.ok(r.warnings.find(w => w.code === 'SESSION_STATE_NOT_RESUMABLE')); }); test('validateSessionStateObject — schema_version mismatch fails', () => { const s = goodState(); s.schema_version = 2; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_SCHEMA_MISMATCH')); }); test('validateSessionStateObject — invalid timestamp rejected', () => { const s = goodState(); s.updated_at = 'not-a-date'; const r = validateSessionStateObject(s); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_INVALID_TIMESTAMP')); }); test('validateSessionStateContent — malformed JSON returns SESSION_STATE_PARSE_ERROR', () => { const r = validateSessionStateContent('{ broken'); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_PARSE_ERROR')); }); test('validateSessionState — missing file returns SESSION_STATE_NOT_FOUND', () => { const r = validateSessionState('/tmp/nonexistent-ultracontinue-test-9b2f4e.json'); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_NOT_FOUND')); }); test('validateSessionState — fixture file loads and parses correctly (SC-1)', () => { const r = validateSessionState('tests/fixtures/session-state/valid-in-progress.json'); assert.equal(r.valid, true); assert.equal(r.parsed.status, 'in_progress'); assert.equal(typeof r.parsed.project, 'string'); assert.equal(typeof r.parsed.next_session_brief_path, 'string'); assert.equal(typeof r.parsed.next_session_label, 'string'); }); test('validateSessionState — malformed fixture returns SESSION_STATE_PARSE_ERROR (SC-3)', () => { const r = validateSessionState('tests/fixtures/session-state/malformed.json'); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'SESSION_STATE_PARSE_ERROR')); }); test('validateSessionStateObject — forward-compat: unknown keys ignored silently', () => { // Simulates graceful-handoff v2.2 dual-write with extra fields. const s = { ...goodState(), branch: 'main', git_status: { dirty: false, ahead: 0, detached: false }, committed_by: 'graceful-handoff', last_commits: [{ sha: 'abc1234', msg: 'feat: foo' }], next_steps: ['cd repo', 'git status'], }; const r = validateSessionStateObject(s); assert.equal(r.valid, true); assert.deepEqual(r.errors, []); assert.deepEqual(r.warnings, []); });