import test from 'node:test'; import assert from 'node:assert/strict'; import { applyOrgChartMove, createOrgChartMoveValidator, validateOrgChartMove, type OrgChartMoveFailureCode, type OrgChartMoveRequest } from './moves.js'; import { calculateSummary } from './summary.js'; import { normalizeOrgChartDataset } from './normalize.js'; import { snapshotOrgChartDataset } from './snapshot.js'; import { validateOrgChartDataset } from './validate.js'; import type { Employee, OrgChartDataset } from './types.js'; function member(id: string, organizationId: string, affiliationId = 'company-a'): Employee { return { id, organizationId, affiliationId, name: `가상 ${id}`, position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'active' }; } function fixture(): OrgChartDataset { return { schemaVersion: '0.1-draft', organizations: [ { id: 'a-root', companyId: 'company-a', parentId: null, name: '가상 A', type: 'root', leaderId: 'root-leader', displayOrder: 0 }, { id: 'a-division', parentId: 'a-root', name: '가상 본부', type: 'division', leaderId: null, displayOrder: 4 }, { id: 'a-team', parentId: 'a-division', name: '가상 팀', type: 'team', leaderId: 'team-leader', displayOrder: 8 }, { id: 'a-cell', parentId: 'a-team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 7 }, { id: 'a-other', parentId: 'a-root', name: '가상 다른 본부', type: 'division', leaderId: null, displayOrder: 2 }, { id: 'b-root', companyId: 'company-b', parentId: null, name: '가상 B', type: 'root', leaderId: null, displayOrder: 1 }, { id: 'b-team', parentId: 'b-root', name: '가상 B 팀', type: 'team', leaderId: null, displayOrder: 1 }, ], employees: [member('root-leader', 'a-root'), member('team-leader', 'a-team'), member('staff', 'a-team', 'company-b'), member('cell-staff', 'a-cell'), member('b-staff', 'b-team', 'company-b')], affiliations: [{ id: 'company-a', name: '가상 A 회사' }, { id: 'company-b', name: '가상 B 회사' }], relations: [{ id: 'support', sourceOrganizationId: 'a-team', targetOrganizationId: 'a-other', type: 'support' }], presentation: { title: '가상 조직도', description: null, hubText: null, groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true }, organizationOrder: ['a-other', 'a-team'], }, metadata: { asOfDate: '2026-10-13', dataVersion: 'move-test' }, }; } function request(kind: OrgChartMoveRequest['kind'], sourceId: string, targetOrganizationId: string): OrgChartMoveRequest { return { kind, sourceId, targetOrganizationId }; } function expectRejected(data: OrgChartDataset, move: OrgChartMoveRequest, code: OrgChartMoveFailureCode) { const before = JSON.stringify(data); const policy = validateOrgChartMove(data, move); assert.equal(policy.allowed, false); if (!policy.allowed) { assert.equal(policy.code, code); assert.ok(policy.message.length > 0); } const result = applyOrgChartMove(data, move); assert.equal(result.moved, false); if (!result.moved) assert.equal(result.code, code); assert.equal(JSON.stringify(data), before, 'rejected movement retains every input record'); } test('employee move changes organizationId alone, retains affiliation and IDs, updates counts, and owns frozen state', () => { const original = snapshotOrgChartDataset(fixture()); const before = JSON.stringify(original); const move = request('employee', 'staff', 'a-other'); const result = applyOrgChartMove(original, move); assert.equal(result.moved, true); if (!result.moved) return; assert.equal(result.previousOrganizationId, 'a-team'); assert.deepEqual(result.request, move); assert.deepEqual(result.data.employees.find(employee => employee.id === 'staff'), { ...original.employees.find(employee => employee.id === 'staff')!, organizationId: 'a-other' }); assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'a-other' } : employee) }); assert.equal(JSON.stringify(original), before); assert.ok(Object.isFrozen(result.data) && Object.isFrozen(result.data.employees) && Object.isFrozen(result.data.employees[0]) && Object.isFrozen(result.request)); assert.notEqual(result.data.employees[0], original.employees[0]); const normalized = normalizeOrgChartDataset(result.data); assert.equal(normalized.employeesByOrganization.get('a-team')!.some(employee => employee.id === 'staff'), false); assert.equal(normalized.employeesByOrganization.get('a-other')!.filter(employee => employee.id === 'staff').length, 1); const beforeCounts = calculateSummary(original).raw; const afterCounts = calculateSummary(result.data).raw; assert.equal(afterCounts.total, beforeCounts.total); assert.equal(afterCounts.byOrganization['a-team']!.direct, beforeCounts.byOrganization['a-team']!.direct - 1); assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-other']!.inclusive + 1); assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); assert.equal(validateOrgChartDataset(result.data).valid, true); }); test('group move changes parentId alone, preserving subtree, leaders, sorting, company, relations and employee total', () => { const original = snapshotOrgChartDataset(fixture()); const before = JSON.stringify(original); const result = applyOrgChartMove(original, request('organization', 'a-team', 'a-other')); assert.equal(result.moved, true); if (!result.moved) return; assert.equal(result.previousOrganizationId, 'a-division'); assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'a-other' } : org) }); assert.equal(JSON.stringify(original), before); const normalized = normalizeOrgChartDataset(result.data); assert.equal(normalized.organizationsById.get('a-cell')!.parentId, 'a-team'); assert.equal(normalized.organizationsById.get('a-team')!.leaderId, 'team-leader'); assert.equal(normalized.organizationsById.get('a-team')!.displayOrder, 8); assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length); const beforeCounts = calculateSummary(original).raw; const afterCounts = calculateSummary(result.data).raw; assert.equal(afterCounts.total, beforeCounts.total); assert.equal(afterCounts.byOrganization['a-team']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive); assert.equal(afterCounts.byOrganization['a-division']!.inclusive, 0); assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive); assert.equal(validateOrgChartDataset(result.data).valid, true); }); test('employee policy rejects leaders, absent IDs and existing membership', () => { const data = fixture(); expectRejected(data, request('employee', 'team-leader', 'a-other'), 'LEADER_PROTECTED'); expectRejected(data, request('employee', 'missing', 'a-other'), 'SOURCE_NOT_FOUND'); expectRejected(data, request('employee', 'staff', 'missing'), 'TARGET_NOT_FOUND'); expectRejected(data, request('employee', 'staff', 'a-team'), 'SAME_ORGANIZATION'); }); test('group policy rejects roots, self, descendants, existing parent and absent IDs', () => { const data = fixture(); expectRejected(data, request('organization', 'a-root', 'a-other'), 'ROOT_PROTECTED'); expectRejected(data, request('organization', 'a-team', 'a-team'), 'SELF_TARGET'); expectRejected(data, request('organization', 'a-team', 'a-cell'), 'DESCENDANT_TARGET'); expectRejected(data, request('organization', 'a-team', 'a-division'), 'SAME_ORGANIZATION'); expectRejected(data, request('organization', 'missing', 'a-other'), 'SOURCE_NOT_FOUND'); expectRejected(data, request('organization', 'a-team', 'missing'), 'TARGET_NOT_FOUND'); }); test('cross-company employee move updates existing organization and affiliation references, company totals and immutable state', () => { const data = fixture(); data.employees[2]!.affiliationId = 'company-a'; const original = snapshotOrgChartDataset(data), before = JSON.stringify(original); const move = request('employee', 'staff', 'b-team'); assert.deepEqual(validateOrgChartMove(original, move), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-b' }); const result = applyOrgChartMove(original, move); assert.equal(result.moved, true); if (!result.moved) return; assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'b-team', affiliationId: 'company-b' } : employee) }); const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw; assert.equal(afterCounts.total, beforeCounts.total); assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - 1); assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + 1); assert.equal(afterCounts.byAffiliation['company-a'], beforeCounts.byAffiliation['company-a']! - 1); assert.equal(afterCounts.byAffiliation['company-b'], beforeCounts.byAffiliation['company-b']! + 1); assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); assert.ok(Object.isFrozen(result.data.employees[2])); assert.equal(JSON.stringify(original), before); assert.equal(validateOrgChartDataset(result.data).valid, true); }); test('cross-company group move transfers all descendant companies and employee affiliations while retaining subtree and leaders', () => { const original = snapshotOrgChartDataset(fixture()), before = JSON.stringify(original); const result = applyOrgChartMove(original, request('organization', 'a-team', 'b-team')); assert.equal(result.moved, true); if (!result.moved) return; const subtree = new Set(['a-team', 'a-cell']); assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'b-team', companyId: 'company-b' } : subtree.has(org.id) ? { ...org, companyId: 'company-b' } : org), employees: original.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: 'company-b' } : employee), }); assert.equal(result.data.organizations.find(org => org.id === 'a-cell')!.parentId, 'a-team'); assert.equal(result.data.organizations.find(org => org.id === 'a-team')!.leaderId, 'team-leader'); assert.equal(result.data.employees.find(employee => employee.id === 'team-leader')!.organizationId, 'a-team'); const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw; const transferred = beforeCounts.byOrganization['a-team']!.inclusive; assert.equal(afterCounts.total, beforeCounts.total); assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - transferred); assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + transferred); assert.equal(afterCounts.byAffiliation['company-b'], 4); assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length); assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); assert.equal(JSON.stringify(original), before); assert.equal(validateOrgChartDataset(result.data).valid, true); assert.equal(validateOrgChartMove(result.data, request('organization', 'a-cell', 'a-other')).allowed, true, 'transferred subtree can subsequently move back'); }); test('cross-company moves reject unknown target affiliation rather than inventing a new company record', () => { const data = fixture(); data.organizations[5]!.companyId = 'unregistered-company'; assert.equal(validateOrgChartDataset(data).valid, true); expectRejected(data, request('employee', 'staff', 'b-team'), 'TARGET_AFFILIATION_MISSING'); expectRejected(data, request('organization', 'a-team', 'b-team'), 'TARGET_AFFILIATION_MISSING'); }); test('policy rejects malformed request, duplicate IDs, invalid source relationships and company hierarchy contradictions', () => { expectRejected(fixture(), { kind: 'unknown', sourceId: 'staff', targetOrganizationId: 'a-other' } as unknown as OrgChartMoveRequest, 'INVALID_REQUEST'); const duplicate = fixture(); duplicate.employees.push({ ...duplicate.employees[2]! }); expectRejected(duplicate, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); const cycle = fixture(); cycle.organizations[1]!.parentId = 'a-cell'; expectRejected(cycle, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); const invalidEmployee = fixture(); invalidEmployee.employees[2]!.organizationId = 'missing'; expectRejected(invalidEmployee, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); const conflict = fixture(); conflict.organizations[2]!.companyId = 'company-b'; expectRejected(conflict, request('organization', 'a-team', 'a-other'), 'INVALID_DATA'); }); test('legacy roots derive company through their leader but unresolved company identity prevents moves', () => { const legacy = fixture(); delete legacy.organizations[0]!.companyId; assert.equal(validateOrgChartMove(legacy, request('employee', 'staff', 'a-other')).allowed, true); legacy.organizations[0]!.leaderId = null; expectRejected(legacy, request('employee', 'staff', 'a-other'), 'COMPANY_UNKNOWN'); }); test('drop-target validator snapshots once and retains its policy if a caller later mutates the dataset', () => { const data = fixture(); const policy = createOrgChartMoveValidator(data); data.employees[2]!.organizationId = 'b-team'; assert.deepEqual(policy(request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-a' }); assert.deepEqual(validateOrgChartMove(data, request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'b-team', previousCompanyId: 'company-b', companyId: 'company-a' }); }); test('opaque IDs remain safe move keys without duplicate or synthetic records', () => { const data = fixture(); data.organizations[4]!.id = '__proto__'; data.relations![0]!.targetOrganizationId = '__proto__'; data.presentation!.organizationOrder[0] = '__proto__'; const result = applyOrgChartMove(data, request('employee', 'staff', '__proto__')); assert.equal(result.moved, true); if (!result.moved) return; assert.equal(result.data.employees.find(employee => employee.id === 'staff')!.organizationId, '__proto__'); assert.equal(result.data.employees.length, data.employees.length); assert.equal(validateOrgChartDataset(result.data).valid, true); });