diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 459ad0e1..db37247a 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -2076,3 +2076,36 @@ Validation: Follow-ups: - Optional consolidation: extract shared phase6/phase7 VM loader helpers into a single test utility to reduce duplication. + +--- + +### CL-059: TASK22260 next slice — shared service harness extraction (continued bounded risk-reduction) + +date: 2026-03-25 +author: Cline +scope: `tests/{serviceHarness,phase6/service-behaviour,phase7/service-behaviour}.cjs` +type: change +rationale: Continue the bounded risk-reduction stream by removing duplicated test harness infrastructure across phase6/phase7 service behavioural suites and centralizing client-wrapper-compatible mocks. +impact: Reduces test harness drift risk and keeps client-wrapper migration verification stable across multiple suites, without runtime code changes. +status: completed + +Summary: + +- Added shared helper module `tests/serviceHarness.cjs` with reusable: + - `createAxiosMock` + - `createLoggerMock` + - `createAxiosError` + - `loadServiceModule` (with default `getJson`/`requestJson`/`buildHashedQueryUrl` injections) + - `normalize` +- Refactored `tests/phase6/service-behaviour.test.cjs` to import shared harness utilities and remove duplicated local harness implementation. +- Refactored `tests/phase7/service-behaviour.test.cjs` to import shared harness utilities and remove duplicated local harness implementation. + +Validation: + +- `node tests/phase6/service-behaviour.test.cjs` -> pass (8/8) +- `node tests/phase7/service-behaviour.test.cjs` -> pass (12/12) +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- Optional next bounded risk-reduction slice: evaluate whether other legacy service test suites can adopt `tests/serviceHarness.cjs` to standardize migration-era service mocking behavior. diff --git a/tests/phase6/service-behaviour.test.cjs b/tests/phase6/service-behaviour.test.cjs index 9f5e7e5a..06f49ff6 100644 --- a/tests/phase6/service-behaviour.test.cjs +++ b/tests/phase6/service-behaviour.test.cjs @@ -1,116 +1,11 @@ -const fs = require("fs"); -const path = require("path"); -const vm = require("vm"); const assert = require("assert"); - -const rootDir = path.resolve(__dirname, "..", ".."); -const servicesDir = path.join(rootDir, "actions", "services"); - -const createAxiosMock = () => { - const axios = (config) => axios.request(config); - - axios.calls = []; - axios.requestHandler = async () => { - throw new Error("No axios.request handler configured"); - }; - axios.getHandler = async () => { - throw new Error("No axios.get handler configured"); - }; - axios.postHandler = async () => { - throw new Error("No axios.post handler configured"); - }; - - axios.request = (config) => { - axios.calls.push({ type: "request", config }); - return axios.requestHandler(config); - }; - - axios.get = (url, config) => { - axios.calls.push({ type: "get", url, config }); - return axios.getHandler(url, config); - }; - - axios.post = (url, data, config) => { - axios.calls.push({ type: "post", url, data, config }); - return axios.postHandler(url, data, config); - }; - - return axios; -}; - -const createLoggerMock = () => { - const calls = []; - const consoleLogger = (error) => { - calls.push(error); - }; - return { consoleLogger, calls }; -}; - -const createAxiosError = (status = 500, statusText = "Server Error") => { - return { - response: { - status, - statusText, - data: {} - }, - config: { - url: "/mock-url" - } - }; -}; - -const loadServiceModule = (fileName, injected = {}) => { - const filePath = path.join(servicesDir, fileName); - let source = fs.readFileSync(filePath, "utf8"); - - source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); - - const exportNames = Array.from( - source.matchAll(/export const\s+(\w+)\s*=/g) - ).map((match) => match[1]); - - source = source.replace(/export const\s+/g, "const "); - source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; - - const defaultGetJson = (url, config) => { - if (!injected.axios || !injected.axios.get) { - throw new Error("Missing axios.get for default getJson mock"); - } - - return injected.axios - .get(url, config) - .then((response) => response.data); - }; - - const defaultRequestJson = (config) => { - if (!injected.axios) { - throw new Error("Missing axios for default requestJson mock"); - } - - return injected.axios(config).then((response) => response.data); - }; - - const context = { - module: { exports: {} }, - exports: {}, - require, - URLSearchParams, - encodeURIComponent, - FormData: global.FormData, - console: { - log: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - getJson: injected.getJson || defaultGetJson, - requestJson: injected.requestJson || defaultRequestJson, - ...injected - }; - - vm.runInNewContext(source, context, { filename: filePath }); - return context.module.exports; -}; +const { + createAxiosMock, + createLoggerMock, + createAxiosError, + loadServiceModule, + normalize +} = require("../serviceHarness.cjs"); const tests = []; @@ -118,8 +13,6 @@ const test = (name, fn) => { tests.push({ name, fn }); }; -const normalize = (value) => JSON.parse(JSON.stringify(value)); - test("search/getBasicSearch returns res.data on success", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs index e5bb54ca..d456b7e8 100644 --- a/tests/phase7/service-behaviour.test.cjs +++ b/tests/phase7/service-behaviour.test.cjs @@ -1,132 +1,11 @@ -const fs = require("fs"); -const path = require("path"); -const vm = require("vm"); const assert = require("assert"); - -const rootDir = path.resolve(__dirname, "..", ".."); -const servicesDir = path.join(rootDir, "actions", "services"); - -const createAxiosMock = () => { - const axios = (config) => axios.request(config); - - axios.calls = []; - axios.requestHandler = async () => { - throw new Error("No axios.request handler configured"); - }; - axios.getHandler = async () => { - throw new Error("No axios.get handler configured"); - }; - axios.postHandler = async () => { - throw new Error("No axios.post handler configured"); - }; - - axios.request = (config) => { - axios.calls.push({ type: "request", config }); - return axios.requestHandler(config); - }; - - axios.get = (url, config) => { - axios.calls.push({ type: "get", url, config }); - return axios.getHandler(url, config); - }; - - axios.post = (url, data, config) => { - axios.calls.push({ type: "post", url, data, config }); - return axios.postHandler(url, data, config); - }; - - return axios; -}; - -const createLoggerMock = () => { - const calls = []; - const consoleLogger = (error) => { - calls.push(error); - }; - return { consoleLogger, calls }; -}; - -const createAxiosError = (status = 500, statusText = "Server Error") => { - return { - response: { - status, - statusText, - data: {} - }, - config: { - url: "/mock-url" - } - }; -}; - -const loadServiceModule = (fileName, injected = {}) => { - const filePath = path.join(servicesDir, fileName); - let source = fs.readFileSync(filePath, "utf8"); - - source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); - - const exportNames = Array.from( - source.matchAll(/export const\s+(\w+)\s*=/g) - ).map((match) => match[1]); - - source = source.replace(/export const\s+/g, "const "); - source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; - - const defaultGetJson = (url, config) => { - if (!injected.axios || !injected.axios.get) { - throw new Error("Missing axios.get for default getJson mock"); - } - - return injected.axios - .get(url, config) - .then((response) => response.data); - }; - - const defaultRequestJson = (config) => { - if (!injected.axios) { - throw new Error("Missing axios for default requestJson mock"); - } - - return injected.axios(config).then((response) => response.data); - }; - - const defaultBuildHashedQueryUrl = async (queryUrl) => { - if (!injected.axios || !injected.axios.get) { - throw new Error( - "Missing axios.get for default buildHashedQueryUrl mock" - ); - } - - const hashResponse = await injected.axios.get( - "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) - ); - - return queryUrl + hashResponse.data.hash; - }; - - const context = { - module: { exports: {} }, - exports: {}, - require, - URLSearchParams, - encodeURIComponent, - FormData: global.FormData, - console: { - log: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - getJson: injected.getJson || defaultGetJson, - requestJson: injected.requestJson || defaultRequestJson, - buildHashedQueryUrl: - injected.buildHashedQueryUrl || defaultBuildHashedQueryUrl, - ...injected - }; - - vm.runInNewContext(source, context, { filename: filePath }); - return context.module.exports; -}; +const { + createAxiosMock, + createLoggerMock, + createAxiosError, + loadServiceModule, + normalize +} = require("../serviceHarness.cjs"); const tests = []; @@ -134,8 +13,6 @@ const test = (name, fn) => { tests.push({ name, fn }); }; -const normalize = (value) => JSON.parse(JSON.stringify(value)); - test("document/getAwaitingSubmissionFromBlob includes hash token and returns data", async () => { const axios = createAxiosMock(); const logger = createLoggerMock(); diff --git a/tests/serviceHarness.cjs b/tests/serviceHarness.cjs new file mode 100644 index 00000000..2ee7b943 --- /dev/null +++ b/tests/serviceHarness.cjs @@ -0,0 +1,144 @@ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const rootDir = path.resolve(__dirname, ".."); +const servicesDir = path.join(rootDir, "actions", "services"); + +const createAxiosMock = () => { + const axios = (config) => axios.request(config); + + axios.calls = []; + axios.requestHandler = async () => { + throw new Error("No axios.request handler configured"); + }; + axios.getHandler = async () => { + throw new Error("No axios.get handler configured"); + }; + axios.postHandler = async () => { + throw new Error("No axios.post handler configured"); + }; + + axios.request = (config) => { + axios.calls.push({ type: "request", config }); + return axios.requestHandler(config); + }; + + axios.get = (url, config) => { + axios.calls.push({ type: "get", url, config }); + return axios.getHandler(url, config); + }; + + axios.post = (url, data, config) => { + axios.calls.push({ type: "post", url, data, config }); + return axios.postHandler(url, data, config); + }; + + return axios; +}; + +const createLoggerMock = () => { + const calls = []; + const consoleLogger = (error) => { + calls.push(error); + }; + return { consoleLogger, calls }; +}; + +const createAxiosError = (status = 500, statusText = "Server Error") => { + return { + response: { + status, + statusText, + data: {} + }, + config: { + url: "/mock-url" + } + }; +}; + +const loadServiceModule = (fileName, injected = {}) => { + const filePath = path.join(servicesDir, fileName); + let source = fs.readFileSync(filePath, "utf8"); + + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + + const exportNames = Array.from( + source.matchAll(/export const\s+(\w+)\s*=/g) + ).map((match) => match[1]); + + source = source.replace(/export const\s+/g, "const "); + source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; + + const defaultGetJson = (url, config) => { + if (!injected.axios || !injected.axios.get) { + throw new Error("Missing axios.get for default getJson mock"); + } + + return injected.axios + .get(url, config) + .then((response) => response.data); + }; + + const defaultRequestJson = (config) => { + if (!injected.axios) { + throw new Error("Missing axios for default requestJson mock"); + } + + return injected.axios(config).then((response) => response.data); + }; + + const defaultBuildHashedQueryUrl = async (queryUrl) => { + if (!injected.axios || !injected.axios.get) { + throw new Error( + "Missing axios.get for default buildHashedQueryUrl mock" + ); + } + + const hashResponse = await injected.axios.get( + "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl) + ); + + return queryUrl + hashResponse.data.hash; + }; + + const context = { + module: { exports: {} }, + exports: {}, + require, + URLSearchParams, + encodeURIComponent, + FormData: global.FormData, + console: { + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + }, + getJson: injected.getJson || defaultGetJson, + requestJson: injected.requestJson || defaultRequestJson, + buildHashedQueryUrl: + injected.buildHashedQueryUrl || defaultBuildHashedQueryUrl, + ...injected + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +const normalize = (value) => { + if (value === undefined || value === null) { + return value; + } + + return JSON.parse(JSON.stringify(value)); +}; + +module.exports = { + createAxiosMock, + createLoggerMock, + createAxiosError, + loadServiceModule, + normalize +};