241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||
import fc from "fast-check";
|
||
import { parseArgs } from "../src/layers/cli-layer/cli-layer.js";
|
||
import { coerceValue } from "../src/layers/env-layer/env-layer.js";
|
||
import type { ConfigData } from "../src/types/common.js";
|
||
import { getByPath, maskSensitive, setByPath } from "../src/utils/mask.js";
|
||
import { deepMerge, mergeLayers } from "../src/utils/merge.js";
|
||
|
||
/**
|
||
* Arbitrarily generates a plain JSON-serialisable config object with no
|
||
* undefined values (required for associativity property).
|
||
*/
|
||
const plainObjectArb = fc.dictionary(
|
||
fc
|
||
.string({ minLength: 1, maxLength: 10 })
|
||
.filter((s) => /^[a-z]+$/i.test(s)),
|
||
fc.oneof(
|
||
fc.integer(),
|
||
fc.string({ maxLength: 20 }),
|
||
fc.boolean(),
|
||
fc.constant(null)
|
||
),
|
||
{ minKeys: 0, maxKeys: 5 }
|
||
) as fc.Arbitrary<ConfigData>;
|
||
|
||
/**
|
||
* Valid dot-path: 1–4 alphanum segments.
|
||
*/
|
||
const validPathArb = fc
|
||
.array(
|
||
fc
|
||
.string({ minLength: 1, maxLength: 8 })
|
||
.filter((s) => /^[a-zA-Z0-9]+$/.test(s)),
|
||
{ minLength: 1, maxLength: 4 }
|
||
)
|
||
.map((parts) => parts.join("."));
|
||
|
||
describe("deepMerge associativity (no undefined values)", () => {
|
||
it("merge(merge(a,b),c) === merge(a,merge(b,c))", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
fc.assert(
|
||
fc.property(
|
||
plainObjectArb,
|
||
plainObjectArb,
|
||
plainObjectArb,
|
||
(a, b, c) => {
|
||
const left = deepMerge(deepMerge(a, b), c);
|
||
const right = deepMerge(a, deepMerge(b, c));
|
||
expect(left).toEqual(right);
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("mergeLayers priority ordering", () => {
|
||
it("result equals manually sorted-then-merged layers", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
const layerArb = fc
|
||
.record({
|
||
priority: fc.integer({ min: 0, max: 100 }),
|
||
data: plainObjectArb,
|
||
})
|
||
.map((l) => ({ name: `layer-${l.priority}`, ...l }));
|
||
|
||
fc.assert(
|
||
fc.property(
|
||
fc.array(layerArb, { minLength: 2, maxLength: 8 }),
|
||
(layers) => {
|
||
const resultFromMergeLayers = mergeLayers(
|
||
[...layers].sort((a, b) => a.priority - b.priority)
|
||
);
|
||
const sortedAndFolded = layers
|
||
.slice()
|
||
.sort((a, b) => a.priority - b.priority)
|
||
.reduce<ConfigData>(
|
||
(acc, l) => deepMerge(acc, l.data),
|
||
{}
|
||
);
|
||
expect(resultFromMergeLayers).toEqual(sortedAndFolded);
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("setByPath / getByPath round-trip", () => {
|
||
it("getByPath(setByPath({}, path, v), path) === v", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
const leafArb = fc.oneof(
|
||
fc.integer(),
|
||
fc.string({ maxLength: 20 }),
|
||
fc.boolean(),
|
||
fc.constant(null)
|
||
);
|
||
|
||
fc.assert(
|
||
fc.property(validPathArb, leafArb, (path, value) => {
|
||
const obj: ConfigData = {};
|
||
setByPath(obj, path, value);
|
||
expect(getByPath(obj, path)).toStrictEqual(value);
|
||
}),
|
||
{ numRuns: 300 }
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("maskSensitive idempotency", () => {
|
||
it("applying mask twice equals applying once", { timeout: 30000 }, () => {
|
||
const pathsArb = fc.array(validPathArb, { minLength: 0, maxLength: 4 });
|
||
|
||
fc.assert(
|
||
fc.property(plainObjectArb, pathsArb, (data, paths) => {
|
||
const once = maskSensitive(data, paths);
|
||
const twice = maskSensitive(once, paths);
|
||
expect(twice).toEqual(once);
|
||
}),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("coerceValue stability", () => {
|
||
it("coercing the string representation of a non-zero safe integer yields the same number", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
// "0" is in FALSY_STRINGS and maps to false, not 0 — excluded here.
|
||
// "1" is in TRUTHY_STRINGS and maps to true — also excluded.
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: 2, max: 1_000_000 }), (n) => {
|
||
const coerced = coerceValue(String(n));
|
||
expect(coerced).toBe(n);
|
||
}),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
|
||
it("coercing the string representation of a negative integer (not -1) yields the same number", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: -1_000_000, max: -2 }), (n) => {
|
||
const coerced = coerceValue(String(n));
|
||
expect(coerced).toBe(n);
|
||
}),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
|
||
it("coercing the string representation of a boolean yields the same boolean", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
fc.assert(
|
||
fc.property(fc.boolean(), (b) => {
|
||
const coerced = coerceValue(String(b));
|
||
expect(coerced).toBe(b);
|
||
}),
|
||
{ numRuns: 100 }
|
||
);
|
||
});
|
||
|
||
it("coercing JSON.stringify of an object yields a deeply equal object", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.dictionary(
|
||
fc
|
||
.string({ minLength: 1, maxLength: 8 })
|
||
.filter((s) => /^[a-z]+$/.test(s)),
|
||
fc.oneof(
|
||
fc.integer(),
|
||
fc.string({ maxLength: 10 }),
|
||
fc.boolean()
|
||
),
|
||
{ minKeys: 1, maxKeys: 4 }
|
||
),
|
||
(obj) => {
|
||
const coerced = coerceValue(JSON.stringify(obj));
|
||
expect(coerced).toEqual(obj);
|
||
}
|
||
),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("parseArgs completeness", () => {
|
||
it("every --key value pair appears in output when key is in mapping", {
|
||
timeout: 30000,
|
||
}, () => {
|
||
const keyArb = fc
|
||
.string({ minLength: 2, maxLength: 10 })
|
||
.filter((s) => /^[a-z][a-z0-9-]*$/.test(s));
|
||
const valueArb = fc
|
||
.string({ minLength: 1, maxLength: 20 })
|
||
.filter((s) => !s.startsWith("-"));
|
||
|
||
const pairsArb = fc.array(fc.tuple(keyArb, valueArb), {
|
||
minLength: 1,
|
||
maxLength: 6,
|
||
});
|
||
|
||
fc.assert(
|
||
fc.property(pairsArb, (pairs) => {
|
||
// Deduplicate keys (last wins in args, but first in mapping)
|
||
const seen = new Set<string>();
|
||
const unique: [string, string][] = [];
|
||
for (const [k, v] of pairs) {
|
||
if (!seen.has(k)) {
|
||
seen.add(k);
|
||
unique.push([k, v]);
|
||
}
|
||
}
|
||
|
||
const mapping: Record<string, string> = {};
|
||
for (const [k] of unique) {
|
||
mapping[k] = `config.${k}`;
|
||
}
|
||
|
||
const args: string[] = [];
|
||
for (const [k, v] of unique) {
|
||
args.push(`--${k}`, v);
|
||
}
|
||
|
||
const result = parseArgs(args, mapping);
|
||
|
||
for (const [k, v] of unique) {
|
||
expect(result[k]).toBe(v);
|
||
}
|
||
}),
|
||
{ numRuns: 200 }
|
||
);
|
||
});
|
||
});
|