Initial Commit
This commit is contained in:
154
packages/foundation/core/config/tests/integration.test.ts
Normal file
154
packages/foundation/core/config/tests/integration.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createConfig } from "../src/core/config-builder/config-builder.js";
|
||||
import { ConfigError } from "../src/error-codes/error-codes.js";
|
||||
import { cliLayer } from "../src/layers/cli-layer/cli-layer.js";
|
||||
import { defaultsLayer } from "../src/layers/defaults-layer/defaults-layer.js";
|
||||
import { envLayer } from "../src/layers/env-layer/env-layer.js";
|
||||
import { fileLayer } from "../src/layers/file-layer/file-layer.js";
|
||||
import { objectLayer } from "../src/layers/object-layer/object-layer.js";
|
||||
|
||||
describe("createConfig integration", () => {
|
||||
it("full 5-layer pipeline produces correct merged config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({
|
||||
server: { port: 3000, host: "0.0.0.0" },
|
||||
db: { pool: 5, timeout: 30_000, password: "defaultpw" },
|
||||
log: { level: "info", pretty: true },
|
||||
}),
|
||||
fileLayer({
|
||||
content: {
|
||||
server: { port: 3100 },
|
||||
db: { pool: 10 },
|
||||
},
|
||||
}),
|
||||
objectLayer({
|
||||
server: { port: 3200 },
|
||||
}),
|
||||
envLayer({
|
||||
mapping: { PORT: "server.port", DB_POOL: "db.pool" },
|
||||
source: { PORT: "3300", DB_POOL: "15" },
|
||||
}),
|
||||
cliLayer({
|
||||
args: ["--port=3400", "--debug"],
|
||||
mapping: { port: "server.port", debug: "log.debug" },
|
||||
}),
|
||||
],
|
||||
sensitive: ["db.password"],
|
||||
});
|
||||
|
||||
// CLI (priority 40) wins for port
|
||||
expect(store.get("server.port")).toBe(3400);
|
||||
// ENV (priority 30) wins for db.pool over file/object/defaults
|
||||
expect(store.get("db.pool")).toBe(15);
|
||||
// CLI added debug flag
|
||||
expect(store.get("log.debug")).toBe(true);
|
||||
// Defaults survive for host
|
||||
expect(store.get("server.host")).toBe("0.0.0.0");
|
||||
// File layer contributed timeout (no higher layer overrode it)
|
||||
expect(store.get("db.timeout")).toBe(30_000);
|
||||
|
||||
// sensitive masking
|
||||
const masked = store.toMasked() as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(masked.db.password).toBe("****");
|
||||
expect(store.get("db.password")).toBe("defaultpw");
|
||||
});
|
||||
|
||||
it("has() returns true/false correctly", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
});
|
||||
expect(store.has("port")).toBe(true);
|
||||
expect(store.has("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("toJSON returns full frozen config", { timeout: 5000 }, () => {
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ port: 3000 })],
|
||||
});
|
||||
const json = store.toJSON();
|
||||
expect(json).toEqual({ port: 3000 });
|
||||
expect(() => {
|
||||
(json as Record<string, unknown>).port = 9999;
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("reload with validation throws when validator rejects", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let pass = true;
|
||||
const dynamic = {
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ port: pass ? 3000 : "bad" }),
|
||||
};
|
||||
const store = createConfig({
|
||||
layers: [dynamic],
|
||||
validate: (d) => {
|
||||
if (typeof (d as Record<string, unknown>).port !== "number") {
|
||||
throw new Error("port must be number");
|
||||
}
|
||||
return d;
|
||||
},
|
||||
});
|
||||
|
||||
pass = false;
|
||||
await expect(store.reload()).rejects.toBeInstanceOf(ConfigError);
|
||||
});
|
||||
|
||||
it("watch-mode: fileLayer with EventBus triggers reload", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
type BusMap = { "config:file:changed": Record<string, never> };
|
||||
const bus = new EventBus<BusMap>();
|
||||
|
||||
let fileContent = { port: 3000 };
|
||||
const fLayer = fileLayer({
|
||||
get content() {
|
||||
return fileContent;
|
||||
},
|
||||
watchBus: bus,
|
||||
});
|
||||
|
||||
const store = createConfig({
|
||||
layers: [fLayer],
|
||||
});
|
||||
|
||||
expect(store.get("port")).toBe(3000);
|
||||
|
||||
// Simulate file change
|
||||
fileContent = { port: 4000 };
|
||||
await bus.emit("config:file:changed", {});
|
||||
|
||||
// Give the async reload a tick to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(store.get("port")).toBe(4000);
|
||||
});
|
||||
|
||||
it("environment override applies on reload", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let counter = 0;
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
{
|
||||
name: "dynamic",
|
||||
priority: 0,
|
||||
resolve: () => ({ count: ++counter, debug: false }),
|
||||
},
|
||||
],
|
||||
environments: { test: { debug: true } },
|
||||
environment: "test",
|
||||
});
|
||||
|
||||
expect(store.get("debug")).toBe(true);
|
||||
await store.reload();
|
||||
expect(store.get("debug")).toBe(true);
|
||||
});
|
||||
});
|
||||
240
packages/foundation/core/config/tests/property.test.ts
Normal file
240
packages/foundation/core/config/tests/property.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
8
packages/foundation/core/config/tests/setup/polyfills.ts
Normal file
8
packages/foundation/core/config/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user