- The idempotent-deactivation test now pins the route's other 404: license_not_found stays an error. The previous negative case was a 403 that the deactivate route never returns, so swallowing license_not_found (an unknown key shown as deactivated, the local token deleted) went unnoticed. - A test deactivates this machine and checks that the re-read activation status brings the Activate button back. Removing that re-read used to leave the suite green. - The Activate button stays disabled while the list loads (selectCanActivate), the counterpart of selectCanDeactivate: a verify answer landing after a successful activation can no longer mark as fresh a list that misses this machine. - CHANGELOG: the old /simpl-resultat address is redirected by the site since la-compagnie-maximus#153, so the "Buy" bullet now says the button opens the product page directly, in the app's language. The test comment is moved to the past tense. Proven red on the fixed tree: idempotence swallowing license_not_found 1, no status re-read after deactivation 1, Activate live during a list load 1. Refs #270 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
licenseMachinesReducer,
|
|
initialLicenseMachinesState,
|
|
selectShowEmptyState,
|
|
selectCanDeactivate,
|
|
selectCanActivate,
|
|
shouldLoadOnOpen,
|
|
performActivate,
|
|
performDeactivate,
|
|
loadMachines,
|
|
loadActivationStatus,
|
|
type LicenseMachinesAction,
|
|
type LicenseMachinesDeps,
|
|
type LicenseMachinesState,
|
|
} from "./useLicenseMachines";
|
|
import {
|
|
parseLicenseError,
|
|
type ActivationStatus,
|
|
type LicenseError,
|
|
type LicenseErrorCode,
|
|
type LicenseResult,
|
|
type MachineInfo,
|
|
} from "../services/licenseService";
|
|
|
|
const err = (code: LicenseErrorCode, extra: Record<string, unknown> = {}): LicenseError =>
|
|
parseLicenseError(JSON.stringify({ code, ...extra }));
|
|
|
|
const machine = (id: string): MachineInfo => ({
|
|
machine_id: id,
|
|
machine_name: `host-${id}`,
|
|
activated_at: "2026-09-01T00:00:00Z",
|
|
last_seen_at: "2026-09-20T00:00:00Z",
|
|
});
|
|
|
|
const reduce = (
|
|
actions: LicenseMachinesAction[],
|
|
from: LicenseMachinesState = initialLicenseMachinesState,
|
|
): LicenseMachinesState => actions.reduce(licenseMachinesReducer, from);
|
|
|
|
const limitHit = reduce([
|
|
{ type: "ACTIVATE_START" },
|
|
{ type: "ACTIVATE_ERROR", error: err("machine_limit_reached") },
|
|
]);
|
|
|
|
describe("licenseMachinesReducer — machine limit (D2)", () => {
|
|
it("opens the list and keeps the message on machine_limit_reached", () => {
|
|
expect(limitHit.activationError?.code).toBe("machine_limit_reached");
|
|
expect(limitHit.listOpen).toBe(true);
|
|
expect(limitHit.activating).toBe(false);
|
|
});
|
|
|
|
it("does not open the list for other activation refusals", () => {
|
|
const s = reduce([
|
|
{ type: "ACTIVATE_START" },
|
|
{ type: "ACTIVATE_ERROR", error: err("license_revoked") },
|
|
]);
|
|
expect(s.activationError?.code).toBe("license_revoked");
|
|
expect(s.listOpen).toBe(false);
|
|
});
|
|
|
|
it("keeps the activation error through the list load it triggers", () => {
|
|
const loading = reduce([{ type: "LIST_LOAD_START" }], limitHit);
|
|
expect(loading.activationError?.code).toBe("machine_limit_reached");
|
|
const loaded = reduce(
|
|
[{ type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b"), machine("c")] }],
|
|
loading,
|
|
);
|
|
expect(loaded.activationError?.code).toBe("machine_limit_reached");
|
|
expect(loaded.machines).toHaveLength(3);
|
|
expect(loaded.listLoaded).toBe(true);
|
|
});
|
|
|
|
it("keeps the activation error when the list load itself fails (e.g. a 429 right after the 409)", () => {
|
|
const s = reduce(
|
|
[{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("rate_limit", { retry_after_s: 30 }) }],
|
|
limitHit,
|
|
);
|
|
expect(s.activationError?.code).toBe("machine_limit_reached");
|
|
expect(s.listError?.code).toBe("rate_limit");
|
|
expect(s.listLoaded).toBe(false);
|
|
});
|
|
|
|
it("clears only a machine-limit activation error once a slot is freed", () => {
|
|
const withList = reduce(
|
|
[{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }],
|
|
limitHit,
|
|
);
|
|
const freed = reduce(
|
|
[{ type: "DEACTIVATE_START", machineId: "a" }, { type: "DEACTIVATE_DONE", machineId: "a" }],
|
|
withList,
|
|
);
|
|
expect(freed.activationError).toBeNull();
|
|
expect(freed.machines.map((m) => m.machine_id)).toEqual(["b"]);
|
|
expect(freed.deactivatingId).toBeNull();
|
|
|
|
const networkFailure = reduce([
|
|
{ type: "ACTIVATE_START" },
|
|
{ type: "ACTIVATE_ERROR", error: err("network") },
|
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
|
{ type: "DEACTIVATE_DONE", machineId: "a" },
|
|
]);
|
|
expect(networkFailure.activationError?.code).toBe("network");
|
|
});
|
|
});
|
|
|
|
describe("licenseMachinesReducer — who may clear the activation error", () => {
|
|
// Only ACTIVATE_START and a DEACTIVATE_DONE after a machine-limit refusal
|
|
// clear it. Everything else (list loads in particular) must leave it alone.
|
|
const preserving: LicenseMachinesAction[] = [
|
|
{ type: "ACTIVATION_STATUS", activation: { is_activated: false, machine_id: "x" } },
|
|
{ type: "TOGGLE_LIST" },
|
|
{ type: "TOGGLE_LIST" },
|
|
{ type: "LIST_LOAD_START" },
|
|
{ type: "LIST_LOAD_DONE", machines: [] },
|
|
{ type: "LIST_LOAD_ERROR", error: err("server", { status: 502 }) },
|
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
|
{ type: "DEACTIVATE_ERROR", error: err("network") },
|
|
{ type: "ACTIVATE_DONE" },
|
|
];
|
|
|
|
// ACTIVATE_DONE is listed too: it is not a clearing path of its own, since
|
|
// ACTIVATE_START always clears the error before a successful activation.
|
|
it.each(preserving.map((a) => [a.type, a] as const))("%s keeps it", (_type, action) => {
|
|
expect(licenseMachinesReducer(limitHit, action).activationError?.code).toBe(
|
|
"machine_limit_reached",
|
|
);
|
|
});
|
|
|
|
it("ACTIVATE_START clears it", () => {
|
|
expect(licenseMachinesReducer(limitHit, { type: "ACTIVATE_START" }).activationError).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("licenseMachinesReducer — list lifecycle", () => {
|
|
it("DEACTIVATE_START clears a stale list error", () => {
|
|
const s = reduce([
|
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
|
{ type: "DEACTIVATE_ERROR", error: err("rate_limit", { retry_after_s: 20 }) },
|
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
|
]);
|
|
expect(s.listError).toBeNull();
|
|
expect(s.deactivatingId).toBe("a");
|
|
});
|
|
|
|
it("closes and invalidates the list after a successful activation", () => {
|
|
const s = reduce(
|
|
[
|
|
{ type: "LIST_LOAD_START" },
|
|
{ type: "LIST_LOAD_DONE", machines: [machine("a")] },
|
|
{ type: "ACTIVATE_START" },
|
|
{ type: "ACTIVATE_DONE" },
|
|
],
|
|
{ ...initialLicenseMachinesState, listOpen: true },
|
|
);
|
|
expect(s.listOpen).toBe(false);
|
|
expect(s.listLoaded).toBe(false);
|
|
expect(s.activating).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selectShowEmptyState", () => {
|
|
it("is false before the first load", () => {
|
|
expect(selectShowEmptyState(initialLicenseMachinesState)).toBe(false);
|
|
});
|
|
|
|
it("is true once a load returns an empty list without error", () => {
|
|
expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]))).toBe(true);
|
|
});
|
|
|
|
it("is false while loading, on a list error, or with machines", () => {
|
|
expect(selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false);
|
|
expect(
|
|
selectShowEmptyState(
|
|
reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("license_revoked") }]),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
selectShowEmptyState(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a")] }])),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is false when a deactivation fails on a list emptied locally", () => {
|
|
const s = reduce([
|
|
{ type: "LIST_LOAD_START" },
|
|
{ type: "LIST_LOAD_DONE", machines: [] },
|
|
{ type: "DEACTIVATE_START", machineId: "a" },
|
|
{ type: "DEACTIVATE_ERROR", error: err("network") },
|
|
]);
|
|
expect(selectShowEmptyState(s)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selectCanDeactivate", () => {
|
|
const loaded = reduce([
|
|
{ type: "LIST_LOAD_START" },
|
|
{ type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] },
|
|
]);
|
|
|
|
it("allows a deactivation on an idle, loaded list", () => {
|
|
expect(selectCanDeactivate(loaded)).toBe(true);
|
|
});
|
|
|
|
it("blocks every button while one deactivation is running", () => {
|
|
expect(selectCanDeactivate(reduce([{ type: "DEACTIVATE_START", machineId: "a" }], loaded))).toBe(false);
|
|
});
|
|
|
|
it("blocks the buttons while a 409 reloads an already loaded list", () => {
|
|
// The reload answers with the list as of its request: a machine deactivated
|
|
// before it lands would reappear.
|
|
const reloading = reduce(
|
|
[
|
|
{ type: "ACTIVATE_START" },
|
|
{ type: "ACTIVATE_ERROR", error: err("machine_limit_reached") },
|
|
{ type: "LIST_LOAD_START" },
|
|
],
|
|
loaded,
|
|
);
|
|
expect(reloading.machines).toHaveLength(2);
|
|
expect(selectCanDeactivate(reloading)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("selectCanActivate", () => {
|
|
it("allows an activation when nothing is running", () => {
|
|
expect(selectCanActivate(initialLicenseMachinesState)).toBe(true);
|
|
});
|
|
|
|
it("blocks the button while an activation is running", () => {
|
|
expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]))).toBe(false);
|
|
});
|
|
|
|
it("blocks the button while the list loads", () => {
|
|
// Otherwise a verify answer landing after a successful activation would
|
|
// mark as fresh a list that no longer includes this machine.
|
|
const loading = reduce([{ type: "TOGGLE_LIST" }, { type: "LIST_LOAD_START" }]);
|
|
expect(selectCanActivate(loading)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldLoadOnOpen", () => {
|
|
const loaded = reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [] }]);
|
|
|
|
it("loads the first time the list opens", () => {
|
|
expect(shouldLoadOnOpen(initialLicenseMachinesState)).toBe(true);
|
|
});
|
|
|
|
it("does not reload a list already loaded without error", () => {
|
|
expect(shouldLoadOnOpen(loaded)).toBe(false);
|
|
});
|
|
|
|
it("retries after a list error", () => {
|
|
expect(
|
|
shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }, { type: "LIST_LOAD_ERROR", error: err("network") }])),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("never loads when closing the list or while a load is running", () => {
|
|
expect(shouldLoadOnOpen({ ...initialLicenseMachinesState, listOpen: true })).toBe(false);
|
|
expect(shouldLoadOnOpen(reduce([{ type: "LIST_LOAD_START" }]))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Orchestration: the effects are plain functions with injected dependencies,
|
|
// so the D2 path can be replayed without React or Tauri. Every dispatched
|
|
// action is folded through the real reducer.
|
|
function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
|
let state = initialLicenseMachinesState;
|
|
const deps: LicenseMachinesDeps = {
|
|
activateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
|
deactivateMachine: vi.fn(async (): Promise<LicenseResult<void>> => ({ ok: true, value: undefined })),
|
|
listActivatedMachines: vi.fn(
|
|
async (): Promise<LicenseResult<MachineInfo[]>> => ({ ok: true, value: [] }),
|
|
),
|
|
getActivationStatus: vi.fn(async () => ({ is_activated: true, machine_id: "this" })),
|
|
refresh: vi.fn(async () => {}),
|
|
dispatch: (action) => {
|
|
state = licenseMachinesReducer(state, action);
|
|
},
|
|
...overrides,
|
|
};
|
|
const networkCalls = () =>
|
|
vi.mocked(deps.activateMachine).mock.calls.length +
|
|
vi.mocked(deps.deactivateMachine).mock.calls.length +
|
|
vi.mocked(deps.listActivatedMachines).mock.calls.length;
|
|
return { deps, getState: () => state, networkCalls };
|
|
}
|
|
|
|
describe("machine-limit path (D2) end to end", () => {
|
|
it("fits in 4 license-server calls and keeps the message until a slot is freed", async () => {
|
|
const activateMachine = vi
|
|
.fn<() => Promise<LicenseResult<void>>>()
|
|
.mockResolvedValueOnce({ ok: false, error: err("machine_limit_reached") })
|
|
.mockResolvedValueOnce({ ok: true, value: undefined });
|
|
const { deps, getState, networkCalls } = harness({
|
|
activateMachine,
|
|
listActivatedMachines: vi.fn(async () => ({
|
|
ok: true as const,
|
|
value: [machine("a"), machine("b"), machine("c")],
|
|
})),
|
|
});
|
|
|
|
await performActivate(deps);
|
|
let s = getState();
|
|
expect(s.activationError?.code).toBe("machine_limit_reached");
|
|
expect(s.listOpen).toBe(true);
|
|
expect(s.listLoaded).toBe(true);
|
|
expect(s.machines).toHaveLength(3);
|
|
expect(deps.refresh).not.toHaveBeenCalled();
|
|
|
|
await performDeactivate(deps, "b");
|
|
s = getState();
|
|
expect(s.machines.map((m) => m.machine_id)).toEqual(["a", "c"]);
|
|
expect(s.activationError).toBeNull();
|
|
|
|
await performActivate(deps);
|
|
s = getState();
|
|
expect(s.activationError).toBeNull();
|
|
|
|
// activate (409) + verify + deactivate + activate: under the server's
|
|
// 5 requests per minute and per IP.
|
|
expect(networkCalls()).toBe(4);
|
|
expect(deps.listActivatedMachines).toHaveBeenCalledTimes(1);
|
|
expect(deps.refresh).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("performActivate", () => {
|
|
it("refreshes the edition after a successful activation, without reloading the list", async () => {
|
|
const { deps, getState } = harness();
|
|
await performActivate(deps);
|
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
|
expect(deps.getActivationStatus).toHaveBeenCalledTimes(1);
|
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
|
expect(getState().activation?.is_activated).toBe(true);
|
|
});
|
|
|
|
it("still refreshes when the local activation status cannot be read", async () => {
|
|
const { deps } = harness({
|
|
getActivationStatus: vi.fn(async () => {
|
|
throw new Error("machine id unreadable");
|
|
}),
|
|
});
|
|
await expect(performActivate(deps)).resolves.toBeUndefined();
|
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("neither loads the list nor refreshes on other refusals", async () => {
|
|
const { deps, getState } = harness({
|
|
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_revoked") })),
|
|
});
|
|
await performActivate(deps);
|
|
expect(getState().activationError?.code).toBe("license_revoked");
|
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
|
expect(deps.refresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("performDeactivate", () => {
|
|
it("removes the machine locally and refreshes, without calling verify", async () => {
|
|
const { deps, getState } = harness();
|
|
await loadMachines({
|
|
...deps,
|
|
listActivatedMachines: vi.fn(async () => ({ ok: true as const, value: [machine("a"), machine("b")] })),
|
|
});
|
|
await performDeactivate(deps, "a");
|
|
expect(getState().machines.map((m) => m.machine_id)).toEqual(["b"]);
|
|
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
|
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-reads the local status, so deactivating this machine brings back Activate", async () => {
|
|
const getActivationStatus = vi
|
|
.fn<() => Promise<ActivationStatus>>()
|
|
.mockResolvedValueOnce({ is_activated: true, machine_id: "this" })
|
|
.mockResolvedValueOnce({ is_activated: false, machine_id: "this" });
|
|
const { deps, getState } = harness({ getActivationStatus });
|
|
await loadActivationStatus(deps);
|
|
await loadMachines({
|
|
...deps,
|
|
listActivatedMachines: vi.fn(async () => ({
|
|
ok: true as const,
|
|
value: [machine("this"), machine("b")],
|
|
})),
|
|
});
|
|
expect(getState().activation?.is_activated).toBe(true);
|
|
|
|
// Rust deletes this machine's activation token on success; only the
|
|
// re-read status turns the card back from "Activated" to "Activate".
|
|
await performDeactivate(deps, "this");
|
|
expect(getState().activation?.is_activated).toBe(false);
|
|
expect(getActivationStatus).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("shows a refusal on the list and releases the busy state", async () => {
|
|
const { deps, getState } = harness({
|
|
deactivateMachine: vi.fn(async () => ({
|
|
ok: false as const,
|
|
error: err("rate_limit", { retry_after_s: 25 }),
|
|
})),
|
|
});
|
|
await performDeactivate(deps, "a");
|
|
const s = getState();
|
|
expect(s.listError?.code).toBe("rate_limit");
|
|
expect(s.listError?.params).toEqual({ count: 25 });
|
|
expect(s.deactivatingId).toBeNull();
|
|
expect(deps.refresh).not.toHaveBeenCalled();
|
|
});
|
|
});
|