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); +});