215 lines
15 KiB
TypeScript
215 lines
15 KiB
TypeScript
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);
|
|
});
|