feat(foundry): close the operational map data loop
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("Design Profile owns design-only page fragments while Page Library stays read-only", async () => {
|
||||
const runtimeDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-design-profile-"));
|
||||
const port = await freePort();
|
||||
const foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
|
||||
cwd: foundryRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "development",
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(port),
|
||||
FOUNDRY_RUNTIME_DIR: runtimeDir,
|
||||
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForService(port, foundry);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const profileResponse = await fetch(`${base}/api/design-profiles/default`);
|
||||
assert.equal(profileResponse.status, 200);
|
||||
const profile = await profileResponse.json();
|
||||
const fragment = profile.layout.pageTypes["map@0.1.0"];
|
||||
assert.equal(fragment.templateId, "map");
|
||||
assert.equal(fragment.templateVersion, "0.1.0");
|
||||
assert.equal("camera" in fragment, false);
|
||||
assert.equal("pinBindings" in fragment, false);
|
||||
assert.equal("dataProductBindings" in fragment, false);
|
||||
|
||||
const pageLibraryMutation = await fetch(`${base}/api/page-layouts/map`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(pageLibraryMutation.status, 405);
|
||||
assert.equal((await pageLibraryMutation.json()).error, "page_library_read_only");
|
||||
|
||||
const forbiddenFragment = await fetch(`${base}/api/design-profiles/default`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: profile.name,
|
||||
layout: {
|
||||
...profile.layout,
|
||||
pageTypes: {
|
||||
...profile.layout.pageTypes,
|
||||
"map@0.1.0": { ...fragment, camera: { longitude: 0, latitude: 0 } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(forbiddenFragment.status, 400);
|
||||
assert.equal((await forbiddenFragment.json()).error, "design_profile_page_fragment_contains_runtime");
|
||||
|
||||
const savedProfileResponse = await fetch(`${base}/api/design-profiles/default`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: profile.name,
|
||||
layout: {
|
||||
...profile.layout,
|
||||
pageTypes: {
|
||||
...profile.layout.pageTypes,
|
||||
"map@0.1.0": {
|
||||
...fragment,
|
||||
settings: { ...fragment.settings, buildingsOpacity: 0.74 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(savedProfileResponse.status, 200);
|
||||
const savedProfile = await savedProfileResponse.json();
|
||||
assert.equal(savedProfile.version, "0.6.1");
|
||||
assert.equal(savedProfile.layout.pageTypes["map@0.1.0"].settings.buildingsOpacity, 0.74);
|
||||
|
||||
const applicationResponse = await fetch(`${base}/api/applications`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "Profile merge test", slug: "profile-merge-test", templateId: "map", templateVersion: "0.1.0" }),
|
||||
});
|
||||
assert.equal(applicationResponse.status, 201);
|
||||
const application = await applicationResponse.json();
|
||||
application.pages[0].designOverrides = { map: { settings: { buildingsOpacity: 0.5 } } };
|
||||
const applicationSaveResponse = await fetch(`${base}/api/applications/${application.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(application),
|
||||
});
|
||||
assert.equal(applicationSaveResponse.status, 200);
|
||||
const savedApplication = await applicationSaveResponse.json();
|
||||
assert.deepEqual(savedApplication.pages[0].designOverrides, { map: { settings: { buildingsOpacity: 0.5 } } });
|
||||
} finally {
|
||||
foundry.kill("SIGTERM");
|
||||
await Promise.race([once(foundry, "exit"), new Promise((resolveWait) => setTimeout(resolveWait, 2_000))]);
|
||||
if (foundry.exitCode === null) foundry.kill("SIGKILL");
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function freePort() {
|
||||
const server = createNetServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
assert(address && typeof address === "object");
|
||||
const port = address.port;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForService(port, child) {
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
if (response.ok) return;
|
||||
} catch { /* service is still starting */ }
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
||||
}
|
||||
throw new Error(`foundry_start_timeout:${output}`);
|
||||
}
|
||||
Reference in New Issue
Block a user