generated from nholtware/service-template-express
Initial commit
This commit is contained in:
commit
a0c3aa640c
36 changed files with 7676 additions and 0 deletions
176
src/lib/runtime-config.test.ts
Normal file
176
src/lib/runtime-config.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../app.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import { createRuntimeConfig, type RuntimeConfig } from "./runtime-config.js";
|
||||
|
||||
type Line = { level: number; msg: string; [k: string]: unknown };
|
||||
|
||||
function capture() {
|
||||
const lines: Line[] = [];
|
||||
const logger = pino({}, { write: (l: string) => lines.push(JSON.parse(l)) });
|
||||
return { lines, logger };
|
||||
}
|
||||
|
||||
const waitFor = async (pred: () => boolean, ms = 3000) => {
|
||||
const end = Date.now() + ms;
|
||||
while (!pred()) {
|
||||
if (Date.now() > end) throw new Error("timed out waiting for condition");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
};
|
||||
|
||||
describe("runtime config", () => {
|
||||
let dir: string;
|
||||
let path: string;
|
||||
let rc: RuntimeConfig | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "rc-"));
|
||||
path = join(dir, "runtime.yaml");
|
||||
});
|
||||
afterEach(async () => {
|
||||
rc?.close();
|
||||
rc = undefined;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("no path: defaults, source defaults", async () => {
|
||||
const { logger } = capture();
|
||||
rc = await createRuntimeConfig({ logger });
|
||||
expect(rc.get()).toEqual({
|
||||
greeting: "hello",
|
||||
maxItems: 50,
|
||||
featureHints: [],
|
||||
});
|
||||
expect(rc.snapshot().source).toBe("defaults");
|
||||
});
|
||||
|
||||
it("missing file: defaults and warns once", async () => {
|
||||
const { lines, logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
expect(rc.snapshot().source).toBe("defaults");
|
||||
expect(rc.get().greeting).toBe("hello");
|
||||
expect(lines.filter((l) => /missing/.test(l.msg))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("loads the file, fills defaults for absent keys", async () => {
|
||||
await writeFile(path, "greeting: hi\n");
|
||||
const { logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
expect(rc.snapshot().source).toBe("file");
|
||||
expect(rc.get()).toEqual({
|
||||
greeting: "hi",
|
||||
maxItems: 50,
|
||||
featureHints: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads on change, notifies onChange with changed keys, logs info", async () => {
|
||||
await writeFile(path, "greeting: one\nmaxItems: 5\n");
|
||||
const { lines, logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
const seen: string[][] = [];
|
||||
rc.onChange((_v, changed) => seen.push(changed));
|
||||
|
||||
await writeFile(path, "greeting: two\nmaxItems: 5\nfeatureHints: [a, b]\n");
|
||||
await waitFor(() => rc!.get().greeting === "two");
|
||||
|
||||
expect(rc.get()).toEqual({
|
||||
greeting: "two",
|
||||
maxItems: 5,
|
||||
featureHints: ["a", "b"],
|
||||
});
|
||||
expect(seen.at(-1)?.sort()).toEqual(["featureHints", "greeting"]);
|
||||
const reloaded = lines.find((l) => l.msg === "runtime config reloaded");
|
||||
expect(reloaded).toMatchObject({
|
||||
level: 30,
|
||||
changedKeys: expect.arrayContaining(["greeting"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("bad YAML keeps last good values and logs error", async () => {
|
||||
await writeFile(path, "greeting: good\n");
|
||||
const { lines, logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
|
||||
await writeFile(path, "greeting: [unclosed\n");
|
||||
await waitFor(() => lines.some((l) => l.level === 50));
|
||||
|
||||
expect(rc.get().greeting).toBe("good");
|
||||
expect(lines.find((l) => l.level === 50)?.error).toMatch(/invalid YAML/);
|
||||
});
|
||||
|
||||
it("schema violation keeps last good values and logs zod issues", async () => {
|
||||
await writeFile(path, "maxItems: 10\n");
|
||||
const { lines, logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
|
||||
await writeFile(path, 'maxItems: "lots"\n');
|
||||
await waitFor(() => lines.some((l) => l.level === 50));
|
||||
|
||||
expect(rc.get().maxItems).toBe(10);
|
||||
const err = lines.find((l) => l.level === 50)!;
|
||||
expect(err.msg).toMatch(/keeping last good/);
|
||||
expect(JSON.stringify(err.issues)).toMatch(/maxItems/);
|
||||
|
||||
// and recovers on the next good commit
|
||||
await writeFile(path, "maxItems: 20\n");
|
||||
await waitFor(() => rc!.get().maxItems === 20);
|
||||
});
|
||||
|
||||
it("sees a kubelet-style ..data symlink swap", async () => {
|
||||
// Mimic a ConfigMap volume: runtime.yaml -> ..data/runtime.yaml,
|
||||
// ..data -> ..v1; an update writes ..v2 and atomically renames ..data.
|
||||
await mkdir(join(dir, "..v1"));
|
||||
await writeFile(join(dir, "..v1", "runtime.yaml"), "greeting: v1\n");
|
||||
await symlink("..v1", join(dir, "..data"));
|
||||
await symlink("..data/runtime.yaml", path);
|
||||
const { logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
expect(rc.get().greeting).toBe("v1");
|
||||
|
||||
await mkdir(join(dir, "..v2"));
|
||||
await writeFile(join(dir, "..v2", "runtime.yaml"), "greeting: v2\n");
|
||||
await symlink("..v2", join(dir, "..data_tmp"));
|
||||
await rename(join(dir, "..data_tmp"), join(dir, "..data"));
|
||||
await rm(join(dir, "..v1"), { recursive: true });
|
||||
|
||||
await waitFor(() => rc!.get().greeting === "v2");
|
||||
});
|
||||
|
||||
it("GET / uses greeting; GET /admin/runtime-config serves the snapshot", async () => {
|
||||
await writeFile(path, "greeting: howdy\n");
|
||||
const { logger } = capture();
|
||||
rc = await createRuntimeConfig({ path, logger, debounceMs: 10 });
|
||||
const app = createApp({
|
||||
config: parseConfig({ SERVICE_NAME: "test-svc" }),
|
||||
logger: pino({ level: "silent" }),
|
||||
runtimeConfig: rc,
|
||||
});
|
||||
|
||||
expect((await request(app).get("/")).body.greeting).toBe("howdy");
|
||||
const admin = await request(app).get("/admin/runtime-config");
|
||||
expect(admin.status).toBe(200);
|
||||
expect(admin.body).toMatchObject({
|
||||
source: "file",
|
||||
values: { greeting: "howdy", maxItems: 50, featureHints: [] },
|
||||
});
|
||||
expect(Date.parse(admin.body.loadedAt)).not.toBeNaN();
|
||||
|
||||
await writeFile(path, "greeting: changed\n");
|
||||
await waitFor(() => rc!.get().greeting === "changed");
|
||||
expect((await request(app).get("/")).body.greeting).toBe("changed");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue