From 21d756d7f08aae5c6e795069ddd2d69ebbffe961 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Fri, 18 Sep 2026 02:25:16 +0700 Subject: [PATCH] fix(combos): accept isHidden in updateComboSchema (#12898) * fix(combos): accept isHidden in updateComboSchema A combo's visibility is stored on the record and honoured by the builder option list and the dashboard grid, but updateComboSchema never listed isHidden. The PUT handler spreads the validated body, so zod stripped the field: a visibility-only update was rejected as "No valid fields to update", and a mixed update succeeded while dropping the visibility change. Closes #12836 * docs(changelog): fragment for #12898 --- .../fixes/12898-combo-hidden-update-schema.md | 1 + src/shared/validation/schemas/combo.ts | 6 ++ tests/unit/combo-hidden-update-12836.test.ts | 102 ++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 changelog.d/fixes/12898-combo-hidden-update-schema.md create mode 100644 tests/unit/combo-hidden-update-12836.test.ts diff --git a/changelog.d/fixes/12898-combo-hidden-update-schema.md b/changelog.d/fixes/12898-combo-hidden-update-schema.md new file mode 100644 index 0000000000..190a86682c --- /dev/null +++ b/changelog.d/fixes/12898-combo-hidden-update-schema.md @@ -0,0 +1 @@ +- **fix(combos):** A combo's visibility can be changed through the API again: `updateComboSchema` accepts `isHidden`, so a visibility-only update is no longer rejected as empty and a mixed update no longer drops it ([#12898](https://github.com/diegosouzapw/OmniRoute/pull/12898), closes [#12836](https://github.com/diegosouzapw/OmniRoute/issues/12836)) diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index a9349dfd61..1cd3e28377 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -419,6 +419,11 @@ export const updateComboSchema = z strategy: comboStrategySchema.optional(), config: comboRuntimeConfigSchema.optional(), isActive: z.boolean().optional(), + // Stored on the combo record and honoured by the readers — the builder's + // option list and the dashboard grid both filter on it — but omitted here, + // so the one endpoint a client can flip it through stripped the field and + // a visibility-only update was rejected as empty. #12836 + isHidden: z.boolean().optional(), allowedProviders: z.array(z.string().trim().min(1).max(200)).max(100).optional(), allowedModelFamilies: z.array(z.string().trim().min(1).max(100)).max(100).optional(), // Nullable like `description` and `context_length` above: an absent field means @@ -443,6 +448,7 @@ export const updateComboSchema = z value.strategy === undefined && value.config === undefined && value.isActive === undefined && + value.isHidden === undefined && value.allowedProviders === undefined && value.allowedModelFamilies === undefined && value.system_message === undefined && diff --git a/tests/unit/combo-hidden-update-12836.test.ts b/tests/unit/combo-hidden-update-12836.test.ts new file mode 100644 index 0000000000..4e39cd6103 --- /dev/null +++ b/tests/unit/combo-hidden-update-12836.test.ts @@ -0,0 +1,102 @@ +/** + * #12836 — a combo's visibility must be updatable through the API. + * + * `isHidden` is stored on the combo record and honoured by its readers: the + * builder's option list drops hidden combos (`src/lib/combos/builderOptions.ts`) + * and so does the dashboard grid. `createCombo` writes the field explicitly. + * The only endpoint a client can flip it through is `PUT /api/combos/[id]`, + * which spreads the *validated* body into the update — and `updateComboSchema` + * never listed `isHidden`, so zod stripped it. A visibility-only update was + * rejected as "No valid fields to update", and pairing it with a recognized + * field made the request succeed while silently dropping the visibility change. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-hidden-12836-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { updateComboSchema } = await import("../../src/shared/validation/schemas.ts"); +const core = await import("../../src/lib/db/core.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +async function resetStorage() { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#12836 a visibility-only update is a valid update", () => { + assert.equal(updateComboSchema.parse({ isHidden: true }).isHidden, true); + assert.equal(updateComboSchema.parse({ isHidden: false }).isHidden, false); + // The empty-update refinement still fires for a genuinely empty body. + assert.throws(() => updateComboSchema.parse({}), /No valid fields to update/); +}); + +test("#12836 visibility survives a mixed update instead of being stripped", () => { + const parsed = updateComboSchema.parse({ description: "fixture", isHidden: true }); + assert.equal(parsed.description, "fixture"); + assert.equal(parsed.isHidden, true); +}); + +test("#12836 only a boolean is accepted", () => { + for (const bad of ["true", 1, null, {}]) { + assert.throws(() => updateComboSchema.parse({ isHidden: bad })); + } +}); + +test("#12836 hiding a combo persists and reads back", async () => { + const created = await combosDb.createCombo({ + name: "Visibility Combo", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + assert.equal(created.isHidden, false); + + const body = updateComboSchema.parse({ isHidden: true }); + const updated = await combosDb.updateCombo(created.id as string, body); + assert.ok(updated); + assert.equal(updated!.isHidden, true); + + // Re-read: this is the state the builder and the dashboard grid filter on. + const reread = await combosDb.getComboById(created.id as string); + assert.ok(reread); + assert.equal(reread!.isHidden, true); +}); + +test("#12836 unhiding it again works, and an unrelated update leaves it alone", async () => { + const created = await combosDb.createCombo({ + name: "Round Trip Combo", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + + await combosDb.updateCombo(created.id as string, updateComboSchema.parse({ isHidden: true })); + const shown = await combosDb.updateCombo( + created.id as string, + updateComboSchema.parse({ isHidden: false }) + ); + assert.ok(shown); + assert.equal(shown!.isHidden, false); + + await combosDb.updateCombo(created.id as string, updateComboSchema.parse({ isHidden: true })); + const untouched = await combosDb.updateCombo( + created.id as string, + updateComboSchema.parse({ description: "note" }) + ); + assert.ok(untouched); + assert.equal(untouched!.description, "note"); + assert.equal(untouched!.isHidden, true); +});