Files
iot-dc3/dc3-cli/test/manager-write.test.ts
pnokerandClaude Code e1edbbcdc3 docs(cli): sync readme with new commands and fix documentation drift
adds the provider model and attachment command groups to the readme
with usage examples for every subcommand, documents device
import-template and alert point-profile, corrects the point history
option name from --count to --limit, fixes the mcp example key from
transport to type, adds config profile create/delete to the multi-
profile section, and aligns the test suite with the updated command
surface. also updates ci to run typecheck alongside tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 13:43:30 +08:00

294 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SilentExit } from '../src/utils/format.js';
import { Command } from "commander";
import { registerCommandCommand } from "../src/commands/command.js";
import { registerDeviceCommand } from "../src/commands/device.js";
import { registerDriverCommand } from "../src/commands/driver.js";
import { registerEventCommand } from "../src/commands/event.js";
import { registerPointCommand } from "../src/commands/point.js";
import { registerProfileCommand } from "../src/commands/profile.js";
import { registerGroupCommand } from "../src/commands/group.js";
import { registerLabelCommand } from "../src/commands/label.js";
type Register = (_program: Command) => void;
type FetchCall = { url: string; init: RequestInit };
const fetchCalls: FetchCall[] = [];
vi.mock("../src/core/config-manager.js", () => ({
configManager: {
getActiveProfile: vi.fn(async () => ({
gateway: "http://gw.test/",
tenant: "t",
username: "u",
})),
getActiveProfileName: vi.fn(async () => "default"),
load: vi.fn(async () => ({ current_profile: "default" })),
getSettings: vi.fn(async () => ({ renewal_threshold_hours: 12 })),
},
}));
vi.mock("../src/core/token-manager.js", () => ({
tokenManager: {
getState: vi.fn(async () => null),
needsRenewal: vi.fn(async () => false),
},
}));
const cases: Array<{
entity: string;
register: Register;
nameField: string;
stableField: [string, unknown];
}> = [
{
entity: "driver",
register: registerDriverCommand,
nameField: "driverName",
stableField: ["driverCode", "driver-code"],
},
{
entity: "device",
register: registerDeviceCommand,
nameField: "deviceName",
stableField: ["driverId", "101"],
},
{
entity: "profile",
register: registerProfileCommand,
nameField: "profileName",
stableField: ["profileCode", "profile-code"],
},
{
entity: "point",
register: registerPointCommand,
nameField: "pointName",
stableField: ["profileId", "102"],
},
{
entity: "command",
register: registerCommandCommand,
nameField: "commandName",
stableField: ["timeout", 30],
},
{
entity: "event",
register: registerEventCommand,
nameField: "eventName",
stableField: ["eventTypeFlag", "INFO"],
},
{
entity: "group",
register: registerGroupCommand,
nameField: "groupName",
stableField: ["groupCode", "group-code"],
},
{
entity: "label",
register: registerLabelCommand,
nameField: "labelName",
stableField: ["labelCode", "label-code"],
},
];
function buildProgram(register: Register): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeErr: () => undefined });
register(program);
return program;
}
async function run(register: Register, args: string[]): Promise<string> {
const program = buildProgram(register);
let output = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output += String(chunk);
return true;
});
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
try { await program.parseAsync(args, { from: "user" }); } catch (e) { if (!(e instanceof SilentExit)) throw e; }
return output;
}
describe("manager optimistic-lock write commands", () => {
beforeEach(() => {
fetchCalls.length = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const request = { url, init: init ?? {} };
fetchCalls.push(request);
if (request.init.method === "DELETE") {
return new Response(null, { status: 204 });
}
if (url.includes("/get_by_id")) {
const entity = url.match(/\/manager\/([^/]+)\//)?.[1] ?? "resource";
const testCase = cases.find((item) => item.entity === entity);
return new Response(
JSON.stringify({
id: "id A&B",
version: 7,
[testCase?.nameField ?? "name"]: "old-name",
[testCase?.stableField[0] ?? "stable"]: testCase?.stableField[1],
}),
{ status: 200 },
);
}
return new Response(String(request.init.body), { status: 200 });
}),
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
for (const testCase of cases) {
it(`${testCase.entity} delete uses DELETE with id and version`, async () => {
const output = await run(testCase.register, [
testCase.entity,
"delete",
"id A&B",
"--version",
"3",
]);
expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0].init.method).toBe("DELETE");
expect(fetchCalls[0].url).toBe(
`http://gw.test/api/v3/manager/${testCase.entity}/delete?id=id%20A%26B&version=3`,
);
expect(output).toBe("");
});
it(`${testCase.entity} delete rejects a missing version`, async () => {
await expect(
buildProgram(testCase.register).parseAsync(
[testCase.entity, "delete", "1"],
{ from: "user" },
),
).rejects.toMatchObject({
code: "commander.missingMandatoryOptionValue",
});
expect(fetchCalls).toHaveLength(0);
});
it(`${testCase.entity} update merges the current resource and caller version`, async () => {
await run(testCase.register, [
testCase.entity,
"update",
"id A&B",
"--version",
"3",
"--name",
"new-name",
]);
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0].init.method).toBe("GET");
expect(fetchCalls[0].url).toBe(
`http://gw.test/api/v3/manager/${testCase.entity}/get_by_id?id=id%20A%26B`,
);
expect(fetchCalls[1].init.method).toBe("POST");
expect(fetchCalls[1].url).toBe(
`http://gw.test/api/v3/manager/${testCase.entity}/update`,
);
expect(JSON.parse(String(fetchCalls[1].init.body))).toMatchObject({
id: "id A&B",
version: 3,
[testCase.nameField]: "new-name",
[testCase.stableField[0]]: testCase.stableField[1],
});
});
it(`${testCase.entity} update sends an explicit empty name instead of dropping it (F018)`, async () => {
await run(testCase.register, [
testCase.entity,
"update",
"id A&B",
"--version",
"3",
"--name",
"",
]);
expect(fetchCalls).toHaveLength(2);
// Strict equality: an explicitly empty name must reach the wire as ""
// (empty-name legality is the gateway's call), never be silently
// dropped back to the server-side value.
expect(JSON.parse(String(fetchCalls[1].init.body))[testCase.nameField]).toBe("");
});
it(`${testCase.entity} update without --name keeps the server-side name untouched (F018)`, async () => {
await run(testCase.register, [testCase.entity, "update", "id A&B", "--version", "3"]);
expect(fetchCalls).toHaveLength(2);
expect(JSON.parse(String(fetchCalls[1].init.body))[testCase.nameField]).toBe("old-name");
});
}
describe("empty-string option values reach the wire (F018)", () => {
it("device update sends an explicit empty description as an empty remark", async () => {
await run(registerDeviceCommand, [
"device",
"update",
"1",
"--version",
"3",
"--description",
"",
]);
expect(JSON.parse(String(fetchCalls[1].init.body)).remark).toBe("");
});
it("device add sends an explicit empty description as an empty remark", async () => {
await run(registerDeviceCommand, [
"device",
"add",
"--name",
"n",
"--driver-id",
"d",
"--profile-id",
"p",
"--description",
"",
]);
expect(fetchCalls).toHaveLength(1);
expect(JSON.parse(String(fetchCalls[0].init.body)).remark).toBe("");
});
it("driver update sends an explicit empty service name", async () => {
await run(registerDriverCommand, [
"driver",
"update",
"1",
"--version",
"3",
"--service-name",
"",
]);
expect(JSON.parse(String(fetchCalls[1].init.body)).serviceName).toBe("");
});
it("point update sends explicit empty name and unit", async () => {
await run(registerPointCommand, [
"point",
"update",
"1",
"--version",
"3",
"--name",
"",
"--unit",
"",
]);
const body = JSON.parse(String(fetchCalls[1].init.body));
expect(body.pointName).toBe("");
expect(body.unit).toBe("");
});
});
});