import { describe, it, expect, vi, beforeEach } from "vitest"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { invoke } from "@tauri-apps/api/core"; import { licenseMachinesReducer, initialLicenseMachinesState, selectShowEmptyState, selectCanDeactivate, selectCanActivate, shouldLoadOnOpen, performActivate, performDeactivate, loadMachines, loadActivationStatus, useLicenseMachines, type LicenseMachinesAction, type LicenseMachinesDeps, type LicenseMachinesState, } from "./useLicenseMachines"; import { parseLicenseError, type ActivationStatus, type LicenseError, type LicenseErrorCode, type LicenseResult, type MachineInfo, } from "../services/licenseService"; // The hook itself reads the LicenseContext; the pure functions below never do. const context = { refresh: vi.fn(async () => {}), revoked: false }; vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); const err = (code: LicenseErrorCode, extra: Record = {}): 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", is_this_machine: false, }); 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 } }, { 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, false)).toBe(true); }); it("blocks the button while an activation is running", () => { expect(selectCanActivate(reduce([{ type: "ACTIVATE_START" }]), false)).toBe(false); }); it("blocks the button while a deactivation is running", () => { // An activation racing the deactivation after a 409 could reach the server // first: the machine-limit message would vanish with 5 calls already spent. const s = reduce( [ { type: "LIST_LOAD_START" }, { type: "LIST_LOAD_DONE", machines: [machine("a"), machine("b")] }, { type: "DEACTIVATE_START", machineId: "a" }, ], limitHit, ); expect(selectCanActivate(s, false)).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, false)).toBe(false); }); it("blocks the button for a key revoked during the session", () => { // Every attempt would get a 403 and spend the shared rate budget (#347). expect(selectCanActivate(initialLicenseMachinesState, true)).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 = {}) { let state = initialLicenseMachinesState; const deps: LicenseMachinesDeps = { activateMachine: vi.fn(async (): Promise> => ({ ok: true, value: undefined })), deactivateMachine: vi.fn(async (): Promise> => ({ ok: true, value: undefined })), listActivatedMachines: vi.fn( async (): Promise> => ({ ok: true, value: [] }), ), getActivationStatus: vi.fn(async () => ({ is_activated: true })), 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>>() .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("reloads the license context 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_expired") })), }); await performActivate(deps); expect(getState().activationError?.code).toBe("license_expired"); 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>() .mockResolvedValueOnce({ is_activated: true }) .mockResolvedValueOnce({ is_activated: false }); 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(); }); }); // The Rust command that receives `license_revoked` persists it (#365): each of the three // server calls reloads the LicenseContext, so the edition (Free) and the card follow. describe("a revocation reloads the license context", () => { const revokedResult = { ok: false as const, error: err("license_revoked") }; it("from an activation, without loading the list", async () => { const { deps, getState } = harness({ activateMachine: vi.fn(async () => revokedResult) }); await performActivate(deps); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(deps.listActivatedMachines).not.toHaveBeenCalled(); expect(getState().activationError?.code).toBe("license_revoked"); }); it("from the machine list", async () => { const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => revokedResult) }); await loadMachines(deps); expect(deps.refresh).toHaveBeenCalledTimes(1); expect(getState().listError?.code).toBe("license_revoked"); }); it("from a deactivation", async () => { const { deps } = harness({ deactivateMachine: vi.fn(async () => revokedResult) }); await performDeactivate(deps, "a"); expect(deps.refresh).toHaveBeenCalledTimes(1); }); it("never from another refusal", async () => { const { deps } = harness({ activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })), listActivatedMachines: vi.fn(async () => ({ ok: false as const, error: err("network") })), deactivateMachine: vi.fn(async () => ({ ok: false as const, error: err("server", { status: 502 }) })), }); await performActivate(deps); await loadMachines(deps); await performDeactivate(deps, "a"); expect(deps.refresh).not.toHaveBeenCalled(); }); }); // The hook glues the pure functions to the LicenseContext: rendered once on the // server, with the context faked, to check both directions of that glue. describe("useLicenseMachines wiring to the LicenseContext", () => { function renderHook(): ReturnType { let view: ReturnType | undefined; function Probe() { view = useLicenseMachines(true); return null; } renderToStaticMarkup(createElement(Probe)); if (!view) throw new Error("the probe did not render"); return view; } beforeEach(() => { context.revoked = false; context.refresh.mockClear(); vi.mocked(invoke).mockReset(); }); it("reads the revocation from the context and blocks Activate", () => { context.revoked = true; const view = renderHook(); expect(view.revoked).toBe(true); expect(view.canActivate).toBe(false); }); it("allows Activate without a revocation", () => { const view = renderHook(); expect(view.revoked).toBe(false); expect(view.canActivate).toBe(true); }); it("hands the context's refresh to the server calls", async () => { vi.mocked(invoke).mockRejectedValue('{"code":"license_revoked"}'); const view = renderHook(); await view.activate(); expect(context.refresh).toHaveBeenCalledTimes(1); }); });