From 113f49f0181e8c090f40c1883ba4cacfde1a98f3 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 12 Aug 2026 20:42:12 +0200 Subject: [PATCH] refactor(pleno-vue): extract useModuleConfig composable for Configuration* pages (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ` - \ No newline at end of file + diff --git a/tests/unit/use-module-config.spec.js b/tests/unit/use-module-config.spec.js new file mode 100644 index 00000000..332a659c --- /dev/null +++ b/tests/unit/use-module-config.spec.js @@ -0,0 +1,149 @@ +// @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); + }); +});