Implemented the Organization Chart feature to create customer organizational

This commit is contained in:
2026-10-26 18:15:32 -03:00
parent bf48ed1dcb
commit 3229ce9c41
41 changed files with 4099 additions and 63 deletions

View File

@@ -0,0 +1,114 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const factory = new vm.Script(fs.readFileSync("src/services/fusion/account-contact.js", "utf8") + "\ncreateFusionAccountContact").runInNewContext();
function setup(response) {
const calls=[], logs=[], failures=[], window={}; let tokens=0;
const service=factory({ environment:{window, async fetch(url,options) {
assert.equal(this,window,"Native fetch must use Window as its receiver");
calls.push({url,options}); return response instanceof Response ? response : Response.json(response);
}}, getToken:async()=>{tokens++;return "test-relay-token";},logRequestStart:entry=>{logs.push(entry);return entry;},
logRequestSuccess:()=>{}, logRequestFailure:(_,error)=>failures.push(error),readResponseBodyForDebug:response=>response.clone().text() });
return {service,calls,logs,failures,tokens:()=>tokens};
}
test("contact opportunities uses the person's PartyNumber, paging and contact-query headers",async()=>{
const h=setup({items:[{OptyNumber:"A56DLD"}],hasMore:true,totalResults:12});
const data=await h.service.requestOpportunitiesByContact("18725565",5);
assert.equal(data.items[0].OptyNumber,"A56DLD");
const {url,options}=h.calls[0], parsed=new URL(url);
assert.equal(parsed.searchParams.get("q"),"(OpportunityContact.ContactPartyNumber='18725565')");
assert.equal(parsed.searchParams.get("offset"),"5");assert.equal(parsed.searchParams.get("limit"),"5");
assert.equal(options.method,"GET");assert.equal(options.headers.Authorization,"Bearer test-relay-token");
assert.equal(options.headers.Preference,"transient");assert.equal(options.headers.Origin,"https://eeho.fa.us2.oraclecloud.com");
assert.equal(h.logs[0].name,"requestOpportunitiesByContact");
assert.equal(h.logs[0].url,url);
assert.ok(url.includes("q=%28OpportunityContact.ContactPartyNumber%3D%2718725565%27%29"));
await assert.rejects(h.service.requestOpportunitiesByContact(undefined),/PartyNumber/);
assert.equal(h.calls.length,1);
});
test("contact identity lookup uses exact PartyId without name keyword ranking",async()=>{
const h=setup({items:[{PartyId:"123",PartyName:"New Name",PartyNumber:"789"}]});
const contacts=await h.service.requestQueryAccountContact(456,"Old Name",()=>true,"123");
const body=JSON.parse(h.calls[0].options.body);
assert.equal(body.keywords,"");assert.deepEqual(body.q.criteria.at(-1),{attribute:"PartyId",op:"$eq",value:"123"});
assert.equal(contacts[0].PartyNumber,"789");
});
test("contact opportunity query preserves an alphanumeric PartyNumber",async()=>{
const h=setup({items:[],hasMore:false});
await h.service.requestOpportunitiesByContact("A4KCS2P");
assert.equal(new URL(h.calls[0].url).searchParams.get("q"),"(OpportunityContact.ContactPartyNumber='A4KCS2P')");
assert.equal(h.logs[0].metadata.partyNumber,"A4KCS2P");
});
test("opportunities resolution failures are logged before any HTTP request",async()=>{
const h=setup({items:[]});
await assert.rejects(h.service.requestOpportunitiesByContact(null,0,{contactPartyId:"123",contactName:"Alex",resolvePartyNumber:async()=>undefined}),/HTTP request was not sent/);
assert.equal(h.calls.length,0);assert.equal(h.tokens(),0);
assert.equal(h.logs[0].name,"resolveContactForOpportunities");assert.equal(h.logs[0].method,"LOCAL");assert.equal(h.logs[0].metadata.httpRequestSent,false);
assert.equal(h.logs[0].metadata.contactPartyId,"123");assert.equal(h.failures.length,1);
await assert.rejects(h.service.requestOpportunitiesByContact(null,0,{resolvePartyNumber:async()=>{throw new Error("Contact query failed");}}),/Contact query failed/);
assert.equal(h.failures.length,2);
});
test("contact search posts the specified payload with dynamic PartyId, keywords and Token Relay bearer token",async()=>{
const h=setup({items:[{PartyId:123,PartyName:"Same Name",JobTitle:"Architect"},{PartyId:456,PartyName:"Same Name",JobTitle:null}]});
const result=await h.service.requestQueryAccountContact("100000810210835",'Alex "Cloud"');
assert.equal(result.length,2); assert.equal(result[0].PartyId,"123");
const {url,options}=h.calls[0]; assert.ok(url.endsWith("/crmRestApi/searchResources/latest/custom-actions/queries"));
assert.equal(options.method,"POST");assert.equal(options.headers.Authorization,"Bearer test-relay-token");
assert.equal(options.headers["Content-Type"],"application/json");assert.equal(options.credentials,"include");
assert.equal(options.headers.Preference,"transient");
assert.equal(options.headers.Origin,"https://eeho.fa.us2.oraclecloud.com");
const fields=["PartyName","PrimaryEmail.EmailAddress","JobTitle","PrimaryAccount.PartyName","PartyId","PartyNumber"];
assert.deepEqual(JSON.parse(options.body),{aggregationResults:false,skipInValidFields:true,skipHiddenFromUIFields:true,language:"en",onlyData:true,
keywords:'Alex "Cloud"',entity:"Contact",fields,keywordsFields:fields,
q:{op:"$and",criteria:[{op:"$or",innerQuery:true,criteria:[{attribute:"PrimaryAccount.PartyId",op:"$eq",value:100000810210835}]}],innerQuery:true},
sort:[{attribute:"_score",direction:"descending"}],limit:15,offset:0});
assert.equal(h.logs[0].name,"requestQueryAccountContact");assert.equal(h.calls.length,1);
});
test("contact search skips blank or obsolete queries and never sends a query without an account",async()=>{
const h=setup({items:[]});
await h.service.requestQueryAccountContact(123," ");
await h.service.requestQueryAccountContact(123,"Alex",()=>false);
await assert.rejects(h.service.requestQueryAccountContact(undefined,"Alex"),/PartyId/);
assert.equal(h.calls.length,0);assert.equal(h.tokens(),0);
});
test("contact search reports HTTP and malformed-response errors",async()=>{
for(const response of [new Response("Denied",{status:401}),{items:null}]){
const h=setup(response);await assert.rejects(h.service.requestQueryAccountContact(123,"Alex"));
assert.equal(h.calls.length,1);assert.equal(h.failures.length,1);
}
});
test("contact creation maps account, phone and mutually exclusive contact preferences", async()=>{
for (const preference of ["none", "deny", "allow"]) {
const h=setup({PartyId:321});
await h.service.requestPersistContact({PartyId:123,PartyNumber:"456"},{firstName:" Alex ",lastName:" Test ",jobTitle:"Engineer",email:"alex@example.test",
emailConsent:preference,workConsent:preference,mobileConsent:preference,workPhone:"3131313131",workCountry:"55",workExtension:"3131",mobilePhone:"31999999999",mobileCountry:"55"});
const {url,options}=h.calls[0], payload=JSON.parse(options.body);
assert.ok(url.endsWith("/contacts?onlyData=true")); assert.equal(options.method,"POST");
assert.equal(options.headers.Authorization,"Bearer test-relay-token");assert.equal(options.headers.Origin,"https://eeho.fa.us2.oraclecloud.com");
assert.equal(payload.AccountPartyId,"123");assert.equal(payload.AccountPartyNumber,"456");assert.equal(payload.FirstName,"Alex");
assert.equal(payload.RawWorkPhoneNumber,"55-3131313131");assert.equal(payload.WorkPhoneExtension,"3131");assert.equal(payload.RawMobileNumber,"55-31999999999");
for(const key of ["DoCallWorkPhoneFlag","DoCallMobilePhoneFlag","DoContactEmailFlag"]) assert.equal(payload[key],preference==="none"?undefined:preference==="allow");
for(const key of ["DoNotCallWorkPhoneFlag","DoNotCallMobileFlag","DoNotContactEmailFlag"]) assert.equal(payload[key],preference==="none"?undefined:preference==="deny");
assert.equal(h.logs[0].name,"requestPersistContact");
}
});
test("contact creation reports failed POST without automatic retries",async()=>{
const h=setup(new Response("Denied",{status:403}));
await assert.rejects(h.service.requestPersistContact({PartyId:123,PartyNumber:"456"},{firstName:"Alex",lastName:"Test",jobTitle:"",email:"alex@example.test",workPhone:"",workCountry:"",workExtension:"",mobilePhone:"",mobileCountry:""}),/403/);
assert.equal(h.calls.length,1);assert.equal(h.failures.length,1);
});
test("contact creation sends directly and omits empty optional fields", async()=>{
const values={firstName:"Alex",lastName:"Test",jobTitle:"",email:"alex@example.test",workPhone:"",workCountry:"",workExtension:"",mobilePhone:"",mobileCountry:""};
const h=setup({PartyId:321});
await h.service.requestPersistContact({PartyId:123,PartyNumber:"456"},values);
assert.equal(h.calls.length,1);assert.equal(h.tokens(),1);
assert.deepEqual(JSON.parse(h.calls[0].options.body),{FirstName:"Alex",LastName:"Test",AccountPartyId:"123",AccountPartyNumber:"456",Type:"ZCA_CONTACT",EmailAddress:"alex@example.test"});
});

View File

@@ -37,7 +37,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
}
}
}
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/features/org-chart/model.js", "src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {

View File

@@ -20,6 +20,18 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("debug renders opportunities lookup before HTTP headers exist and preserves failed resolution details", () => {
const h = fixture();
const entry = h.app.debugContentLog.logRequestStart({ name: "requestOpportunitiesByContact", method: "GET", url: "https://example.invalid/opportunities",
metadata: { httpRequestSent: false, phase: "Resolving contact PartyNumber" } });
h.app.debugContentLog.logRequestFailure(entry, "Contact PartyNumber unavailable. HTTP request was not sent.");
assert.ok(h.nodes().some(node => node.textContent === "Debug Requests"));
assert.equal(h.ids().length, 1);
assert.equal(h.app.debugContentLog.formatHeaderBlock(undefined), "(none)");
assert.equal(h.app.debugContentLog.formatMetadataBlock(null), "(none)");
assert.match(h.app.debugContentLog.formatMetadataBlock({httpRequestSent:false, offset:0}), /httpRequestSent: false\noffset: 0/);
});
test("catalog and identity secrets are revealed exactly by the eye button and hidden again", () => {
const h = fixture();
const body = JSON.stringify({ result: "catalog-exact-secret", detail: "Preserved" });
@@ -89,7 +101,8 @@ test("request categories cover every current family and unknown requests stay vi
for (const [name, category] of Object.entries({
requestTokenRelay: "authentication", requestTokenRelaySalesCommon: "authentication",
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpportunitiesByContact: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestQueryAccountContact: "contacts", requestPersistContact: "contacts",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
requestConsumptionPlanDetail: "consumption",
@@ -177,8 +190,11 @@ test("full-page debug opens with close focus, closes via Escape/button/shortcut
trigger.isConnected = true;
trigger.focus();
h.panel.hidden = true;
h.panel.inert = true;
h.app.debugContentView.toggleDebugPanel();
assert.equal(h.panel.hidden, false);
assert.equal(h.panel.inert, false);
assert.ok(h.modal.children.includes(h.panel), "debug escapes nested modal containers");
assert.equal(h.panel.getAttribute("role"), "dialog");
assert.equal(h.document.activeElement.getAttribute("aria-label"), "Close debug");
h.add("p", "requestOpptyProducts", 1, "pending");
@@ -292,7 +308,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css")
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -8,6 +8,7 @@
<body>
<button id="open" type="button">Open Opportunities</button>
<script src="/fixture-runtime.js"></script>
<script src="/org-storage-fixture.js"></script>
<script src="/extension.js"></script>
</body>
</html>

View File

@@ -7,7 +7,7 @@
OptyNumber: `TEST${index + 1}`,
OptyId: 1000 + index,
Name: `Synthetic opportunity ${index + 1} - Database migration`,
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview || projectionPreview ? 789 : "test-party", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview || projectionPreview ? 789 : "100000810210835", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
@@ -33,6 +33,10 @@
onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) {
const operation = message.type.split(".").at(-1);
if (operation === "loadOrganizationChart" || operation === "saveOrganizationChart") {
try { return await window.fixtureOrgStorage[operation === "loadOrganizationChart" ? "load" : "save"](message); }
catch (error) { return { ok: false, error: error.message }; }
}
if (operation === "requestWorkloadForecastUpdate") return { ok: true, result: null, debugRequests: [] };
if (operation === "requestWorkloads") return { ok: true, workloads: [{ id: 123, name: "Example workload",
adjustedACR: 50000, consumptionStartDate: "2026-11-15T00:00:00", forecast: Array.from({ length: 12 }, (_, index) => ({
@@ -97,9 +101,27 @@
const url = new URL(input);
let body;
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
else if (url.pathname.endsWith("/opportunities")) {
if (window.fixtureOpportunitiesDelay) await new Promise(resolve => setTimeout(resolve, window.fixtureOpportunitiesDelay));
(window.fixtureOpportunitiesByContact ||= []).push(url.toString());
const offset = Number(url.searchParams.get("offset"));
body = { items: Array.from({length: offset ? 1 : 5}, (_, i) => ({ OptyNumber: "CONTACT" + (offset + i), Name: "Contact opportunity " + (offset + i), WinProb: 60, CurrencyCode: "BRL", Revenue: "5656658", EffectiveDate: "2024-05-08", StatusCode: "CLOSED" })), totalResults: 6, hasMore: offset === 0 };
}
else if (url.pathname.endsWith("custom-actions/queries")) {
if (options.method === "GET") return Response.json({ items: [{ isDefault: true, savedSearchNumber: "fixture-default" }] });
const { offset, limit, entity } = JSON.parse(options.body);
const { offset, limit, entity, keywords } = JSON.parse(options.body);
if (entity === "Contact") {
(window.fixtureContactQueries ||= []).push(keywords);
if (window.fixtureContactsFailure) return new Response("Synthetic contacts failure", { status: 503 });
const contacts = ["Alex Morgan", "Taylor Chen", "Jordan Silva", "Sam Rivera"].map((PartyName, index) => ({
PartyName, PartyId: String(2000 + index), PartyNumber: String(18000 + index),
JobTitle: ["Chief Technology Officer", "Infrastructure Director", "Data and Analytics Director", "Cloud Architect"][index],
PrimaryEmail: { EmailAddress: "contact" + index + "@example.invalid" }, PrimaryAccount: { PartyName: "Example Customer" }
}));
if (window.fixtureContactDelay?.[keywords]) await new Promise(resolve => setTimeout(resolve, window.fixtureContactDelay[keywords]));
(window.fixtureContactCompleted ||= []).push(keywords);
return Response.json({ items: contacts.filter(contact => contact.PartyId === keywords || contact.PartyName.toLowerCase().includes(keywords.toLowerCase())).map(contact => ({ ...contact, ...window.fixtureContactOverrides?.[contact.PartyId] })), hasMore: false });
}
if (entity === "CRMServiceRequest") return new Response(JSON.stringify({ items: [{ SrId: "123456", SrNumber: "SR123456", Title: "Example parent SR", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", CustomerDeadline_c: "2026-11-30T00:00:00.000+0000", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST1" } }], hasMore: false }), { status: 200 });
body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };
} else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: <script>never executed</script>." };

View File

@@ -81,6 +81,7 @@ test("ten columns retain their links and put product/customer controls below the
assert.equal(customer.children[1].children[0].title, "View customer consumption");
assert.equal(customer.children[1].children[1].title, "View customer installed base");
assert.equal(customer.children[1].children[2].title, "View customer service requests");
assert.equal(customer.children[1].children[3].title, "View customer organization chart");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
@@ -267,7 +268,7 @@ test("loading skeleton preserves columns and action slots, then disappears on su
assert.equal(row.attributes["aria-hidden"], "true");
assert.deepEqual(row.children.map(cell => cell.attributes["data-column"]), h.headers().map(header => header.attributes["data-column"]));
assert.ok(row.children.every(cell => findClass(cell, "opportunities-extension-account-plans-skeleton-bar")));
assert.equal(walk(row).filter(node => hasClass(node, "opportunities-extension-table-skeleton-badge")).length, 7);
assert.equal(walk(row).filter(node => hasClass(node, "opportunities-extension-table-skeleton-badge")).length, 8);
assert.equal(walk(row).some(node => node.tag === "button" || node.tag === "a"), false);
}
h.app.opportunitiesTable.setOpportunitiesTableState({ status: nextStatus, items: nextStatus === "ready" ? [synthetic()] : [], message: "" });
@@ -295,14 +296,14 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.deepEqual(flexible, ["name", "customer"]);
assert.match(css, /th\[data-column="owner"\] \{ width: 132px;/);
assert.doesNotMatch(css, /width: (?:calc|clamp)\(/);
assert.match(css, /min-width: 1120px/);
assert.match(css, /min-width: 1168px/);
assert.match(css, /min-height: 2\.7em/);
assert.match(css, /padding-bottom: 38px/);
const actions = css.match(/\.opportunities-extension-cell-actions \{([^}]+)\}/)[1];
assert.match(actions, /position: absolute/);
assert.match(actions, /bottom: 10px/);
assert.match(actions, /height: 24px/);
assert.match(css, /min-width: 84px/);
assert.match(css, /min-width: 114px/);
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
@@ -312,12 +313,12 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
for (const width of [1120, 1280, 1440, 1840]) {
for (const width of [1168, 1280, 1440, 1840]) {
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
assert.ok(remaining / 2 >= 12 + 114, "four customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css"].map(read).join("\n");
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

81
tests/orgChart.test.cjs Normal file
View File

@@ -0,0 +1,81 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const { loadBackground } = require("./support/characterization.cjs");
const factory = (file, name) => new vm.Script(fs.readFileSync(file, "utf8") + "\n" + name).runInNewContext();
const model = factory("src/features/org-chart/model.js", "createOrgChartModel")();
const person = (id, parentId = null) => ({ id, parentId, name: id, role: "Director", notes: "", collapsed: false });
const plain = value => JSON.parse(JSON.stringify(value));
test("contact PartyNumber survives normalization and edits while legacy nodes remain supported", () => {
const nodes = model.normalize([{...person("a"), contactPartyId:"123", PartyNumber:18725565}]);
assert.equal(nodes[0].PartyNumber,"18725565");
assert.equal(model.normalize([{...person("alpha"), PartyNumber:"A4KCS2P"}])[0].PartyNumber,"A4KCS2P");
assert.equal(model.upsert(nodes,{...nodes[0],name:"Updated"})[0].PartyNumber,"18725565");
assert.equal(model.normalize([person("legacy")])[0].PartyNumber,undefined);
assert.throws(()=>model.normalize([{...person("a"),PartyNumber:"bad'query"}]),/PartyNumber/);
});
test("organization charts validate IDs, parents, cycles, required fields and limits", () => {
const nodes = [person("a"), person("b", "a"), person("c", "b")];
assert.equal(model.normalize(nodes).length, 3);
for (const invalid of [
[person("a"), person("a")], [person("a", "missing")], [person("a", "a")],
[person("a", "b"), person("b", "a")], [{ ...person("a"), name: " " }],
[{ ...person("a"), notes: "x".repeat(4001) }],
Array.from({ length: 501 }, (_, i) => person("p" + i))
]) assert.throws(() => model.normalize(invalid));
assert.throws(() => model.upsert(nodes, { ...nodes[0], parentId: "c" }), /cycle/);
assert.deepEqual([...model.descendants(nodes, "b")], ["b", "c"]);
});
test("editing and deleting preserve other people and promote direct reports without losing notes", () => {
const nodes = [person("a"), { ...person("b", "a"), notes: "<script>literal text</script>" }, person("c", "b")];
const before = JSON.stringify(nodes);
const updated = model.upsert(nodes, { ...nodes[1], name: "Changed" });
assert.equal(updated[1].name, "Changed");
const removed = model.remove(updated, "a");
assert.equal(removed[0].parentId, null);
assert.equal(removed[1].parentId, "b");
assert.equal(removed[0].notes, nodes[1].notes);
assert.equal(JSON.stringify(nodes), before);
});
test("collapsed branches omit descendants from layout, not persisted data", () => {
const nodes = [person("a"), { ...person("b", "a"), collapsed: true }, person("c", "b"), person("d")];
const root = model.hierarchy(nodes);
assert.deepEqual(plain(root.children.map(node => node.id)), ["a", "d"]);
assert.equal(root.children[0].children[0].children.length, 0);
assert.equal(nodes.length, 4);
});
test("contact identity survives persistence while legacy people remain valid", () => {
const nodes = model.normalize([person("legacy"), { ...person("contact"), contactPartyId: "300008443263169" }]);
assert.equal(nodes[0].contactPartyId, undefined);
assert.equal(nodes[1].contactPartyId, "300008443263169");
assert.throws(() => model.normalize([{ ...person("a"), contactPartyId: "" }]));
});
test("normalization persists Job Title but never email address", () => {
const [node] = model.normalize([{ ...person("a"), emailAddress: "test@example.invalid", jobTitle: "Director" }]);
assert.equal(Object.hasOwn(node, "role"), false);
assert.equal(Object.hasOwn(node, "emailAddress"), false);
assert.equal(node.jobTitle, "Director");
});
test("organization storage rejects invalid input and unavailable IndexedDB without claiming a save", async () => {
const store = factory("src/features/org-chart/storage.js", "createOrgChartStorage")({ environment: {}, model });
await assert.rejects(store.load({ principal: "", customerId: "1" }), /current user/);
await assert.rejects(store.load({ principal: "test@example.invalid", customerId: "1" }), /IndexedDB/);
await assert.rejects(store.save({ principal: "test@example.invalid", customerId: "1", revision: 0, nodes: [person("x", "missing")] }), /missing manager/);
});
for (const family of ["chrome", "firefox"]) test(family + ": organization messages return storage errors through the existing reply contract", async () => {
const app = loadBackground({ family });
for (const operation of ["loadOrganizationChart", "saveOrganizationChart"]) {
const result = await app.dispatch({ type: "opportunitiesExtension." + operation, principal: "test@example.invalid", customerId: "1", revision: 0, nodes: [] });
assert.equal(result.ok, false);
assert.match(result.error, /IndexedDB/);
}
});