import test from 'node:test'; import assert from 'node:assert/strict'; import { calculateSummary, JsonOrgChartDataSource, normalizeOrgChartDataset, OrgChartSourceError, snapshotOrgChartDataset, validateOrgChartDataset, type ApprovedAggregationPolicy, type OrgChartDataset, } from './index.js'; function fixture(): OrgChartDataset { return { schemaVersion: '0.1-draft', organizations: [ { id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'a', displayOrder: 0 }, { id: 'team-a', parentId: 'root', name: '가상 기획팀', type: 'team', leaderId: 'b', displayOrder: 2 }, { id: 'team-b', parentId: 'root', name: '가상 운영팀', type: 'team', leaderId: null, displayOrder: 1 }, ], employees: [ { id: 'a', organizationId: 'root', affiliationId: 'aff-a', name: '가상직원 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' }, { id: 'b', organizationId: 'team-a', affiliationId: 'aff-a', name: '가상직원 나', position: '책임', jobTitle: '팀장', profileImage: null, workStatus: 'active' }, { id: 'c', organizationId: 'team-a', affiliationId: 'aff-b', name: '가상직원 다', position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'dispatched' }, { id: 'd', organizationId: 'team-b', affiliationId: 'aff-b', name: '가상직원 라', position: '선임', jobTitle: null, profileImage: 'https://example.invalid/virtual.png', workStatus: 'onLeave' }, ], affiliations: [{ id: 'aff-a', name: '가상 관계사 A' }, { id: 'aff-b', name: '가상 관계사 B' }], metadata: { asOfDate: '2026-10-12', dataVersion: 'test-1' }, }; } function codes(input: unknown): string[] { const result = validateOrgChartDataset(input); assert.equal(result.valid, false); return result.valid ? [] : result.issues.map(issue => issue.code); } const policy: ApprovedAggregationPolicy = { policyVersion: 'approved-test-1', includedStatuses: ['active', 'dispatched'], activeStatuses: ['active'], includeLeaders: true, membershipMode: 'primaryOnly', }; test('minimal v0.1-draft dataset validates and owns an immutable copy', () => { const original = fixture(); const result = validateOrgChartDataset(original); assert.equal(result.valid, true); if (!result.valid) return; original.organizations[0]!.name = 'Changed'; assert.equal(result.data.organizations[0]!.name, '가상 본부'); assert.ok(Object.isFrozen(result.data.employees[0])); }); test('version is required at top level; compatibility metadata mirror must agree', () => { const original = fixture() as unknown as Record; delete original.schemaVersion; assert.ok(codes(original).includes('SCHEMA_VERSION')); const incompatible = fixture(); (incompatible.metadata as unknown as Record).schemaVersion = 'future'; assert.ok(codes(incompatible).includes('SCHEMA_VERSION')); }); test('required fields, nullability, whitespace and unknown fields are rejected', () => { const original = fixture(); original.organizations[0]!.name = ' '; original.employees[0]!.position = null as unknown as string; (original.organizations[1] as unknown as Record).nodeTemplate = ''; assert.ok(codes(original).includes('REQUIRED_VALUE')); assert.ok(codes(original).includes('UNKNOWN_FIELD')); }); test('dates are real calendar dates and timestamps require valid clock/timezone', () => { for (const date of ['2026-02-30', '2026-13-01', '2026-1-1']) { const original = fixture(); original.metadata.asOfDate = date; assert.ok(codes(original).includes('REQUIRED_VALUE')); } const original = fixture(); original.metadata.updatedAt = '2026-10-12T24:00:00Z'; assert.ok(codes(original).includes('REQUIRED_VALUE')); original.metadata.updatedAt = '2026-10-12T11:23:45+09:00'; assert.equal(validateOrgChartDataset(original).valid, true); }); test('zero employees is valid; zero organizations is a data error', () => { const original = fixture(); original.employees = []; original.organizations.forEach(org => { org.leaderId = null; }); assert.equal(validateOrgChartDataset(original).valid, true); original.organizations = []; assert.ok(codes(original).includes('EMPTY_ORGANIZATIONS')); }); test('duplicate collection IDs never overwrite an earlier item', () => { const original = fixture(); original.employees.push({ ...original.employees[0]! }); assert.ok(codes(original).includes('DUPLICATE_ID')); }); test('employee, parent, affiliation and leader references are checked', () => { const original = fixture(); original.employees[0]!.organizationId = 'missing'; original.employees[1]!.affiliationId = 'missing'; original.organizations[2]!.parentId = 'missing'; original.organizations[2]!.leaderId = 'missing'; const failures = codes(original); for (const code of ['ORGANIZATION_REFERENCE', 'AFFILIATION_REFERENCE', 'PARENT_REFERENCE', 'LEADER_REFERENCE']) assert.ok(failures.includes(code)); }); test('leader cross-organization membership requires explicit business approval', () => { const original = fixture(); original.organizations[2]!.leaderId = 'b'; assert.ok(codes(original).includes('LEADER_APPROVAL_REQUIRED')); }); test('self-parent and multi-level cycles are rejected independently of sorting', () => { const original = fixture(); original.organizations[0]!.parentId = 'team-a'; assert.ok(codes(original).includes('HIERARCHY_CYCLE')); original.organizations[0]!.parentId = 'root'; assert.ok(codes(original).includes('HIERARCHY_CYCLE')); }); test('unknown vocabularies are rejected without implicit status mapping', () => { const original = fixture(); original.employees[0]!.workStatus = 'retired' as never; original.organizations[0]!.type = 'unknown' as never; assert.ok(codes(original).includes('INVALID_STATUS')); assert.ok(codes(original).includes('INVALID_TYPE')); }); test('relation endpoints, self-relations and duplicate directed relations are checked', () => { const original = fixture(); original.relations = [ { id: 'r1', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' }, { id: 'r2', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' }, { id: 'r3', sourceOrganizationId: 'missing', targetOrganizationId: 'missing', type: 'support' }, ]; assert.ok(codes(original).includes('INVALID_RELATION')); assert.ok(codes(original).includes('RELATION_REFERENCE')); }); test('markup, executable URLs and identifier control characters are rejected', () => { for (const image of ['javascript:alert(1)', 'data:image/svg+xml,', '//host.invalid/x', 'file:///x', 'https://user:password@example.invalid/x', 'images\\x.png']) { const original = fixture(); original.employees[0]!.profileImage = image; assert.ok(codes(original).includes('UNSAFE_INPUT')); } const original = fixture(); original.employees[0]!.name = ''; original.organizations[1]!.id += '\u202e'; assert.ok(codes(original).includes('UNSAFE_INPUT')); }); test('normalization preserves hierarchy, forest roots and deterministic presentation order', () => { const original = fixture(); original.organizations.push({ id: 'other-root', parentId: null, name: '추가 본부', type: 'root', leaderId: null, displayOrder: 1 }); const natural = normalizeOrgChartDataset(original); assert.deepEqual(natural.roots.map(node => node.organization.id), ['root', 'other-root']); assert.deepEqual(natural.roots[0]!.children.map(node => node.organization.id), ['team-b', 'team-a']); original.presentation = { title: '가상 조직도', description: null, hubText: null, groups: { showEmployees: false, showSummary: true, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: ['team-a'], }; assert.equal(validateOrgChartDataset(original).valid, true); assert.deepEqual(normalizeOrgChartDataset(original).roots[0]!.children.map(node => node.organization.id), ['team-a', 'team-b']); original.presentation.organizationOrder.push('missing'); assert.ok(codes(original).includes('PRESENTATION_REFERENCE')); }); test('raw summary uses unique IDs, primary membership and position rather than job title', () => { const original = fixture(); const summary = calculateSummary(original); assert.equal(summary.raw.total, 4); assert.deepEqual(summary.raw.byOrganization.root, { direct: 1, inclusive: 4 }); assert.deepEqual(summary.raw.byOrganization['team-a'], { direct: 2, inclusive: 2 }); assert.equal(summary.raw.byPosition['선임'], 2); assert.equal(summary.raw.byPosition['팀장'], undefined); assert.equal(summary.raw.byAffiliation['aff-b'], 2); assert.deepEqual(summary.raw.byStatus, { active: 2, dispatched: 1, onLeave: 1 }); assert.deepEqual(summary.aggregation, { status: 'unavailable', reason: 'policyRequired' }); }); test('approved filtering changes business counts; raw counts and hidden groups remain unchanged', () => { const original = fixture(); const result = calculateSummary(original, policy); assert.equal(result.raw.total, 4); assert.equal(result.aggregation.status, 'ready'); if (result.aggregation.status === 'ready') { assert.equal(result.aggregation.counts.total, 3); assert.equal(result.aggregation.counts.byOrganization.root!.inclusive, 3); assert.equal(result.aggregation.counts.byStatus.onLeave, 0); } original.presentation = { title: '가상 조직도', description: null, hubText: '설명 콘텐츠', groups: { showEmployees: false, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: ['team-b'], }; original.relations = [{ id: 'rel-a', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'cooperation' }]; assert.deepEqual(calculateSummary(original, policy), result); original.employees[2]!.organizationId = 'team-b'; original.employees[2]!.workStatus = 'active'; original.employees[2]!.affiliationId = 'aff-a'; const moved = calculateSummary(original); assert.equal(moved.raw.byOrganization['team-a']!.direct, 1); assert.equal(moved.raw.byOrganization['team-b']!.direct, 2); assert.equal(moved.raw.byStatus.active, 3); assert.equal(moved.raw.byAffiliation['aff-a'], 3); }); test('ambiguous or invalid business policy stays unavailable', () => { for (const invalid of [ { ...policy, includeLeaders: false }, { ...policy, membershipMode: 'uniqueEmployee' }, { ...policy, policyVersion: '' }, { ...policy, includedStatuses: ['active', 'active'] }, { ...policy, includedStatuses: ['dispatched'] }, ]) { const result = calculateSummary(fixture(), invalid as ApprovedAggregationPolicy); assert.deepEqual(result.aggregation, { status: 'unavailable', reason: 'unsupportedPolicy' }); } }); test('prototype-like opaque IDs and positions cannot corrupt summary records', () => { const original = fixture(); original.employees[0]!.position = '__proto__'; assert.equal(calculateSummary(original).raw.byPosition['__proto__'], 1); }); test('source captures inline input once and returns owned copies', async () => { const original = fixture(); const source = new JsonOrgChartDataSource(original); original.metadata.dataVersion = 'consumer-mutated'; const first = await source.load() as OrgChartDataset; assert.equal(first.metadata.dataVersion, 'test-1'); assert.notEqual(first, await source.load()); assert.ok(Object.isFrozen(first)); const snapshot = snapshotOrgChartDataset(original); assert.notEqual(snapshot, original); }); test('URL source passes cancellation signal and captures the response', async () => { const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined; const source = new JsonOrgChartDataSource('./virtual.json', { fetch: (async (_url, options) => { receivedSignal = options?.signal; return new Response(JSON.stringify(fixture()), { status: 200 }); }) as typeof fetch }); assert.equal(validateOrgChartDataset(await source.load({ signal: controller.signal })).valid, true); assert.equal(receivedSignal, controller.signal); }); test('default network source loads with a receiver-sensitive browser-native fetch', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async function(this: unknown) { if (this !== globalThis) throw new TypeError('Illegal invocation'); return new Response(JSON.stringify(fixture()), { status: 200 }); } as typeof fetch; try { const source = new JsonOrgChartDataSource('./virtual.json'); assert.equal(validateOrgChartDataset(await source.load()).valid, true); } finally { globalThis.fetch = originalFetch; } }); test('source errors distinguish HTTP, parsing, unsafe URL and abort without leaking upstream text', async () => { assert.throws(() => new JsonOrgChartDataSource('javascript:alert(1)'), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_URL'); const failed = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { throw new Error('PRIVATE_SERVER_ERROR'); }) as typeof fetch }); await assert.rejects(failed.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD' && !error.message.includes('PRIVATE')); const http = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('private', { status: 500 })) as typeof fetch }); await assert.rejects(http.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD'); const parse = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('{invalid')) as typeof fetch }); await assert.rejects(parse.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_PARSE'); const controller = new AbortController(); controller.abort(); await assert.rejects(failed.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT'); }); test('late ignored abort response cannot become a successful source result', async () => { const controller = new AbortController(); const source = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { controller.abort(); return new Response(JSON.stringify(fixture())); }) as typeof fetch }); await assert.rejects(source.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT'); });