Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 13 additions & 9 deletions src/backends/traefik/templateParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[part];
value = (value as Record<string, unknown>)[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
Expand Down
121 changes: 93 additions & 28 deletions test/unit/traefik/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
});