Add customer mass import service with API route, test coverage, and e-conomic integration

This commit is contained in:
Jeppe Bundgaard
2026-04-23 21:07:21 +02:00
parent 4420d76cd2
commit 0813bfc0f0
27 changed files with 2801 additions and 238 deletions
+1
View File
@@ -52,6 +52,7 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
+1
View File
@@ -77,6 +77,7 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
+7
View File
@@ -86,6 +86,7 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
@@ -94,32 +95,38 @@ services:
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.priority=200"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.priority=200"
- "traefik.http.routers.edge-broker-api-staging.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.priority=200"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.priority=200"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.priority=200"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
+73 -17
View File
@@ -12,6 +12,21 @@ import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.m
const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
try {
await fs.access(path.join(cwd, "docker-compose.yml"));
return cwd;
} catch {
return path.resolve(path.dirname(scriptPath), "..");
}
}
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
if (stdio === "inherit") {
await new Promise((resolve, reject) => {
@@ -74,8 +89,8 @@ async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 50
throw new Error(message);
}
async function ensureComposeServices(rootDir) {
await runCommand("docker", ["compose", "up", "-d", ...COMPOSE_SERVICES], {
async function ensureComposeServices(rootDir, composeProject) {
await runCommand("docker", composeArgs(composeProject, ["up", "-d", ...COMPOSE_SERVICES]), {
cwd: rootDir,
stdio: "inherit",
});
@@ -120,21 +135,20 @@ function parseLastJsonLine(output) {
throw new Error(`Unable to parse JSON from command output:\n${output}`);
}
async function runPhpFixture(rootDir, action, payload = null) {
async function runPhpFixture(rootDir, composeProject, action, payload = null) {
const encodedPayload = payload === null
? ""
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
const result = await runCommand("docker", [
"compose",
const result = await runCommand("docker", composeArgs(composeProject, [
"exec",
"-T",
"php1",
"sh",
"-lc",
command,
], {
]), {
cwd: rootDir,
});
@@ -233,6 +247,7 @@ async function waitForSocketOpen(socket) {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
const socketUrl = typeof socket.url === "string" && socket.url !== "" ? ` (${socket.url})` : "";
const onOpen = () => {
clearTimeout(timeout);
@@ -241,11 +256,25 @@ async function waitForSocketOpen(socket) {
const onError = (error) => {
clearTimeout(timeout);
reject(error instanceof Error ? error : new Error(String(error)));
if (error instanceof Error) {
reject(error);
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : "unknown";
reject(new Error(`Websocket failed to open${socketUrl}; readyState=${readyState}.`));
};
const onClose = (event) => {
clearTimeout(timeout);
const code = event && typeof event === "object" && "code" in event ? event.code : "unknown";
const reason = event && typeof event === "object" && "reason" in event ? event.reason : "";
reject(new Error(`Websocket closed before open${socketUrl}; code=${code} reason=${reason || "none"}.`));
};
onSocket(socket, "open", onOpen);
onSocket(socket, "error", onError);
onSocket(socket, "close", onClose);
});
}
@@ -282,12 +311,15 @@ function collectMessages(rows) {
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const rootDir = await resolveRootDir(scriptPath);
const runId = randomUUID().slice(0, 8);
const containerName = `truckwash-edge-e2e-${runId}`;
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
const composeProject =
process.env.EDGE_GATEWAY_E2E_COMPOSE_PROJECT
|| path.basename(rootDir);
let fixture = null;
let gatewayId = null;
@@ -295,10 +327,10 @@ async function main() {
let shellSocket = null;
try {
await ensureComposeServices(rootDir);
await ensureComposeServices(rootDir, composeProject);
await waitForApiReady(baseUrl);
fixture = await runPhpFixture(rootDir, "create");
fixture = await runPhpFixture(rootDir, composeProject, "create");
const authToken = String(fixture.auth_token || "");
const departmentId = Number(fixture.department_id || 0);
@@ -383,6 +415,23 @@ async function main() {
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return Boolean(
detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected
);
},
{
timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.",
}
);
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
token: authToken,
@@ -401,6 +450,13 @@ async function main() {
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
);
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
assert.equal(
Boolean(readyMessage?.connected),
true,
"Gateway stream became ready before the broker reported the gateway as connected."
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
body: {
@@ -531,12 +587,6 @@ async function main() {
closeSocket(shellSocket);
closeSocket(streamSocket);
if (gatewayId !== null && fixture?.auth_token) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
}).catch(() => {});
}
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"stop",
@@ -547,8 +597,14 @@ async function main() {
allowFailure: true,
}).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
}).catch(() => {});
}
if (fixture !== null) {
await runPhpFixture(rootDir, "cleanup", fixture).catch(() => {});
await runPhpFixture(rootDir, composeProject, "cleanup", fixture).catch(() => {});
}
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
+22 -3
View File
@@ -21,6 +21,21 @@ export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
}
async function resolveRootDir(scriptPath) {
const cwd = process.cwd();
try {
await fs.access(path.join(cwd, "docker-compose.yml"));
return cwd;
} catch {
return path.resolve(path.dirname(scriptPath), "..");
}
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
@@ -245,7 +260,7 @@ async function ensureComposeServices(rootDir, skipComposeUp) {
return;
}
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], {
await runCommand("docker", composeArgs(resolveComposeProjectName(rootDir), ["up", "-d", ...DEFAULT_COMPOSE_SERVICES]), {
cwd: rootDir,
stdio: "inherit",
});
@@ -417,7 +432,7 @@ async function main() {
}
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const rootDir = await resolveRootDir(scriptPath);
const configDir = resolveConfigDirectory(rootDir, options.configDir);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
@@ -485,8 +500,12 @@ Gateway ID: ${config.gatewayId ?? "unclaimed"}
}
const currentFilePath = fileURLToPath(import.meta.url);
const currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedScript === path.resolve(currentFilePath)) {
const invokedRealPath = invokedScript !== ""
? await fs.realpath(invokedScript).catch(() => invokedScript)
: "";
if (invokedRealPath === currentRealPath) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
+1 -5
View File
@@ -4,11 +4,7 @@ WORKDIR /opt/truckwash-edge-agent
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
libsqlite3-dev \
ca-certificates; \
docker-php-ext-install curl sqlite3; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*
COPY services/nginx/app/resources/edge-gateway-agent/ ./
+199 -154
View File
@@ -1,5 +1,6 @@
import http from "node:http";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
function parseJsonBody(req) {
@@ -515,179 +516,183 @@ export function createBrokerServer(options = {}) {
return;
}
if (ws.gatewayId) {
if (message.type === "COMMAND_RESULT") {
const pending = pendingCommands.get(message.commandId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
pendingCommands.delete(message.commandId);
pending.resolve({
ok: Boolean(message.ok),
payload: message.payload,
error: message.error,
});
return;
}
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
try {
if (ws.gatewayId) {
if (message.type === "COMMAND_RESULT") {
const pending = pendingCommands.get(message.commandId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
pendingCommands.delete(message.commandId);
pending.resolve({
ok: Boolean(message.ok),
payload: message.payload,
error: message.error,
});
return;
}
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
if (message.type === "TELEMETRY") {
const payload = message.payload || {};
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
broadcastGatewayEvent(String(ws.gatewayId), {
type: "gateway.telemetry",
gatewayId: String(ws.gatewayId),
telemetry: payload,
gateway: ingested?.gateway || ingested || null,
});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "stats.updated",
gatewayId: String(ws.gatewayId),
statistics: ingested?.statistics || ingested || null,
});
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
if (message.type === "TASK_EVENT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
return;
}
if (message.type === "TASK_RESULT") {
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
if (!Number.isFinite(operationId) || operationId <= 0) {
return;
}
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "task.updated",
gatewayId: String(ws.gatewayId),
operationId,
operation,
});
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
sendJson(sessionRecord.ws, { type: "opened" });
return;
}
if (message.type === "SHELL_EXIT") {
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
await closeBrowserShellSession(sessionRecord, "agent_exit");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
}
}
}
return;
}
if (message.type === "LOG_FRAME") {
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
broadcastGatewayEvent(String(ws.gatewayId), {
type: "log.append",
gatewayId: String(ws.gatewayId),
entry: logEntry,
});
if (ws.sessionInfo) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (!agent || agent.readyState !== 1) {
return;
}
if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SHELL_OUTPUT") {
sessionRecord.transcript += String(message.data || "");
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
return;
}
if (message.type === "SHELL_OPENED") {
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
sendJson(sessionRecord.ws, { type: "opened" });
return;
}
if (message.type === "SHELL_EXIT") {
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
await closeBrowserShellSession(sessionRecord, "agent_exit");
browserShellSessions.delete(String(message.sessionId));
if (sessionRecord.ws.readyState < 2) {
sessionRecord.ws.close();
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
}
return;
}
if (ws.sessionInfo) {
const sessionId = String(ws.sessionInfo.id);
const agent = agents.get(String(ws.sessionInfo.gateway_id));
if (!agent || agent.readyState !== 1) {
return;
}
if (message.type === "input") {
sendJson(agent, {
type: "SHELL_INPUT",
payload: {
sessionId,
data: String(message.data || ""),
},
});
return;
}
if (message.type === "resize") {
sendJson(agent, {
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
});
return;
}
if (message.type === "close") {
sendJson(agent, {
type: "CLOSE_ROOT_SHELL",
payload: { sessionId },
});
}
return;
}
if (ws.streamSessionInfo) {
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
if (!sessionRecord) {
return;
}
if (message.type === "SUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.add(scope);
}
sendJson(sessionRecord.ws, {
type: "subscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "UNSUBSCRIBE") {
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
sessionRecord.subscriptions.delete(scope);
}
sendJson(sessionRecord.ws, {
type: "unsubscribed",
subscriptions: Array.from(sessionRecord.subscriptions.values()),
});
return;
}
if (message.type === "PING") {
sendJson(sessionRecord.ws, { type: "PONG" });
}
} catch {
// Ignore stale gateway/session delivery errors without killing the broker process.
}
});
@@ -788,3 +793,43 @@ export function createBrokerServer(options = {}) {
},
};
}
async function runBrokerFromCli() {
const broker = createBrokerServer();
let shuttingDown = false;
const shutdown = async (signal) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
try {
await broker.close();
process.exit(0);
} catch (error) {
console.error(`Failed to shut down broker after ${signal}:`, error);
process.exit(1);
}
};
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
const requestedPort = Number(process.env.PORT || 4300);
const address = await broker.listen(Number.isFinite(requestedPort) ? requestedPort : 4300);
const normalizedPort =
typeof address === "object" && address !== null && "port" in address
? address.port
: requestedPort;
console.log(`TruckWash edge broker listening on ${normalizedPort}`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
runBrokerFromCli().catch((error) => {
console.error("TruckWash edge broker failed to start:", error);
process.exit(1);
});
}
+30
View File
@@ -350,3 +350,33 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
agent.terminate();
await broker.close();
});
test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestTelemetry: async () => {
throw new Error("Edge gateway not found");
},
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "TELEMETRY",
payload: {
status: "ONLINE",
},
})
);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.equal(broker.server.listening, true);
agent.terminate();
await broker.close();
});
@@ -39,6 +39,9 @@ test("traefik does not expose a dedicated public edge broker port", () => {
test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
@@ -50,6 +53,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
@@ -58,6 +62,9 @@ test("example docker compose routes edge broker traffic through traefik", () =>
test("standalone production compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const brokerEntryPath = path.resolve(testDirectory, "../server.mjs");
test("server entrypoint starts the broker and stays alive until terminated", async () => {
const child = spawn(process.execPath, [brokerEntryPath], {
env: {
...process.env,
PORT: "0",
EDGE_AUTH_MODE: "stub",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Broker entrypoint did not report readiness.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
}, 10_000);
child.once("exit", (code, signal) => {
clearTimeout(timeout);
reject(new Error(`Broker entrypoint exited early with code=${code} signal=${signal}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
});
const poll = () => {
if (/TruckWash edge broker listening on \d+/.test(stdout)) {
clearTimeout(timeout);
resolve();
return;
}
setTimeout(poll, 25);
};
poll();
});
assert.equal(child.exitCode, null, `Broker exited unexpectedly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
const exitResult = await new Promise((resolve, reject) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
child.kill("SIGTERM");
setTimeout(() => reject(new Error("Broker did not exit after SIGTERM.")), 10_000);
});
const exitedCleanly = exitResult.code === 0 || exitResult.signal === "SIGTERM";
assert.equal(exitedCleanly, true, `Broker exited unsuccessfully.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
});
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
class customer_mass_import_service
{
/**
* Import or create a company customer from one normalized spreadsheet row.
*
* @throws \RuntimeException
*/
public function import(array $payload): array
{
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
$customerNumber = (int)$normalized['customer_number'];
$cvr = (string)$normalized['cvr'];
$warnings = [];
$economicCustomers = $this->searchEconomicCustomersByCvr($cvr);
$localUserExistsBefore = $this->localCustomerNumberExists($customerNumber);
$localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null;
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber);
if ($matchingEconomicCustomer !== null) {
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
true,
false,
$warnings
);
}
if (count($economicCustomers) > 0) {
$existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]);
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'search',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'existingCustomerNumber' => $existingEconomicCustomerNumber,
]);
throw new \RuntimeException(
'CVR already registered under customer number '
. $existingEconomicCustomerNumber
. '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.',
409
);
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'create',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'createdCustomerNumber' => $createdCustomerNumber,
'response' => $createResponse,
]);
throw new \RuntimeException(
'E-conomic created the customer under customer number '
. $createdCustomerNumber
. ' instead of the submitted phone number '
. $customerNumber
. '. Manual cleanup or reassignment is required before retrying.',
409
);
}
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
false,
true,
$warnings
);
}
protected function normalizePayload(array $payload): array
{
return [
'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null),
'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null),
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
protected function assertValidNormalizedPayload(array $normalized): void
{
$customerNumber = $normalized['customer_number'];
$cvr = $normalized['cvr'];
if ($customerNumber === null) {
throw new \RuntimeException('Phone number is required.', 400);
}
$customerNumberLength = strlen((string)$customerNumber);
if ($customerNumberLength < 8 || $customerNumberLength > 10) {
throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400);
}
if ($cvr === null) {
throw new \RuntimeException('CVR is required.', 400);
}
$cvrLength = strlen($cvr);
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
{
$digits = $this->normalizeDigitString($value);
if ($digits === null) {
return null;
}
$normalized = (int)$digits;
return $normalized > 0 ? $normalized : null;
}
protected function normalizeDigitString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
return $digits !== '' ? $digits : null;
}
protected function normalizeText(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
protected function normalizeEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.', 400);
}
return $email;
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
return $normalized['name'];
}
$name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr']));
if ($name === '') {
throw new \RuntimeException('Customer name is required to create a new company.', 400);
}
return $name;
}
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function searchEconomicCustomersByCvr(string $cvr): array
{
$response = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => $cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection ?? [];
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
'corporateIdentificationNumber' => (string)$normalized['cvr'],
'customerGroup' => [
'customerGroupNumber' => 1,
],
'paymentTerms' => [
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
],
];
if ($normalized['ean'] !== null) {
$payload['ean'] = (string)$normalized['ean'];
}
return (new economic())->customers->customers->create($payload);
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
$rows = (new users_o())->getFieldsWhere([
'customer_number' => (string)$customerNumber,
], ['id']);
return count($rows) > 0;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
return $this->localUserExists($customer) ? $customer : null;
}
protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object
{
if ($localUserExistsBefore && $this->localUserExists($localUser)) {
return $localUser;
}
return $this->bootstrapLocalCustomerOrFail($customerNumber);
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if ($this->localUserExists($customer)) {
return $customer;
}
$this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [
'customerNumber' => $customerNumber,
]);
throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? '');
}
protected function logIssue(string $action, array $context): void
{
$message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($message === false) {
$message = 'Unable to encode customer mass import context';
}
(new logs_o())->add('customers', 'global', 0, 0, $action, $message);
}
protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
{
foreach ($customers as $customer) {
if (!is_object($customer)) {
continue;
}
if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) {
return $customer;
}
}
return null;
}
protected function extractEconomicCustomerNumber(object $customer): int
{
if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) {
return 0;
}
return (int)$customer->customerNumber;
}
protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'account_already_exists',
'Customer already exists locally and already has a login account.',
];
}
if ($localUserExistsBefore) {
return [
'customer_already_exists',
'Customer already exists locally but does not have a login password yet.',
];
}
return [
'imported_existing_customer',
'Imported an existing e-conomic customer into the local customer database.',
];
}
protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'economic_customer_created_for_existing_account',
'Created the e-conomic customer for an existing local login account.',
];
}
if ($localUserExistsBefore) {
return [
'economic_customer_created_for_existing_customer',
'Created the e-conomic customer for an existing local customer record.',
];
}
return [
'created_customer',
'Created the customer in e-conomic and imported it locally.',
];
}
protected function buildSuccessResult(
array $normalized,
object $customer,
string $action,
string $message,
bool $existingLocalCustomer,
bool $existingEconomicCustomer,
bool $createdEconomicCustomer,
array $warnings
): array {
$customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name'];
return [
'customer_number' => (int)$normalized['customer_number'],
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
'user_id' => $this->extractLocalUserId($customer),
'has_account' => $this->hasLocalAccount($customer),
'existing_local_customer' => $existingLocalCustomer,
'existing_economic_customer' => $existingEconomicCustomer,
'created_economic_customer' => $createdEconomicCustomer,
'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')),
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
if (!$customer instanceof users_o || !$customer->exists()) {
return;
}
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) {
$customer->display_name->set($name);
}
if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') {
try {
$customer->setEmail($email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage();
}
}
}
protected function localUserExists(?object $user): bool
{
if (!is_object($user)) {
return false;
}
if (method_exists($user, 'exists')) {
try {
return (bool)$user->exists();
} catch (\Throwable) {
return false;
}
}
return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0;
}
protected function hasLocalAccount(?object $user): bool
{
if (!$this->localUserExists($user)) {
return false;
}
if (method_exists($user, 'hasPassword')) {
try {
return (bool)$user->hasPassword();
} catch (\Throwable) {
return false;
}
}
return (bool)($user->has_password ?? false);
}
protected function extractLocalUserId(?object $user): ?int
{
if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) {
return null;
}
$userId = (int)$user->id;
return $userId > 0 ? $userId : null;
}
protected function extractLocalUserDisplayName(?object $user): ?string
{
if (!is_object($user)) {
return null;
}
if ($user instanceof users_o) {
$name = trim((string)($user->display_name->value() ?? ''));
return $name !== '' ? $name : null;
}
$name = trim((string)($user->display_name ?? $user->name ?? ''));
return $name !== '' ? $name : null;
}
}
+2 -2
View File
@@ -9,11 +9,11 @@
],
"test:api:edge": [
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\""
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('API_TEST_REQUEST_TIMEOUT=180'); putenv('EDGE_GATEWAY_VIEW_CACHE_TTL=0'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\""
],
"test:integration:edge": [
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
],
"test:coverage": [
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
@@ -84,6 +84,7 @@ class edge_gateway_department_workspace_service
$lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$relays = $this->buildRelayPayloads($relayCatalog, $bindingsByRelayId, $consumersByRelayId);
$gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId);
$selfServe = $this->buildSelfServePayload($department, $lanes);
$scanners = $this->buildScannerPayloads($departmentId, $lanes);
@@ -108,6 +109,7 @@ class edge_gateway_department_workspace_service
'lanes' => $lanes,
'self_serve' => $selfServe,
'gates' => $gates,
'relays' => $relays,
'scanners' => $scanners,
'issues' => $issues,
'actions' => $actions,
@@ -326,6 +328,39 @@ class edge_gateway_department_workspace_service
return $gates;
}
/**
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
*/
private function buildRelayPayloads(
array $relayCatalog,
array $bindingsByRelayId,
array $consumersByRelayId
): array {
$relays = [];
foreach ($relayCatalog as $relayId => $relay) {
$config = isset($relay['config']) && is_array($relay['config'])
? (array)$relay['config']
: [];
$relays[] = [
'id' => isset($relay['id']) ? (int)$relay['id'] : 0,
'department' => isset($relay['department']) ? (int)$relay['department'] : 0,
'relay_id' => (string)($relay['relay_id'] ?? $relayId),
'name' => (string)($relay['name'] ?? $relayId),
'type' => (string)($relay['type'] ?? ''),
'config' => $config,
'coverage' => $this->buildRelayCoverage((string)$relayId, $bindingsByRelayId),
'consumer_contexts' => $consumersByRelayId[(string)$relayId] ?? [],
];
}
return $relays;
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<string,mixed>
@@ -124,7 +124,7 @@ class edge_gateway_manager
'status' => self::INSTALL_SESSION_STATUS_PENDING,
'step' => self::INSTALL_SESSION_STATUS_PENDING,
'message' => 'Installer command generated. Run it on the gateway host.',
], strtotime($expiresAt) - self::INSTALL_TOKEN_TTL_SECONDS),
], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS),
],
]);
@@ -1735,8 +1735,10 @@ BASH;
throw new Exception('Shell session is closed');
}
$expiresAt = $session->expires_at->value() === null ? null : strtotime((string)$session->expires_at->value());
if ($expiresAt !== null && $expiresAt !== false && $expiresAt <= time()) {
$expiresAt = self::parseApplicationDateTime(
$session->expires_at->value() === null ? null : (string)$session->expires_at->value()
);
if ($expiresAt !== null && $expiresAt <= time()) {
$session->status->set('EXPIRED');
$session->closed_at->set($this->now());
throw new Exception('Shell session expired');
@@ -2071,7 +2073,11 @@ BASH;
'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []),
];
$status = (string)$normalized['status'];
if (!self::installSessionStatusIsTerminal($status) && $expiresAt !== null && strtotime($expiresAt) < ($now ?? time())) {
if (
!self::installSessionStatusIsTerminal($status)
&& $expiresAt !== null
&& (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time())
) {
$status = self::INSTALL_SESSION_STATUS_EXPIRED;
$normalized['status'] = $status;
$normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.';
@@ -2257,7 +2263,16 @@ BASH;
$host .= ':' . $port;
}
return $scheme . '://' . $host;
$forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null);
if ($forwardedPrefix !== null) {
$normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/');
$basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix;
} else {
$requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : '';
}
return $scheme . '://' . $host . $basePath;
}
private function detectForwardedScheme(): ?string
@@ -2383,7 +2398,7 @@ BASH;
if (!$claimToken->exists()) {
throw new Exception('Invalid install token');
}
if (strtotime((string)$claimToken->expires_at->value()) < time()) {
if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) {
throw new Exception('Install token has expired');
}
@@ -2597,7 +2612,7 @@ BASH;
try {
$statement = $pdo->prepare(
'SELECT id
'SELECT id, delivery_json
FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
@@ -2626,16 +2641,25 @@ BASH;
return null;
}
$delivery = isset($row['delivery_json']) && is_string($row['delivery_json'])
? json_decode($row['delivery_json'], true)
: [];
if (!is_array($delivery)) {
$delivery = [];
}
$delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API;
$delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1;
$delivery['last_dispatch_error'] = null;
$encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedDelivery)) {
$encodedDelivery = '{}';
}
$update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs
SET status = :status,
response_json = :response_json,
delivery_json = JSON_SET(
COALESCE(delivery_json, JSON_OBJECT()),
\'$.delivery_channel\', :delivery_channel,
\'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1,
\'$.last_dispatch_error\', CAST(NULL AS JSON)
),
delivery_json = :delivery_json,
error_message = NULL,
completed_at = NULL
WHERE id = :id'
@@ -2643,7 +2667,7 @@ BASH;
$update->execute([
':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE),
':delivery_json' => $encodedDelivery,
':id' => (int)$row['id'],
]);
@@ -2655,6 +2679,7 @@ BASH;
throw $throwable;
}
$this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']);
return (new edge_gateway_command_jobs_o())->select((int)$row['id']);
}
@@ -4441,8 +4466,8 @@ BASH;
? (array)$gateway['active_operation']
: null;
if ($activeOperation !== null && !empty($activeOperation['started_at'])) {
$startedAt = strtotime((string)$activeOperation['started_at']);
if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']);
if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$diagnostics[] = [
'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT,
'severity' => 'warning',
@@ -4594,8 +4619,8 @@ BASH;
continue;
}
$epoch = strtotime($timestamp);
if ($epoch === false) {
$epoch = self::parseApplicationDateTime($timestamp);
if ($epoch === null) {
continue;
}
@@ -4622,6 +4647,16 @@ BASH;
return self::RELAY_FALLBACK_PREFER_LOCAL;
}
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string
{
if ($device === null) {
@@ -4692,8 +4727,8 @@ BASH;
return null;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
if ($heartbeatTimestamp === false) {
$heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt);
if ($heartbeatTimestamp === null) {
return null;
}
@@ -4706,8 +4741,7 @@ BASH;
return 0;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp;
return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0;
}
private static function statusPriority(string $status): int
@@ -4822,14 +4856,55 @@ BASH;
return hash('sha256', $plainToken);
}
public static function parseApplicationDateTime(?string $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
$timezone = self::applicationTimeZone();
$dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if ($dateTime instanceof \DateTimeImmutable) {
return $dateTime->getTimestamp();
}
try {
return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
public static function formatApplicationDateTime(int $timestamp): string
{
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone(self::applicationTimeZone())
->format('Y-m-d H:i:s');
}
private static function applicationTimeZone(): \DateTimeZone
{
$timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'));
if ($timezone === '') {
$timezone = 'Europe/Copenhagen';
}
try {
return new \DateTimeZone($timezone);
} catch (\Throwable) {
return new \DateTimeZone('Europe/Copenhagen');
}
}
private function now(): string
{
return date('Y-m-d H:i:s');
return self::formatApplicationDateTime(time());
}
private function formatDateTime(int $timestamp): string
{
return date('Y-m-d H:i:s', $timestamp);
return self::formatApplicationDateTime($timestamp);
}
private function remoteIp(): ?string
@@ -3,6 +3,7 @@
namespace classes;
use Exception;
use RuntimeException;
use objects\edge_gateway_operation_events_o;
use objects\edge_gateway_operations_o;
use objects\edge_gateways_o;
@@ -632,7 +633,7 @@ class edge_gateway_operation_service
try {
$statement = $pdo->prepare(
"SELECT id
"SELECT id, type, attempt_count, summary_json
FROM edge_gateway_operations
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
@@ -649,36 +650,69 @@ class edge_gateway_operation_service
return null;
}
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$operation->status->set(self::STATUS_IN_PROGRESS);
$operation->started_at->set($this->now());
$operation->agent_instance_id->set($agentInstanceId);
$operation->last_progress_at->set($this->now());
$operation->lease_expires_at->set($this->leaseExpiry());
$operation->attempt_count->set(((int)($operation->attempt_count->value() ?? 0)) + 1);
$summary = (array)($operation->summary_json->value() ?? []);
$operationId = (int)$row['id'];
$startedAt = $this->now();
$leaseExpiresAt = $this->leaseExpiry();
$attemptCount = ((int)($row['attempt_count'] ?? 0)) + 1;
$summary = isset($row['summary_json']) && is_string($row['summary_json'])
? json_decode($row['summary_json'], true)
: [];
if (!is_array($summary)) {
$summary = [];
}
$summary['label'] = 'Gateway is processing the operation';
$summary['progress'] = max(5, (int)($summary['progress'] ?? 0));
$summary['claimed_by'] = $agentInstanceId;
$operation->summary_json->set($summary);
$encodedSummary = json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedSummary)) {
$encodedSummary = '[]';
}
$update = $pdo->prepare(
"UPDATE edge_gateway_operations
SET status = :status,
started_at = :started_at,
agent_instance_id = :agent_instance_id,
last_progress_at = :last_progress_at,
lease_expires_at = :lease_expires_at,
attempt_count = :attempt_count,
summary_json = :summary_json
WHERE id = :id"
);
$update->execute([
':status' => self::STATUS_IN_PROGRESS,
':started_at' => $startedAt,
':agent_instance_id' => $agentInstanceId,
':last_progress_at' => $startedAt,
':lease_expires_at' => $leaseExpiresAt,
':attempt_count' => $attemptCount,
':summary_json' => $encodedSummary,
':id' => $operationId,
]);
$pdo->commit();
$this->clearObjectPropertyCache('edge_gateway_operations', $operationId);
$operation = $this->fetchOperationRecord($operationId, $pdo);
if ($operation === null) {
throw new RuntimeException('Claimed edge gateway operation could not be reloaded');
}
$this->appendEventRecord(
$gatewayId,
(int)$operation->id,
$operationId,
self::LEVEL_INFO,
'OPERATION_STARTED',
'Gateway started processing the operation',
[
'type' => (string)$operation->type->value(),
'type' => (string)($row['type'] ?? $operation['type'] ?? ''),
'agent_instance_id' => $agentInstanceId,
'attempt_count' => (int)($operation->attempt_count->value() ?? 1),
'attempt_count' => $attemptCount,
'stage' => self::STATUS_IN_PROGRESS,
]
);
$this->refreshGatewayViewCache($gatewayId);
$this->clearGatewayViewCache($gatewayId, $pdo);
return $this->serializeOperation($operation, true);
return $this->serializeOperationRecord($operation, true);
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@@ -706,13 +740,15 @@ class edge_gateway_operation_service
foreach ($rows as $row) {
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
$status = (string)$operation->status->value();
$startedAt = $operation->started_at->value() === null ? null : strtotime((string)$operation->started_at->value());
$leaseExpiresAt = $operation->lease_expires_at->value() === null ? null : strtotime((string)$operation->lease_expires_at->value());
$timedOut = $startedAt !== false
&& $startedAt !== null
$startedAt = edge_gateway_manager::parseApplicationDateTime(
$operation->started_at->value() === null ? null : (string)$operation->started_at->value()
);
$leaseExpiresAt = edge_gateway_manager::parseApplicationDateTime(
$operation->lease_expires_at->value() === null ? null : (string)$operation->lease_expires_at->value()
);
$timedOut = $startedAt !== null
&& ($now - $startedAt) >= self::OPERATION_TIMEOUT_SECONDS;
$leaseExpired = $leaseExpiresAt !== false
&& $leaseExpiresAt !== null
$leaseExpired = $leaseExpiresAt !== null
&& $leaseExpiresAt <= $now;
if (!$timedOut && !$leaseExpired) {
@@ -957,6 +993,123 @@ class edge_gateway_operation_service
edge_gateway_view_cache::syncGateway($this->manager()->getGateway($gatewayId));
}
private function clearGatewayViewCache(int $gatewayId, ?\PDO $pdo = null): void
{
$statement = ($pdo ?? db::getPDO())->prepare(
"SELECT department_id
FROM edge_gateways
WHERE id = :id
AND deleted_at IS NULL
LIMIT 1"
);
$statement->execute([':id' => $gatewayId]);
$row = $statement->fetch();
$departmentId = is_array($row) && isset($row['department_id']) ? (int)$row['department_id'] : null;
edge_gateway_view_cache::clearGateway($gatewayId, $departmentId);
}
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private function fetchOperationRecord(int $operationId, ?\PDO $pdo = null): ?array
{
$statement = ($pdo ?? db::getPDO())->prepare(
"SELECT id,
gateway_id,
type,
operation_type,
status,
request_json,
summary_json,
result_json,
error_code,
error_message,
correlation_id,
agent_instance_id,
lease_expires_at,
last_progress_at,
attempt_count,
requested_by,
requested_at,
started_at,
completed_at,
created_at,
updated_at
FROM edge_gateway_operations
WHERE id = :id
AND deleted_at IS NULL
LIMIT 1"
);
$statement->execute([':id' => $operationId]);
$row = $statement->fetch();
return is_array($row) ? $row : null;
}
/**
* @param array<string,mixed> $operation
* @return array<string,mixed>
*/
private function serializeOperationRecord(array $operation, bool $includeEvents = true): array
{
$operationId = (int)($operation['id'] ?? 0);
$gatewayId = (int)($operation['gateway_id'] ?? 0);
$payload = [
'id' => $operationId,
'gateway_id' => $gatewayId,
'type' => (string)($operation['type'] ?? $operation['operation_type'] ?? ''),
'status' => (string)($operation['status'] ?? self::STATUS_PENDING),
'request' => $this->decodeJsonRecord($operation['request_json'] ?? []),
'summary' => $this->decodeJsonRecord($operation['summary_json'] ?? []),
'result' => $this->decodeJsonRecord($operation['result_json'] ?? []),
'error_code' => isset($operation['error_code']) ? (string)$operation['error_code'] : null,
'error_message' => isset($operation['error_message']) ? (string)$operation['error_message'] : null,
'correlation_id' => (string)($operation['correlation_id'] ?? ''),
'agent_instance_id' => isset($operation['agent_instance_id']) ? (string)$operation['agent_instance_id'] : null,
'lease_expires_at' => isset($operation['lease_expires_at']) ? (string)$operation['lease_expires_at'] : null,
'last_progress_at' => isset($operation['last_progress_at']) ? (string)$operation['last_progress_at'] : null,
'attempt_count' => (int)($operation['attempt_count'] ?? 0),
'requested_by' => isset($operation['requested_by']) ? (int)$operation['requested_by'] : null,
'requested_at' => isset($operation['requested_at']) ? (string)$operation['requested_at'] : '',
'started_at' => isset($operation['started_at']) ? (string)$operation['started_at'] : null,
'completed_at' => isset($operation['completed_at']) ? (string)$operation['completed_at'] : null,
'created_at' => isset($operation['created_at']) ? (string)$operation['created_at'] : '',
'updated_at' => isset($operation['updated_at']) ? (string)$operation['updated_at'] : null,
];
if ($includeEvents && $gatewayId > 0 && $operationId > 0) {
$payload['events'] = $this->listOperationEvents($gatewayId, $operationId, 20);
}
return $payload;
}
/**
* @return array<mixed>
*/
private function decodeJsonRecord(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @throws Exception
*/
@@ -996,12 +1149,12 @@ class edge_gateway_operation_service
private function now(): string
{
return date('Y-m-d H:i:s');
return edge_gateway_manager::formatApplicationDateTime(time());
}
private function leaseExpiry(): string
{
return date('Y-m-d H:i:s', time() + self::OPERATION_LEASE_SECONDS);
return edge_gateway_manager::formatApplicationDateTime(time() + self::OPERATION_LEASE_SECONDS);
}
private function normalizeAgentInstanceId(?string $agentInstanceId, int $gatewayId): string
@@ -346,7 +346,7 @@ final class LocalStateStore
final class BrokerWebSocketClient
{
private const CONNECT_TIMEOUT_SECONDS = 5;
private const CONNECT_TIMEOUT_SECONDS = 15;
private const RECONNECT_DELAY_SECONDS = 2;
/** @var resource|null */
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_mass_import_service;
use customers\economicCustomers;
use objects\logs_o;
use objects\users_o;
@@ -143,6 +144,59 @@ class customerSearchRoute
'search_customers' => 'Search for customers, and list all customers if no search is provided'
]
);
$this->post('/customers/import', function () {
global $response;
$this->requirePermission('add_user');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('customers', 'global', 1, 0, 'IMPORT_CUSTOMER', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$data = [];
}
try {
$result = (new customer_mass_import_service())->import($data);
} catch (\RuntimeException $throwable) {
$statusCode = (int)$throwable->getCode();
if ($statusCode < 400 || $statusCode > 599) {
$statusCode = 400;
}
(new logs_o())->add(
'customers',
'global',
1,
$user->id,
'IMPORT_CUSTOMER_FAILED',
$throwable->getMessage()
);
$response->error([
'message' => $throwable->getMessage(),
], $statusCode);
}
(new logs_o())->add(
'customers',
'global',
1,
$user->id,
'IMPORT_CUSTOMER',
'Successfully imported or created customer ' . ($result['customer_number'] ?? 'unknown')
);
$response->success($result);
},
[
'add_user' => 'Add a user'
]
);
}
private static function parseFunction($collection, \Closure $param): array
@@ -310,9 +310,29 @@ it('rejects missing and invalid edge agent tokens', function (): void {
function edge_agent_test_set_heartbeat_age(int $gatewayId, int $secondsAgo): void
{
$db = api_test_runtime()->db();
$timestamp = date('Y-m-d H:i:s', time() - max(0, $secondsAgo));
$row = api_fixtures()->fetchRowById('edge_gateways', $gatewayId) ?? [];
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
$referenceTimestamp = time();
}
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
$escapedTimestamp = $db->real_escape_string($timestamp);
$db->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escapedTimestamp}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
$redis = api_test_runtime()->redis();
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
api_fixtures()->clearEdgeGatewayViewCache();
}
@@ -70,11 +70,11 @@ it('persists install-session updates and derives gateway runtime status from hea
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
$degraded = $context['manager']->getGateway($gatewayId);
expect($degraded)->toHaveKey('status', 'DEGRADED');
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
$offline = $context['manager']->getGateway($gatewayId);
expect($offline)->toHaveKey('status', 'OFFLINE');
@@ -260,7 +260,7 @@ it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle
});
/**
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli}
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient}
*/
function edge_gateway_integration_context(): array
{
@@ -304,6 +304,7 @@ function edge_gateway_integration_context(): array
'manager' => new edge_gateway_manager(),
'operations' => new edge_gateway_operation_service(),
'mysqli' => $bootstrapped['mysqli'],
'redis' => $bootstrapped['redis'],
];
}
@@ -376,11 +377,36 @@ function edge_gateway_integration_config_value(string $liveKey, string $debugKey
return $liveValue;
}
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, int $gatewayId, int $secondsAgo): void
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void
{
$timestamp = date('Y-m-d H:i:s', time() - max(0, $secondsAgo));
$result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if ($result instanceof mysqli_result) {
$result->free();
}
$referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? ''));
if ($referenceTimestamp === false || $referenceTimestamp <= 0) {
$referenceTimestamp = time();
}
$timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo));
$escaped = $mysqli->real_escape_string($timestamp);
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
'edge_gateway:view:v1:*',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
}
function edge_gateway_integration_inventory(string $suffix): array
@@ -615,6 +615,10 @@ final class ApiFixtures
public function clearEdgeGatewayViewCache(): void
{
$this->deleteRedisPattern('edge_gateway:view:v1:*');
if (class_exists(\classes\edge_gateway_view_cache::class)) {
\classes\edge_gateway_view_cache::clearAll();
}
}
public function fetchRowById(string $table, int $id): ?array
@@ -3,10 +3,12 @@
declare(strict_types=1);
use classes\db;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/Api/ApiCleanup.php';
require_once __DIR__ . '/Api/ApiFixtures.php';
@@ -128,7 +130,7 @@ function edge_gateway_e2e_fixture_context(): array
$context = [
'mysqli' => $mysqli,
'fixtures' => new ApiFixtures($mysqli, null, new ApiCleanup()),
'fixtures' => new ApiFixtures($mysqli, edge_gateway_e2e_redis_client(), new ApiCleanup()),
];
return $context;
@@ -175,6 +177,34 @@ function edge_gateway_e2e_config_value(string $liveKey, string $debugKey, string
return $liveValue;
}
function edge_gateway_e2e_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_e2e_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_e2e_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_e2e_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
/**
* @return array<int, int>
*/
@@ -120,4 +120,6 @@ function run_legacy_script(string $relativeScriptPath): array
}
require_once __DIR__ . '/ApiTestSupport.php';
require_once __DIR__ . '/Api/ApiTestCase.php';
if (class_exists(\PHPUnit\Framework\TestCase::class)) {
require_once __DIR__ . '/Api/ApiTestCase.php';
}
@@ -0,0 +1,229 @@
<?php
use classes\customer_mass_import_service;
if (!function_exists('fakeCustomerMassImportUser')) {
function fakeCustomerMassImportUser(int $id, bool $hasPassword, string $displayName = 'Demo Company'): object
{
return new class($id, $hasPassword, $displayName) {
public int $id;
public bool $has_password;
public string $display_name;
public function __construct(int $id, bool $hasPassword, string $displayName)
{
$this->id = $id;
$this->has_password = $hasPassword;
$this->display_name = $displayName;
}
public function exists(): bool
{
return true;
}
public function hasPassword(): bool
{
return $this->has_password;
}
};
}
}
if (!class_exists('CustomerMassImportServiceProbe')) {
class CustomerMassImportServiceProbe extends customer_mass_import_service
{
public array $economicSearchResults = [];
public array $createCalls = [];
public array $bootstrapCalls = [];
public array $syncCalls = [];
public array $logEntries = [];
public bool $localExists = false;
public ?object $localUser = null;
public ?object $bootstrapUser = null;
public ?object $createResponse = null;
public string $companyName = 'Probe Company';
protected function searchEconomicCustomersByCvr(string $cvr): array
{
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized): object
{
$this->createCalls[] = $normalized;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
return $this->localExists;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
return $this->localUser;
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$this->bootstrapCalls[] = $customerNumber;
if ($this->bootstrapUser === null) {
throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
return $this->bootstrapUser;
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return $this->companyName;
}
protected function logIssue(string $action, array $context): void
{
$this->logEntries[] = [
'action' => $action,
'context' => $context,
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
$this->syncCalls[] = [
'customer' => $customer,
'normalized' => $normalized,
];
}
}
}
it('imports a matching e-conomic customer into the local system when no local record exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->economicSearchResults = [
(object)[
'customerNumber' => 76964600,
'name' => 'SPF-DANMARK A/S',
],
];
$service->bootstrapUser = fakeCustomerMassImportUser(41, false, 'SPF-DANMARK A/S');
$result = $service->import([
'cvr' => '31744520',
'name' => 'SPF-DANMARK A/S',
'email' => 'spf@example.com',
'ean' => '5790000000001',
'phone' => '76964600',
]);
expect($service->createCalls)->toBe([]);
expect($service->bootstrapCalls)->toBe([76964600]);
expect($result['action'])->toBe('imported_existing_customer');
expect($result['existing_economic_customer'])->toBeTrue();
expect($result['created_economic_customer'])->toBeFalse();
expect($result['has_account'])->toBeFalse();
expect($result['user_id'])->toBe(41);
});
it('reports when the local customer already has a login account', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
$service->localUser = fakeCustomerMassImportUser(77, true, 'STEA A/S');
$service->economicSearchResults = [
(object)[
'customerNumber' => 75773355,
'name' => 'STEA A/S',
],
];
$result = $service->import([
'cvr' => '26761751',
'name' => 'STEA A/S',
'phone' => '75773355',
]);
expect($service->bootstrapCalls)->toBe([]);
expect($result['action'])->toBe('account_already_exists');
expect($result['existing_local_customer'])->toBeTrue();
expect($result['has_account'])->toBeTrue();
});
it('creates a new e-conomic customer and returns a created result for new rows', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->bootstrapUser = fakeCustomerMassImportUser(105, false, 'TGP TRANSPORT APS');
$service->createResponse = (object)[
'customerNumber' => 22725567,
];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'tgp@example.com',
'ean' => '5790001234567',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['customer_number'])->toBe(22725567);
expect($service->createCalls[0]['ean'])->toBe('5790001234567');
expect($service->bootstrapCalls)->toBe([22725567]);
expect($result['action'])->toBe('created_customer');
expect($result['created_economic_customer'])->toBeTrue();
expect($result['existing_economic_customer'])->toBeFalse();
expect($result['has_account'])->toBeFalse();
});
it('creates the economic record for an existing local account when no matching upstream customer exists', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->localExists = true;
$service->localUser = fakeCustomerMassImportUser(222, true, 'Existing Account');
$service->createResponse = (object)[
'customerNumber' => 97120896,
];
$result = $service->import([
'cvr' => '49422113',
'name' => 'VESTERBRO PRODUKTHANDEL',
'phone' => '97120896',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->bootstrapCalls)->toBe([]);
expect($result['action'])->toBe('economic_customer_created_for_existing_account');
expect($result['existing_local_customer'])->toBeTrue();
expect($result['created_economic_customer'])->toBeTrue();
expect($result['has_account'])->toBeTrue();
});
it('rejects CVR conflicts when the upstream customer number does not match the submitted phone number', function (): void {
$service = new CustomerMassImportServiceProbe();
$service->economicSearchResults = [
(object)[
'customerNumber' => 87654321,
'name' => 'Conflict Company',
],
];
$call = static fn() => $service->import([
'cvr' => '33333333',
'name' => 'Conflict Company',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'CVR already registered under customer number 87654321.');
expect($service->createCalls)->toBe([]);
expect($service->logEntries[0]['action'] ?? null)->toBe('CUSTOMER_MASS_IMPORT_CONFLICT');
});
it('registers the customer import route and wires it through the mass import service', function (): void {
$routeFile = app_path('routes/customerSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("\$this->post('/customers/import'");
expect($content)->toContain('new customer_mass_import_service()');
expect($content)->toContain("\$this->requirePermission('add_user');");
});
@@ -10,8 +10,10 @@ it('defines the department hardware workspace service payload surface', function
expect($service)->toContain("'lanes' => \$lanes");
expect($service)->toContain("'self_serve' => \$selfServe");
expect($service)->toContain("'gates' => \$gates");
expect($service)->toContain("'relays' => \$relays");
expect($service)->toContain("'scanners' => \$scanners");
expect($service)->toContain("'issues' => \$issues");
expect($service)->toContain("'actions' => \$actions");
expect($service)->toContain("'consumer_contexts'");
expect($service)->toContain("'coverage'");
});
@@ -92,6 +92,7 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void {
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
@@ -118,3 +119,37 @@ it('builds websocket broker urls on the traefik broker path', function (): void
->toBe('wss://api.truckwash.io:4433/edge-broker/ws/browser-shell');
});
});
it('builds localhost websocket broker urls on the local traefik api prefix', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'HTTP_X_FORWARDED_PREFIX' => '/api',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost/api');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/api/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-gateway-stream'))
->toBe('ws://localhost/api/edge-broker/ws/browser-gateway-stream');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('ws://localhost/api/edge-broker/ws/browser-shell');
});
});
it('keeps root-host api urls unprefixed when the request is not under the local api alias', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
'REQUEST_URI' => '/edge-gateways/84/stream-session',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getApiBaseUrl())
->toBe('http://localhost');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('http://localhost/edge-broker');
});
});