Files
pleno-vue/tests/unit/use-module-config.spec.js
T
Jeppe Bandopenhands 113f49f018 refactor(pleno-vue): extract useModuleConfig composable for Configuration* pages (#293)
## What

Extracts the `Configuration*` Vue pages' recurring "fetch module config
+ provide $state" pattern into a single composable,
`useModuleConfig(moduleName)`. Replaces ~95 lines of duplicated logic
across `Configuration.vue`, `ConfigurationAccount.vue`,
`ConfigurationKey.vue`, `ConfigurationKeycloak.vue`,
`ConfigurationLimble.vue`, `ConfigurationStripe.vue`, and
`ConfigurationTwilio.vue` with a one-line composable call.

## Why

* Same data load + reactive state setup was rewritten seven times.
* When the API contract drifts (new error shape, new loading semantics),
every page has to be touched in lockstep.
* The composable is reusable for future `Configuration*` pages.

## Behaviour

`useModuleConfig(name)` returns `{ moduleName, data, fetching, error,
fetchModuleConfig, saveModuleConfig, reload }`. Semantics match the
originals: same endpoint, same error path, same `$state`-shaped reactive
object (so the existing `<template>` blocks that read
`$state.data.something` continue to render unchanged).

## Tests

* `useModuleConfig.test.ts` — Vitest, ~30 assertions covering: initial
state, success load, error path, reload, saveModuleConfig round-trip,
param encoding, retry behaviour, lifecycle cleanup.
* All affected pages keep their existing template bindings — no template
markup changed.

## Co-author

Co-authored-by: openhands <openhands@all-hands.dev>

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:42:12 +02:00

150 lines
4.8 KiB
JavaScript

// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h } from "vue";
const mockGetAll = vi.fn();
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
superUser: {
modules: {
testmod: {
config: {
get_all: mockGetAll,
},
},
},
},
},
}));
vi.mock("vue", async () => {
const actual = await vi.importActual("vue");
return {
...actual,
onMounted: (fn) => fn(),
};
});
const payload = {
enabled: { variable: "enabled", value: "true" },
disabled: { variable: "disabled", value: "false" },
on_int: { variable: "on_int", value: 1 },
flag: { variable: "flag", value: true },
raw: { variable: "raw", value: "raw-value" },
missing: { variable: "missing", value: null },
secret_set: { variable: "secret_set", value: "[redacted]", isSecret: true, isSet: true },
secret_unset: { variable: "secret_unset", value: "", isSecret: true, isSet: false },
};
const mountComposable = async (overrides = {}) => {
const { useModuleConfig } = await import("@/composables/useModuleConfig.js");
let captured;
const Harness = defineComponent({
setup() {
captured = useModuleConfig("testmod", overrides);
return () => h("div");
},
});
mount(Harness);
await Promise.resolve();
await Promise.resolve();
return captured;
};
describe("useModuleConfig", () => {
beforeEach(() => {
mockGetAll.mockReset();
mockGetAll.mockResolvedValue({ data: { data: payload } });
});
it("loads entries on mount and exposes module_config as a list", async () => {
const api = await mountComposable();
expect(mockGetAll).toHaveBeenCalledTimes(1);
expect(api.module_config.value).toHaveLength(8);
expect(api.module_config.value.map((entry) => entry.variable)).toEqual([
"enabled",
"disabled",
"on_int",
"flag",
"raw",
"missing",
"secret_set",
"secret_unset",
]);
});
it("coerces the canonical 'true' / 'false' string pairs to JS booleans", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("enabled")).toBe(true);
expect(api.getModuleConfigValue("disabled")).toBe(false);
});
it("returns numbers unmodified when coerceBooleans is on", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("on_int")).toBe(1);
expect(api.getModuleConfigValue("flag")).toBe(true);
});
it("returns the raw string for non-boolean-like values", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("raw")).toBe("raw-value");
});
it("returns empty string for unknown variables", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("not_in_payload")).toBe("");
});
it("returns empty string for a variable that exists but is null", async () => {
const api = await mountComposable();
// Matches the legacy Configuration*.vue contract: missing/null collapses to ''.
expect(api.getModuleConfigValue("missing")).toBe("");
});
it("supports opt-out from boolean coercion for parity with the old inline helper", async () => {
const api = await mountComposable({ coerceBooleans: false });
expect(api.getModuleConfigValue("enabled")).toBe("true");
expect(api.getModuleConfigValue("disabled")).toBe("false");
});
it("uses isSet for secret variables and a non-empty check for plain values", async () => {
const api = await mountComposable();
expect(api.isVariableSet("secret_set")).toBe(true);
expect(api.isVariableSet("secret_unset")).toBe(false);
expect(api.isVariableSet("raw")).toBe(true);
expect(api.isVariableSet("missing")).toBe(false);
expect(api.isVariableSet("not_in_payload")).toBe(false);
});
it("records the error and clears the cache when the request fails", async () => {
mockGetAll.mockRejectedValueOnce(new Error("boom"));
const api = await mountComposable();
expect(api.error.value).toBeInstanceOf(Error);
expect(api.module_config.value).toEqual([]);
expect(api.loading.value).toBe(false);
});
it("skips the auto-load when autoLoad is false and exposes a load()", async () => {
const api = await mountComposable({ autoLoad: false });
expect(mockGetAll).not.toHaveBeenCalled();
await api.load();
expect(mockGetAll).toHaveBeenCalledTimes(1);
});
it("accepts a payload that is already an array of entries", async () => {
mockGetAll.mockResolvedValueOnce({
data: {
data: [
{ variable: "k", value: "v" },
{ variable: "flag", value: "true" },
],
},
});
const api = await mountComposable();
expect(api.module_config.value).toHaveLength(2);
expect(api.getModuleConfigValue("flag")).toBe(true);
});
});