Files
HMDesign/packages/org-chart/src/data/data.test.ts
T

242 lines
14 KiB
TypeScript

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<string, unknown>;
delete original.schemaVersion;
assert.ok(codes(original).includes('SCHEMA_VERSION'));
const incompatible = fixture();
(incompatible.metadata as unknown as Record<string, unknown>).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<string, unknown>).nodeTemplate = '<script>alert(1)</script>';
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,<svg/>', '//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 = '<img src=x onerror=alert(1)>';
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');
});