Add customer mass import service with API route, test coverage, and e-conomic integration
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);\"",
|
||||
|
||||
+35
@@ -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
|
||||
|
||||
+176
-23
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+31
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user