Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-09 12:24:22 +02:00
parent 9e9372db05
commit 22f856c62a
35 changed files with 2346 additions and 167 deletions
+163 -27
View File
@@ -9,8 +9,8 @@ import {
claimIfNeeded,
createShellBridge,
getRelayStatus,
startAgent,
setRelayState,
startAgent,
} from "../dist/agent.mjs";
test("claimIfNeeded persists claimed gateway credentials", async () => {
@@ -81,27 +81,90 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false);
});
test("shell bridge streams child process output", async () => {
test("shell bridge proxies PTY output, input, resize, and close events", async () => {
const messages = [];
const shell = createShellBridge((message) => messages.push(message));
const createdPtys = [];
shell.open({
const shell = createShellBridge(
(message) => messages.push(message),
{
createPtyProcess: async (options) => {
const listeners = {
data: null,
exit: null,
};
const pty = {
options,
writes: [],
resizes: [],
killed: false,
onData(callback) {
listeners.data = callback;
return {
dispose() {
listeners.data = null;
},
};
},
onExit(callback) {
listeners.exit = callback;
return {
dispose() {
listeners.exit = null;
},
};
},
write(data) {
this.writes.push(data);
},
resize(cols, rows) {
this.resizes.push({ cols, rows });
},
kill() {
this.killed = true;
listeners.exit?.({ exitCode: 0 });
},
emitData(data) {
listeners.data?.(data);
},
};
createdPtys.push(pty);
return pty;
},
}
);
await shell.open({
sessionId: "test-shell",
shellCommand: process.execPath,
shellArgs: ["-e", "process.stdin.on('data', (d) => process.stdout.write(d))"],
shellCommand: "/bin/bash",
shellArgs: ["-l"],
cols: 90,
rows: 24,
cwd: "/tmp",
});
await new Promise((resolve) => setTimeout(resolve, 50));
shell.input({ sessionId: "test-shell", data: "hello\n" });
await new Promise((resolve) => setTimeout(resolve, 50));
createdPtys[0].emitData("root@pi:~# ");
shell.input({ sessionId: "test-shell", data: "ls\r" });
shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
shell.close({ sessionId: "test-shell" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(messages.some((message) => message.type === "SHELL_OPENED"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("hello")));
assert.equal(createdPtys.length, 1);
assert.equal(createdPtys[0].options.command, "/bin/bash");
assert.deepEqual(createdPtys[0].options.args, ["-l"]);
assert.equal(createdPtys[0].options.cols, 90);
assert.equal(createdPtys[0].options.rows, 24);
assert.equal(createdPtys[0].options.cwd, "/tmp");
assert.deepEqual(createdPtys[0].writes, ["ls\r"]);
assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]);
assert.equal(createdPtys[0].killed, true);
assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi")));
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
});
test("startAgent retries the broker tunnel and reports degraded heartbeats while disconnected", async () => {
test("startAgent reports broker state via metadata and executes polled commands", async () => {
class FakeSocket extends EventEmitter {
constructor(url) {
super();
@@ -129,21 +192,85 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
agentToken: "agent-token",
heartbeatIntervalSeconds: 60,
reconnectDelayMs: 10,
commandPollTimeoutSeconds: 0,
commandPollRetryDelayMs: 5,
}));
const heartbeats = [];
const fakeFetch = async (url, options = {}) => {
heartbeats.push({
url,
body: JSON.parse(options.body ?? "{}"),
});
const resultPosts = [];
let polledCommandDelivered = false;
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
const fakeFetch = async (url, options = {}) => {
const body = options.body ? JSON.parse(options.body) : {};
if (String(url).endsWith("/heartbeat")) {
heartbeats.push({
url,
body,
});
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
}
if (String(url).endsWith("/commands/poll")) {
if (!polledCommandDelivered) {
polledCommandDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 99,
commandType: "DISCOVER_SHELLY",
payload: {
candidateIps: ["10.1.0.31"],
},
},
};
},
};
}
await new Promise((resolve) => setTimeout(resolve, 5));
return {
ok: true,
async json() {
return { data: null };
},
};
}
if (String(url).endsWith("/commands/99/result")) {
resultPosts.push({
url,
body,
});
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url) === "http://10.1.0.31/shelly") {
return {
ok: true,
async json() {
return {
mac: "AA:BB:CC:DD:EE:FF",
model: "Shelly Plus 1PM",
num_switches: 1,
};
},
};
}
throw new Error(`Unexpected URL: ${url}`);
};
const sockets = [];
@@ -160,19 +287,28 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
});
assert.equal(heartbeats[0].body.status, "DEGRADED");
assert.equal(heartbeats[0].body.discovery_status, "BROKER_DISCONNECTED");
assert.equal(heartbeats[0].body.metadata.broker_connected, false);
assert.equal("discovery_status" in heartbeats[0].body, false);
assert.equal(sockets.length, 1);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(resultPosts.length, 1);
assert.equal(resultPosts[0].body.ok, true);
assert.equal(Array.isArray(resultPosts[0].body.payload.inventory), true);
assert.equal(resultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
sockets[0].readyState = 1;
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "READY"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.metadata.broker_connected === true));
assert.ok(heartbeats.every((heartbeat) => !("discovery_status" in heartbeat.body)));
sockets[0].emit("close");
await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "BROKER_DISCONNECTED"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.status === "DEGRADED" && heartbeat.body.metadata.broker_connected === false));
assert.equal(sockets.length, 2);
agent.stop();