diff --git a/package-lock.json b/package-lock.json index 8db7c2f..0b042b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1917,7 +1916,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2035,7 +2033,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2374,7 +2371,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -2430,7 +2426,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2710,7 +2705,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2901,7 +2895,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3389,7 +3382,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3632,7 +3624,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5071,7 +5062,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6094,7 +6084,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6148,7 +6137,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6280,7 +6268,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6356,7 +6343,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index 1a10419..4054936 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -79,29 +79,33 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr * * Supports dot notation for nested access: "userData.port" traverses * context.userData.port. The traversal walks through intermediate objects - * until it reaches the final property, which must be a string. + * until it reaches the final property, which must be a primitive value. + * Converts primitives (string, number, boolean) to strings for template substitution. * * @example - * // With context = { userData: { port: "8080" }, app_name: "myapp" } + * // With context = { userData: { port: 8080 }, app_name: "myapp" } * getContextValue("app_name") // => "myapp" - * getContextValue("userData.port") // => "8080" (traverses into userData object) - * getContextValue("userData") // => undefined (not a string) + * getContextValue("userData.port") // => "8080" (traverses into userData object, converts number to string) + * getContextValue("userData") // => undefined (not a primitive) * getContextValue("missing") // => undefined */ function getContextValue(path: string): string | undefined { const parts = path.split('.'); - let current: unknown = context; + let value: unknown = context; // Traverse the path through nested objects for (const part of parts) { - if (current == null || typeof current !== 'object') { + if (value == null || typeof value !== 'object') { return undefined; } - current = (current as Record)[part]; + value = (value as Record)[part]; } - // Only return string values - intermediate objects are not valid substitutions - return typeof current === 'string' ? current : undefined; + // Convert primitives to strings, reject objects/arrays/functions + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; } // Replace all {{ key }} occurrences diff --git a/test/unit/traefik/helpers.test.ts b/test/unit/traefik/helpers.test.ts index bbe0325..59fead5 100644 --- a/test/unit/traefik/helpers.test.ts +++ b/test/unit/traefik/helpers.test.ts @@ -3,60 +3,125 @@ import { getErrorMessage, detectCollisions } from '../../../src/backends/traefik describe('Traefik helpers', () => { describe('getErrorMessage', () => { - it('extracts message from Error objects', () => { + it('extracts message from Error object', () => { const error = new Error('Something went wrong'); expect(getErrorMessage(error)).toBe('Something went wrong'); }); - it('converts non-Error values to strings', () => { - expect(getErrorMessage('string error')).toBe('string error'); - expect(getErrorMessage(123)).toBe('123'); + it('converts string to string', () => { + expect(getErrorMessage('plain string error')).toBe('plain string error'); + }); + + it('converts number to string', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('converts null to string', () => { expect(getErrorMessage(null)).toBe('null'); + }); + + it('converts undefined to string', () => { expect(getErrorMessage(undefined)).toBe('undefined'); }); - it('handles objects by converting to string', () => { - expect(getErrorMessage({ code: 'ERR' })).toBe('[object Object]'); + it('converts object to string', () => { + const obj = { code: 'ERR_UNKNOWN' }; + expect(getErrorMessage(obj)).toBe('[object Object]'); }); - }); - describe('detectCollisions', () => { - it('returns empty array when no collisions', () => { - const target = { a: 1, b: 2 }; - const source = { c: 3, d: 4 }; - expect(detectCollisions(target, source)).toEqual([]); + it('handles TypeError', () => { + const error = new TypeError('Invalid type'); + expect(getErrorMessage(error)).toBe('Invalid type'); + }); + + it('handles custom Error subclass', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + const error = new CustomError('Custom error message'); + expect(getErrorMessage(error)).toBe('Custom error message'); }); + }); + describe('detectCollisions', () => { it('detects single collision', () => { - const target = { a: 1, b: 2 }; - const source = { b: 3, c: 4 }; - expect(detectCollisions(target, source)).toEqual(['b']); + const target = { foo: 1, bar: 2 }; + const source = { foo: 3, baz: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['foo']); }); it('detects multiple collisions', () => { - const target = { a: 1, b: 2, c: 3 }; - const source = { a: 10, c: 30, d: 40 }; - expect(detectCollisions(target, source)).toEqual(['a', 'c']); + const target = { foo: 1, bar: 2, baz: 3 }; + const source = { foo: 10, bar: 20, qux: 40 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('foo'); + expect(collisions).toContain('bar'); + expect(collisions).toHaveLength(2); + }); + + it('returns empty array when no collisions', () => { + const target = { foo: 1, bar: 2 }; + const source = { baz: 3, qux: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); }); it('handles empty target', () => { - const source = { a: 1, b: 2 }; - expect(detectCollisions({}, source)).toEqual([]); + const target = {}; + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); }); it('handles empty source', () => { - const target = { a: 1, b: 2 }; - expect(detectCollisions(target, {})).toEqual([]); + const target = { foo: 1, bar: 2 }; + const source = {}; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); }); - it('handles both empty', () => { - expect(detectCollisions({}, {})).toEqual([]); + it('handles both empty objects', () => { + const collisions = detectCollisions({}, {}); + expect(collisions).toEqual([]); }); - it('handles undefined arguments with defaults', () => { - expect(detectCollisions(undefined, undefined)).toEqual([]); - expect(detectCollisions({ a: 1 }, undefined)).toEqual([]); - expect(detectCollisions(undefined, { a: 1 })).toEqual([]); + it('handles undefined target (default parameter)', () => { + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(undefined, source); + expect(collisions).toEqual([]); + }); + + it('handles undefined source (default parameter)', () => { + const target = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, undefined); + expect(collisions).toEqual([]); + }); + + it('works with different value types', () => { + const target = { str: 'hello', num: 42, bool: true }; + const source = { str: 'world', obj: { nested: 'value' } }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['str']); + }); + + it('detects all keys when source is subset of target', () => { + const target = { a: 1, b: 2, c: 3, d: 4 }; + const source = { a: 10, c: 30 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('a'); + expect(collisions).toContain('c'); + expect(collisions).toHaveLength(2); + }); + + it('preserves order of keys from source', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { c: 30, a: 10, b: 20 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['c', 'a', 'b']); }); }); });