Files
OmniRoute/tests/unit/partial-without-defaults.test.ts
Dizzle 1d03ba0ed2 fix(api): keep omitted fields on partial updates (#13582)
Partial updates keep the fields the client left out. Update/PATCH schemas that carried `.default()` re-applied those defaults under `.partial()`, so renaming a disabled reasoning-routing rule turned it back on, a playground preset lost its params and a proxy edit reset `family` to `auto`. The new `partialWithoutDefaults` helper strips defaults before `.partial()`; a scan test fails if any exported update schema ever leaks a default again.

Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.

Thanks @maxmad64bis!
2026-09-15 10:04:14 -03:00

91 lines
3.4 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
// zod 4 applies .default() even under .partial(): an update schema built that way hands the
// creation default back for every field the client omitted, and the merge that follows
// overwrites the stored value. These tests pin the helper and scan every exported update
// schema so a new one cannot bring the problem back.
const { partialWithoutDefaults } =
await import("../../src/shared/validation/partialWithoutDefaults.ts");
const source = z
.object({
name: z.string().min(1),
enabled: z.boolean().optional().default(true),
priority: z.number().default(0),
note: z.string().optional(),
})
.strict();
test("an omitted field stays absent instead of taking its creation default", () => {
assert.deepEqual(source.partial().parse({ name: "n" }), {
name: "n",
enabled: true,
priority: 0,
});
assert.deepEqual(partialWithoutDefaults(source).parse({ name: "n" }), { name: "n" });
assert.deepEqual(partialWithoutDefaults(source).parse({}), {});
});
test("a sent field is still validated and kept", () => {
const update = partialWithoutDefaults(source);
assert.deepEqual(update.parse({ enabled: false, priority: 3 }), { enabled: false, priority: 3 });
assert.equal(update.safeParse({ priority: "high" }).success, false);
assert.equal(update.safeParse({ name: "" }).success, false);
});
test("the unknown-key policy of the source object is kept", () => {
assert.equal(partialWithoutDefaults(source).safeParse({ typo: 1 }).success, false);
const loose = z.object({ a: z.string().default("x") });
assert.deepEqual(partialWithoutDefaults(loose).parse({ typo: 1 }), {});
});
test("the source schema keeps its defaults for creation", () => {
partialWithoutDefaults(source);
assert.deepEqual(source.parse({ name: "n" }), { name: "n", enabled: true, priority: 0 });
});
function hasDefault(field: unknown): boolean {
let current = field;
for (let depth = 0; depth < 4; depth++) {
if (current instanceof z.ZodDefault || current instanceof z.ZodPrefault) return true;
if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
current = current.unwrap();
} else {
return false;
}
}
return false;
}
function listTsFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return listTsFiles(full);
return entry.name.endsWith(".ts") ? [full] : [];
});
}
test("no exported update or patch schema re-applies a creation default", async () => {
const roots = ["src/shared/schemas", "src/shared/validation"].map((dir) => path.resolve(dir));
const offenders: string[] = [];
let scanned = 0;
for (const file of roots.flatMap(listTsFiles)) {
const mod = (await import(file)) as Record<string, unknown>;
for (const [name, schema] of Object.entries(mod)) {
if (!/update|patch/i.test(name) || !(schema instanceof z.ZodObject)) continue;
scanned++;
for (const [key, field] of Object.entries(schema.shape)) {
if (hasDefault(field))
offenders.push(`${path.relative(process.cwd(), file)} ${name}.${key}`);
}
}
}
assert.ok(scanned > 20, `expected to scan the update schemas, scanned ${scanned}`);
assert.deepEqual(offenders, []);
});