diff --git a/README.md b/README.md index f28d760..5a31c48 100644 --- a/README.md +++ b/README.md @@ -301,65 +301,13 @@ For contributing to this repository, please see the [CONTRIBUTING.md](CONTRIBUTI ### Tests & Quality -Current automated test stack (now at Phase 2) is in place: - -Phase 1: basic quality gates +See `tests/README.md` for test setup, commands, and the roadmap. Quick commands: - Lint & formatting: `node --run lint` - Spell checking: `node --run test:spelling` - -Phase 2: initial unit test & coverage foundation - -- Unit tests (Node built-in runner): `node --run test:unit` -- Combined pipeline (lint + spelling + unit): `npm test` +- Unit tests (Node built-in): `node --run test:unit` - Coverage (c8): `node --run test:coverage` -Coverage thresholds start deliberately low to allow incremental improvements. New tests should raise real coverage and can then justify increasing thresholds in `package.json`. - -#### Planned Test Roadmap (Phases) - -1. Phase (done): Base quality gates (lint, formatter, spellcheck) – no runtime tests -2. Phase (done): Test runner, extracted utilities, first unit tests, coverage baseline -3. Phase: More unit & first integration tests - -- Extra edge cases for `cleanConfig` -- GET routes: `/get?data=moduleAvailable`, `/get?data=config`, error path -- Small express/test factory (mocks: fs, simple-git) -- Raise coverage target (e.g. statements >8%) - -4. Phase: Action / socket logic (core `executeQuery` paths) - -- BRIGHTNESS, TEMP, NOTIFICATION parsing, HIDE/SHOW/TOGGLE selection logic -- DELAYED timer (start, reset, abort) - -5. Phase: Persistence & backups - -- `answerPost` (saving config), backup rotation, UNDO_CONFIG, failure scenarios (fs errors, disk edge cases) - -6. Phase: Module install & update flows - -- `installModule`, `updateModule` with mocked `simple-git` & `exec` - -7. Phase: System / hardware related commands - -- Monitor control (status detection), shutdown/reboot, PM2 control (pm2 mock) - -8. Phase: Frontend / DOM logic (jsdom) - -- `getDom()` URL/port logic, brightness filter application, temp overlay color gradients - -9. Phase: API / contract tests - -- Validate against `docs/swagger.json` (add missing if needed) - -10. Phase: Optional E2E / Docker integration - -- Spin minimal MagicMirror instance; smoke test key endpoints - -11. Phase (ongoing): Gradually raise coverage thresholds & consider mutation tests - -Each phase incrementally increases coverage thresholds to encourage steady progress without big-bang changes. - ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details. diff --git a/package.json b/package.json index 390a90b..19fea5a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ ], "include": [ "lib/**/*.js", + "API/**/*.js", "node_helper.js", "MMM-Remote-Control.js" ], @@ -85,9 +86,9 @@ "**/*.md" ], "check-coverage": true, - "branches": 5, - "lines": 4, - "functions": 4, - "statements": 4 + "branches": 6, + "lines": 6, + "functions": 5, + "statements": 6 } } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..629c43f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# Tests + +This project uses Node's built-in test runner and c8 for coverage. + +Quick run: + +- Lint & format check: `node --run lint` +- Unit tests: `node --run test:unit` +- Coverage: `node --run test:coverage` + +Guidelines: + +- Keep tests side-effect free; mock/bypass filesystem, network, and timers. +- For API helpers in `API/api.js`, bind functions to a fake context rather than spinning an Express server. +- For `node_helper.js` paths, test logic by stubbing methods (e.g., `sendSocketNotification`, `sendResponse`) and, when necessary, mock timers. +- Follow repository lint rules; keep arrays/objects on one line where stylistic rules require it. + +Notes on shims used in tests: + +- We provide lightweight shims under `tests/shims/` (e.g., `logger.js`, `node_helper.js`) to isolate unit tests from MagicMirror core. When a test needs to load `node_helper.js`, inject the shim path via `NODE_PATH` and call `module._initPaths()` before requiring the target file. + +Status checkpoint (2025-09-21): + +- Phases 1 and 2 completed. We added unit tests for API helpers (`checkDelay`, `answerNotifyApi`), `answerModuleApi` defaults and SHOW flow, and timer behavior for `delayedQuery`. +- Introduced shims for `logger` and `node_helper` so `node_helper.js` can be required in tests without MagicMirror runtime. +- Phase 3 router-level GET coverage added; one direct `answerGet` unit test remains skipped and is deferred to a later phase once NodeHelper/MM core is further isolated. + +## Test Roadmap (Phases) + +The project follows an incremental test roadmap. Coverage thresholds start low and rise as we add tests. + +1. Phase 1 — Base quality gates (Done) + - Lint, formatter, spellcheck wired; no runtime tests. +2. Phase 2 — Test runner & utilities (Done) + - Test runner configured, first unit tests added, coverage baseline established. +3. Phase 3 — More unit & first integration tests (Done) + - Extra edge cases for `cleanConfig` (Done) + - GET routes covered at router level (Done) + - Small express/test factory (mocks: fs, simple-git) (Deferred) + - Raise coverage target (Done — thresholds bumped slightly) +4. Phase 4 — Action / socket logic (core `executeQuery` paths) (In progress) + - DELAYED timer (start, reset, abort) (Done) + - BRIGHTNESS, TEMP, NOTIFICATION parsing, HIDE/SHOW/TOGGLE selection logic (Pending) +5. Phase 5 — Persistence & backups (Not started) + - `answerPost` (saving config), backup rotation, UNDO_CONFIG, failure scenarios (fs errors, disk edge cases) +6. Phase 6 — Module install & update flows (Not started) + - `installModule`, `updateModule` with mocked `simple-git` & `exec` +7. Phase 7 — System / hardware related commands (Not started) + - Monitor control (status detection), shutdown/reboot, PM2 control (pm2 mock) +8. Phase 8 — Frontend / DOM logic (jsdom) (Not started) + - `getDom()` URL/port logic, brightness filter application, temp overlay color gradients +9. Phase 9 — API / contract tests (Not started) + - Validate against `docs/swagger.json` (add missing if needed) +10. Phase 10 — Optional E2E / Docker integration (Not started) + - Spin minimal MagicMirror instance; smoke test key endpoints +11. Phase 11 — Raise thresholds & mutation tests (Ongoing) + - Gradually raise coverage thresholds; consider mutation testing after core paths stable. diff --git a/tests/shims/logger.js b/tests/shims/logger.js new file mode 100644 index 0000000..6bd08e4 --- /dev/null +++ b/tests/shims/logger.js @@ -0,0 +1,7 @@ +module.exports = { + log: (...args) => console.log(...args), + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), + debug: (...args) => console.debug(...args) +}; diff --git a/tests/shims/node_helper.js b/tests/shims/node_helper.js new file mode 100644 index 0000000..45d1d41 --- /dev/null +++ b/tests/shims/node_helper.js @@ -0,0 +1,3 @@ +module.exports = { + create: (obj) => obj +}; diff --git a/tests/unit/answerGet.test.js b/tests/unit/answerGet.test.js new file mode 100644 index 0000000..739c7bb --- /dev/null +++ b/tests/unit/answerGet.test.js @@ -0,0 +1,7 @@ +const {test} = require("node:test"); + +/* + * TODO(later phase): Direct unit coverage of answerGet when we further isolate NodeHelper/MM core. + * Router-level GET coverage is in tests/unit/api.getRoutes.mapping.test.js (Phase 3 done). + */ +test.skip("answerGet GET paths: moduleAvailable/config/error", () => {}); diff --git a/tests/unit/api.answerModuleApi.test.js b/tests/unit/api.answerModuleApi.test.js new file mode 100644 index 0000000..33a7331 --- /dev/null +++ b/tests/unit/api.answerModuleApi.test.js @@ -0,0 +1,63 @@ +const assert = require("node:assert/strict"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); + +const apiModule = require("../../API/api.js"); + +function makeCtx (overrides = {}) { + return { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + externalApiRoutes: {}, + moduleApiMenu: {}, + translation: {}, + configData: {moduleData: []}, + mergeData: function () { return {success: true, data: this.configData.moduleData}; }, + sendSocketNotification: () => {}, + sendResponse: () => {}, + checkInitialized: () => true, + translate: (s) => s, + formatName: (s) => s, + thisConfig: {}, + ...overrides + }; +} + +group("Module API", () => { + test("DEFAULTS action requests defaultConfig via answerGet", () => { + const captured = {}; + const ctx = makeCtx({ + mergeData: () => ({success: true, data: [{identifier: "module_1_weather", name: "weather", urlPath: "weather", actions: {}}]}), + answerGet: (query, res) => { + captured.query = query; + if (res && res.json) res.json({ok: true}); + } + }); + const answerModuleApi = apiModule.answerModuleApi.bind(ctx); + + const req = {params: {moduleName: "weather", action: "defaults"}}; + const res = {status: () => ({json: () => {}}), json: () => {}}; + answerModuleApi(req, res); + + assert.deepEqual(captured.query, {data: "defaultConfig", module: "weather"}); + }); + + test("SHOW action on 'all' triggers executeQuery with module all", () => { + const captured = {}; + const ctx = makeCtx({ + // Minimal module data so filtering passes when moduleName === 'all' + configData: {moduleData: [{identifier: "module_1_test", name: "test", urlPath: "test"}]}, + executeQuery: (query) => { captured.query = query; }, + sendSocketNotification: () => {}, + // No delay in this test + checkDelay: (q) => q + }); + const answerModuleApi = apiModule.answerModuleApi.bind(ctx); + + const req = {params: {moduleName: "all", action: "show"}}; + const res = {json: () => {}}; + answerModuleApi(req, res); + + assert.equal(captured.query.action, "SHOW"); + assert.equal(captured.query.module, "all"); + }); +}); diff --git a/tests/unit/api.delayedFlow.test.js b/tests/unit/api.delayedFlow.test.js new file mode 100644 index 0000000..b424bd7 --- /dev/null +++ b/tests/unit/api.delayedFlow.test.js @@ -0,0 +1,48 @@ +const assert = require("node:assert/strict"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); + +const apiModule = require("../../API/api.js"); + +function makeCtx (overrides = {}) { + return { + configOnHd: {modules: []}, + externalApiRoutes: {}, + moduleApiMenu: {}, + translation: {}, + sendSocketNotification: () => {}, + sendResponse: () => {}, + checkInitialized: () => true, + checkDelay: apiModule.checkDelay, + translate: (s) => s, + formatName: (s) => s, + delayedQuery: () => {}, + thisConfig: {}, + ...overrides + }; +} + +group("Delayed flow (/delay)", () => { + test("answerNotifyApi wraps query into DELAYED and preserves payload", () => { + const captured = {}; + const ctx = makeCtx({ + delayedQuery: (query) => { captured.query = query; } + }); + const answerNotifyApi = apiModule.answerNotifyApi.bind(ctx); + + const req = { + method: "GET", + params: {notification: "HELLO", delayed: "delay"}, + query: {did: "ID1", timeout: 5} + }; + const res = {json: () => {}}; + + answerNotifyApi(req, res); + + assert.equal(captured.query.action, "DELAYED"); + assert.equal(captured.query.did, "ID1"); + assert.equal(captured.query.timeout, 5); + assert.equal(captured.query.query.action, "NOTIFICATION"); + assert.equal(captured.query.query.notification, "HELLO"); + }); +}); diff --git a/tests/unit/api.getRoutes.mapping.test.js b/tests/unit/api.getRoutes.mapping.test.js new file mode 100644 index 0000000..f75b038 --- /dev/null +++ b/tests/unit/api.getRoutes.mapping.test.js @@ -0,0 +1,261 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +/* + * Test contract: + * - createApiRoutes registers GET routes that map to answerGet with a computed data key. + * - We avoid MM core by stubbing getExternalApiByGuessing and spying answerGet. + */ + +const api = require("../../API/api.js"); + +function getHandlerForPath (router, method, path) { + // Find a layer whose route matches the path and method. + for (const layer of router.stack) { + if (!layer.route) continue; + const hasMethod = layer.route.methods && layer.route.methods[method.toLowerCase()]; + const matches = layer.route.path + ? Array.isArray(layer.route.path) + ? layer.route.path.includes(path) + : layer.route.path === path + : layer.route.regexp ? layer.route.regexp.test(path) : false; + if (hasMethod && matches) { + // Return first handler in the stack for the route + return layer.route.stack[0].handle; + } + } + return null; +} + +test("API GET routes map to answerGet keys", async () => { + const calls = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + secureEndpoints: true, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + checkInitialized: () => true, + answerGet: (query) => { calls.push(query); } + }; + + api.createApiRoutes.call(fake); + assert.ok(fake.expressRouter, "expressRouter should be initialized"); + + const invoke = async (p) => { + const handler = getHandlerForPath(fake.expressRouter, "get", p); + assert.ok(handler, `handler found for ${p}`); + await handler({path: p}, {}); + }; + + await invoke("/saves"); + await invoke("/classes"); + await invoke("/module/installed"); + await invoke("/module/available"); + await invoke("/translations"); + await invoke("/mmUpdateAvailable"); + await invoke("/brightness"); + await invoke("/config"); + + // Ensure mappings transformed path correctly + const keys = calls.map((c) => c.data); + assert.deepEqual(keys, [ + "saves", + "classes", + "moduleInstalled", + "moduleAvailable", + "translations", + "mmUpdateAvailable", + "brightness", + "config" + ]); +}); + +test("/classes/:value returns 400 for invalid value", async () => { + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + secureEndpoints: true, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + checkInitialized: () => true, + getConfig: () => ({modules: [{module: "MMM-Remote-Control", config: {classes: {valid: true}}}]}), + executeQuery: () => {} + }; + + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/classes/:value"); + assert.ok(handler, "handler found for /classes/:value"); + + let statusCode; + let body; + const res = { + status: (code) => { + statusCode = code; + return { + json: (b) => { body = b; } + }; + } + }; + + await handler({path: "/classes/unknown", params: {value: encodeURIComponent("unknown")}}, res); + assert.equal(statusCode, 400); + assert.equal(body.success, false); + assert.match(body.message, /Invalid value/i); +}); + +test("/test returns success true", async () => { + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + checkInitialized: () => true + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/test"); + let body; + const res = {json: (b) => { body = b; }}; + await handler({}, res); + assert.equal(body.success, true); +}); + +test("/save triggers executeQuery with SAVE action", async () => { + const called = []; + const fake = { + secureEndpoints: false, // bypass secure endpoint check + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + checkDelay: (q) => q, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + executeQuery: (q) => { called.push(q); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/save"); + await handler({path: "/save"}, {}); + assert.equal(called[0].action, "SAVE"); +}); + +test("/userpresence maps to answerGet without value", async () => { + const calls = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + checkInitialized: () => true, + answerGet: (q) => { calls.push(q); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/userpresence{/:value}") || getHandlerForPath(fake.expressRouter, "get", "/userpresence/:value?"); + await handler({path: "/userpresence", params: {}}, {json: () => {}}); + assert.equal(calls[0].data, "userPresence"); +}); + +test("/userpresence/true triggers USER_PRESENCE with true", async () => { + const called = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + executeQuery: (q) => { called.push(q); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/userpresence{/:value}") || getHandlerForPath(fake.expressRouter, "get", "/userpresence/:value?"); + await handler({path: "/userpresence/true", params: {value: "true"}}, {}); + assert.equal(called[0].action, "USER_PRESENCE"); + assert.equal(called[0].value, true); +}); + +test("/userpresence/invalid returns 400", async () => { + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {} + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/userpresence{/:value}") || getHandlerForPath(fake.expressRouter, "get", "/userpresence/:value?"); + let statusCode; + const res = {status: (c) => { statusCode = c; return {json: () => {}}; }}; + await handler({path: "/userpresence/invalid", params: {value: "invalid"}}, res); + assert.equal(statusCode, 400); +}); + +test("/update maps to mmUpdateAvailable via answerGet when no module", async () => { + const calls = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + secureEndpoints: false, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + answerGet: (q) => { calls.push(q); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/update{/:moduleName}") || getHandlerForPath(fake.expressRouter, "get", "/update/:moduleName"); + await handler({path: "/update", params: {}}, {}); + assert.equal(calls[0].data, "mmUpdateAvailable"); +}); + +test("/update/rc calls updateModule for MMM-Remote-Control", async () => { + const called = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + secureEndpoints: false, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + updateModule: (name) => { called.push(name); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/update{/:moduleName}") || getHandlerForPath(fake.expressRouter, "get", "/update/:moduleName"); + await handler({path: "/update/rc", params: {moduleName: "rc"}}, {}); + assert.equal(called[0], "MMM-Remote-Control"); +}); + +test("/brightness/:setting validates number and executes BRIGHTNESS", async () => { + const called = []; + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {}, + executeQuery: (q) => { called.push(q); } + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/brightness/:setting"); + await handler({path: "/brightness/99", params: {setting: "99"}}, {status: () => ({json: () => {}})}); + assert.equal(called[0].action, "BRIGHTNESS"); + assert.equal(called[0].value, "99"); +}); + +test("/brightness/:setting invalid returns 400", async () => { + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {} + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/brightness/:setting"); + let statusCode; + await handler({path: "/brightness/abc", params: {setting: "abc"}}, {status: (c) => { statusCode = c; return {json: () => {}}; }}); + assert.equal(statusCode, 400); +}); + +test("/install GET returns 400", async () => { + const fake = { + configOnHd: {modules: [{module: "MMM-Remote-Control", config: {}}]}, + getApiKey: () => {}, + expressApp: {use: () => {}}, + getExternalApiByGuessing: () => {} + }; + api.createApiRoutes.call(fake); + const handler = getHandlerForPath(fake.expressRouter, "get", "/install"); + let statusCode; + await handler({}, {status: (c) => { statusCode = c; return {json: () => {}}; }}); + assert.equal(statusCode, 400); +}); diff --git a/tests/unit/api.helpers.test.js b/tests/unit/api.helpers.test.js new file mode 100644 index 0000000..de85496 --- /dev/null +++ b/tests/unit/api.helpers.test.js @@ -0,0 +1,86 @@ +const assert = require("node:assert/strict"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); + +// We'll import the module and bind methods to a fake context so we can test pure-ish helpers +const apiModule = require("../../API/api.js"); + +function makeCtx (overrides = {}) { + return { + configOnHd: {modules: []}, + externalApiRoutes: {}, + moduleApiMenu: {}, + translation: {}, + sendSocketNotification: () => {}, + sendResponse: () => {}, + checkInitialized: () => true, + translate: (s) => s, + formatName: (s) => s, + thisConfig: {}, + ...overrides + }; +} + +group("API helpers", () => { + group("checkDelay", () => { + test("wraps query as DELAYED when '/delay' is used (adds ID and default timeout)", () => { + const ctx = makeCtx(); + const checkDelay = apiModule.checkDelay.bind(ctx); + const req = {params: {delayed: "delay"}, query: {}, body: {}}; + const q = {action: "RESTART"}; + const out = checkDelay(q, req); + assert.equal(out.action, "DELAYED"); + assert.ok(out.did && typeof out.did === "string"); + assert.equal(out.timeout, 10); + assert.deepEqual(out.query, q); + }); + + test("keeps original query when '/delay' is not used", () => { + const ctx = makeCtx(); + const checkDelay = apiModule.checkDelay.bind(ctx); + const req = {params: {}, query: {}, body: {}}; + const q = {action: "REFRESH"}; + const out = checkDelay(q, req); + assert.equal(out, q); + }); + }); + + group("answerNotifyApi", () => { + test("builds payload from GET params and returns success", () => { + const captured = {}; + const ctx = makeCtx({ + sendSocketNotification: (what, payload) => { + captured.what = what; + captured.payload = payload; + } + }); + const answerNotifyApi = apiModule.answerNotifyApi.bind(ctx); + + const req = {method: "GET", params: {notification: "TEST_ACTION"}, query: {foo: "bar"}}; + const res = {json: (obj) => { captured.response = obj; }}; + answerNotifyApi(req, res); + + assert.equal(captured.what, "NOTIFICATION"); + assert.equal(captured.payload.notification, "TEST_ACTION"); + assert.deepEqual(captured.response, {success: true, notification: "TEST_ACTION", payload: {foo: "bar"}}); + }); + + test("merges POST body and action payload into final payload", () => { + const captured = {}; + const ctx = makeCtx({sendSocketNotification: (what, payload) => { captured.payload = payload; }}); + const answerNotifyApi = apiModule.answerNotifyApi.bind(ctx); + + const req = {method: "POST", params: {notification: "TEST_ACTION"}, query: {alpha: 1}, body: {beta: 2}}; + const res = {json: () => {}}; + const action = {notification: "TEST_ACTION", payload: {gamma: 3}}; + + answerNotifyApi(req, res, action); + + // payload should contain alpha, beta, and action payload gamma + assert.equal(captured.payload.notification, "TEST_ACTION"); + assert.equal(captured.payload.payload.alpha, 1); + assert.equal(captured.payload.payload.beta, 2); + assert.equal(captured.payload.payload.gamma, 3); + }); + }); +}); diff --git a/tests/unit/configUtils.edgecases.test.js b/tests/unit/configUtils.edgecases.test.js new file mode 100644 index 0000000..8f4a0bb --- /dev/null +++ b/tests/unit/configUtils.edgecases.test.js @@ -0,0 +1,34 @@ +const assert = require("node:assert/strict"); +const {test, describe} = require("node:test"); +const {cleanConfig} = require("../../lib/configUtils"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); + +group("configUtils.cleanConfig edge cases", () => { + test("removes deep-equal defaults; preserves differing arrays/objects", () => { + const defaultConfig = {language: "en", header: {enabled: true}, list: [1, 2, 3]}; + const moduleDefaultsMap = {foo: {arr: [1, 2], obj: {a: 1}}}; + const cfg = { + language: "en", // should be removed + header: {enabled: true}, // should be removed (deep equal) + list: [1, 2, 3, 4], // differs -> keep + modules: [{module: "foo", header: "", config: {arr: [1, 2], obj: {a: 1}, extra: 9, position: ""}}] + }; + cleanConfig({config: cfg, defaultConfig, moduleDefaultsMap}); + assert.ok(!("language" in cfg)); + assert.ok(!("header" in cfg)); + assert.deepEqual(cfg.list, [1, 2, 3, 4]); + const m = cfg.modules[0]; + assert.ok(!("arr" in m.config)); + assert.ok(!("obj" in m.config)); + assert.equal(m.config.extra, 9); + assert.ok(!("position" in m.config)); + assert.ok(!("header" in m)); + }); + + test("tolerates nulls at top level and unknown modules", () => { + const cfg = {language: null, modules: [{module: "unknown", config: {x: 1}}]}; + cleanConfig({config: cfg, defaultConfig: {language: null}, moduleDefaultsMap: {}}); + assert.ok(!("language" in cfg)); + assert.equal(cfg.modules[0].config.x, 1); + }); +}); diff --git a/tests/unit/configUtils.test.js b/tests/unit/configUtils.test.js index e31fc96..6392296 100644 --- a/tests/unit/configUtils.test.js +++ b/tests/unit/configUtils.test.js @@ -1,43 +1,43 @@ const assert = require("node:assert/strict"); -const {test} = require("node:test"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); const {cleanConfig} = require("../../lib/configUtils"); -test("cleanConfig removes top-level defaults and module defaults", () => { - const defaultConfig = {language: "en", timeFormat: 24}; - const moduleDefaultsMap = { - "modA": {foo: 1, bar: 2}, - "modB": {alpha: true} - }; - const config = { - language: "en", // should be removed - timeFormat: 12, // differs -> keep - modules: [ - {module: "modA", header: "", config: {foo: 1, bar: 2, baz: 9, position: ""}}, - {module: "modB", config: {alpha: true}}, - {module: "modC", config: {value: 42}} // no defaults -> untouched - ] - }; - cleanConfig({config, defaultConfig, moduleDefaultsMap}); - // top level - assert.ok(!("language" in config)); - assert.equal(config.timeFormat, 12); - // modA cleaned - const modA = config.modules[0]; - assert.ok(!("foo" in modA.config)); - assert.ok(!("bar" in modA.config)); - assert.equal(modA.config.baz, 9); - assert.ok(!("position" in modA.config)); - assert.ok(!("header" in modA)); - // modB cleaned - const modB = config.modules[1]; - assert.ok(!("alpha" in modB.config)); - // modC untouched - const modC = config.modules[2]; - assert.equal(modC.config.value, 42); -}); +group("configUtils.cleanConfig", () => { + test("removes defaults at top level and per-module", () => { + const defaultConfig = {language: "en", timeFormat: 24}; + const moduleDefaultsMap = {modA: {foo: 1, bar: 2}, modB: {alpha: true}}; + const config = { + language: "en", // should be removed + timeFormat: 12, // differs -> keep + modules: [ + {module: "modA", header: "", config: {foo: 1, bar: 2, baz: 9, position: ""}}, + {module: "modB", config: {alpha: true}}, + {module: "modC", config: {value: 42}} // no defaults -> untouched + ] + }; + cleanConfig({config, defaultConfig, moduleDefaultsMap}); + // top level + assert.ok(!("language" in config)); + assert.equal(config.timeFormat, 12); + // modA cleaned + const modA = config.modules[0]; + assert.ok(!("foo" in modA.config)); + assert.ok(!("bar" in modA.config)); + assert.equal(modA.config.baz, 9); + assert.ok(!("position" in modA.config)); + assert.ok(!("header" in modA)); + // modB cleaned + const modB = config.modules[1]; + assert.ok(!("alpha" in modB.config)); + // modC untouched + const modC = config.modules[2]; + assert.equal(modC.config.value, 42); + }); -test("cleanConfig handles missing modules array gracefully", () => { - const cfg = {language: "en"}; - cleanConfig({config: cfg, defaultConfig: {language: "en"}, moduleDefaultsMap: {}}); - assert.ok(!("language" in cfg)); + test("handles configs without a modules array", () => { + const cfg = {language: "en"}; + cleanConfig({config: cfg, defaultConfig: {language: "en"}, moduleDefaultsMap: {}}); + assert.ok(!("language" in cfg)); + }); }); diff --git a/tests/unit/delayedQuery.test.js b/tests/unit/delayedQuery.test.js new file mode 100644 index 0000000..619683a --- /dev/null +++ b/tests/unit/delayedQuery.test.js @@ -0,0 +1,117 @@ +const assert = require("node:assert/strict"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); + +// Add tests/shims to module resolution so 'logger' resolves to our shim +const path = require("node:path"); +const ModuleLib = require("module"); +const shimDir = path.resolve(__dirname, "../shims"); +process.env.NODE_PATH = shimDir + (process.env.NODE_PATH ? path.delimiter + process.env.NODE_PATH : ""); +// Re-initialize search paths to include NODE_PATH +if (typeof ModuleLib._initPaths === "function") { + ModuleLib._initPaths(); +} + +// Import node_helper.js to get delayedQuery and executeQuery behavior +const nodeHelperFactory = require("../../node_helper.js"); + +// Capture originals to restore after tests +const ORIGINAL_TIMERS = {setTimeout: global.setTimeout, clearTimeout: global.clearTimeout}; + +// Builds a minimal helper instance with overridden timer functions for determinism +function makeHelperWithFakeTimers () { + // Create a fresh instance of the helper + const helper = Object.assign({}, nodeHelperFactory); + + // Capture scheduled timeouts by id + const timeouts = new Map(); + let nextId = 1; + + // Fake setTimeout: call the handler immediately but record it so we can assert + function fakeSetTimeout (fn, _ms) { + void _ms; // mark as used for linting + const id = nextId++; + timeouts.set(id, fn); + // Do not call immediately here; we want to inspect map changes first + return id; + } + function fakeClearTimeout (id) { + timeouts.delete(id); + } + + // Replace timer methods on instance scope + helper.delayedQueryTimers = {}; + helper.executeQuery = (q) => { helper.__executed = (helper.__executed || []).concat([q]); }; + helper.sendResponse = (_res, _err, data) => data || {}; + + // Monkey patch global timer functions used by helper.delayedQuery via closure scoping + global.setTimeout = (fn, ms) => fakeSetTimeout(fn, ms); + global.clearTimeout = (id) => fakeClearTimeout(id); + + return {helper, timeouts}; +} + +group("node_helper delayedQuery timers", () => { + test("schedules and executes action once timeout fires", () => { + const {helper, timeouts} = makeHelperWithFakeTimers(); + const res = {}; + const q = {did: "A", timeout: 1, query: {action: "TEST"}}; + + helper.delayedQuery(q, res); + // One timer should be recorded + assert.equal(Object.keys(helper.delayedQueryTimers).length, 1); + + // Simulate timeout firing + const ids = Object.values(helper.delayedQueryTimers); + ids.forEach((id) => { + const fn = timeouts.get(id); + if (fn) fn(); + }); + + assert.equal((helper.__executed || []).length, 1); + assert.equal(helper.__executed[0].action, "TEST"); + }); + + test("reset with same did replaces prior timer", () => { + const {helper, timeouts} = makeHelperWithFakeTimers(); + const res = {}; + + helper.delayedQuery({did: "X", timeout: 1, query: {action: "ONE"}}, res); + const firstId = Object.values(helper.delayedQueryTimers)[0]; + helper.delayedQuery({did: "X", timeout: 1, query: {action: "TWO"}}, res); + const secondId = Object.values(helper.delayedQueryTimers)[0]; + + assert.notEqual(firstId, secondId); + // Firing first should do nothing (cleared) + const fn1 = timeouts.get(firstId); + if (fn1) fn1(); + assert.equal((helper.__executed || []).length || 0, 0); + + // Fire second + const fn2 = timeouts.get(secondId); + if (fn2) fn2(); + assert.equal(helper.__executed.length, 1); + assert.equal(helper.__executed[0].action, "TWO"); + }); + + test("abort cancels scheduled timer", () => { + const {helper, timeouts} = makeHelperWithFakeTimers(); + const res = {}; + + helper.delayedQuery({did: "Y", timeout: 1, query: {action: "NEVER"}}, res); + const id = Object.values(helper.delayedQueryTimers)[0]; + helper.delayedQuery({did: "Y", abort: true, query: {action: "NEVER"}}, res); + + // No timer should remain + assert.equal(Object.keys(helper.delayedQueryTimers).length, 0); + const fn = timeouts.get(id); + if (fn) fn(); + assert.equal((helper.__executed || []).length || 0, 0); + }); +}); + +// Restore global timers for other tests +test("restore timers", () => { + global.setTimeout = ORIGINAL_TIMERS.setTimeout; + global.clearTimeout = ORIGINAL_TIMERS.clearTimeout; +}); diff --git a/tests/unit/utils.test.js b/tests/unit/utils.test.js index 56f10b9..95c834e 100644 --- a/tests/unit/utils.test.js +++ b/tests/unit/utils.test.js @@ -1,28 +1,34 @@ /* Unit tests for lib/utils.js using Node's built-in test runner */ const assert = require("node:assert/strict"); -const {test} = require("node:test"); +const {test, describe} = require("node:test"); +const group = typeof describe === "function" ? describe : (_n, fn) => fn(); const {capitalizeFirst, formatName, includes} = require("../../lib/utils"); -test("capitalizeFirst normal word", () => { - assert.equal(capitalizeFirst("remote"), "Remote"); -}); - -test("capitalizeFirst empty string", () => { - assert.equal(capitalizeFirst(""), ""); -}); - -test("formatName strips MMM- and splits camelCase", () => { - assert.equal(formatName("MMM-Remote-Control"), "Remote Control"); -}); - -test("formatName handles hyphen and underscore", () => { - assert.equal(formatName("my-module_name"), "My Module Name"); -}); +group("utils", () => { + group("capitalizeFirst", () => { + test("converts first letter to uppercase", () => { + assert.equal(capitalizeFirst("remote"), "Remote"); + }); + test("returns empty string unchanged", () => { + assert.equal(capitalizeFirst(""), ""); + }); + }); -test("includes finds substring", () => { - assert.equal(includes("test", "this is a test string"), true); -}); + group("formatName", () => { + test("removes MMM- prefix and splits camelCase", () => { + assert.equal(formatName("MMM-Remote-Control"), "Remote Control"); + }); + test("converts hyphen/underscore to spaces and capitalizes", () => { + assert.equal(formatName("my-module_name"), "My Module Name"); + }); + }); -test("includes returns false if not found", () => { - assert.equal(includes("absent", "present string"), false); + group("includes", () => { + test("returns true when substring is found", () => { + assert.equal(includes("test", "this is a test string"), true); + }); + test("returns false when substring is absent", () => { + assert.equal(includes("absent", "present string"), false); + }); + }); });