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
This commit is contained in:
Nguyen Thanh Dat
2026-09-18 02:25:16 +07:00
committed by GitHub
parent 8074e3d596
commit 21d756d7f0
3 changed files with 109 additions and 0 deletions

View File

@@ -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))

View File

@@ -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 &&

View File

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