From 631c6c4dc33321ebe27df3f88398a9ed8a6031b1 Mon Sep 17 00:00:00 2001 From: Ankit <177378174+anki1kr@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:24:13 +0530 Subject: [PATCH] fix(compression): send patch-only to PUT /api/settings/compression in CompressionHub (#6039) (#6077) Send patch-only to PUT /api/settings/compression in CompressionHub (#6039). Integrated into release/v3.8.44. --- .../context/combos/CompressionHub.tsx | 7 +- .../ui/CompressionHub-patch-only.test.tsx | 153 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/unit/ui/CompressionHub-patch-only.test.tsx diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx index 9ace6b31fc..736dbf05ed 100644 --- a/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionHub.tsx @@ -112,10 +112,15 @@ export default function CompressionHub() { setSettings(next); setError(null); try { + // Send only the changed fields (patch), not the full merged settings. + // The API schema is designed for partial updates; sending the full + // CompressionConfig round-trips fields unknown to the schema and causes + // a 400 strict-validation failure (e.g. contextBudget, pipeline engines + // added after the schema was written). CompressionPanel already does this. const res = await fetch("/api/settings/compression", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(next), + body: JSON.stringify(patch), }); if (!res.ok) { setSettings(settings); // revert diff --git a/tests/unit/ui/CompressionHub-patch-only.test.tsx b/tests/unit/ui/CompressionHub-patch-only.test.tsx new file mode 100644 index 0000000000..eaa0d2bf68 --- /dev/null +++ b/tests/unit/ui/CompressionHub-patch-only.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +/** + * Guards fix for issue #6039: + * CompressionHub was sending the full merged settings object to PUT + * /api/settings/compression instead of only the patch. The API schema uses + * .strict() Zod validation, so any field present in CompressionConfig but + * absent from compressionSettingsUpdateSchema (e.g. contextBudget, or + * stackedPipeline steps using engines not yet in the discriminated union) + * would cause a 400 validation failure — making switching to any non-default + * combo fail silently. + * + * Fix: send `patch` (the caller-supplied partial update) instead of `next` + * (the full optimistically-merged state). + */ + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +// Stub next/navigation (not used by CompressionHub but required by module graph) +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/", +})); + +// Track all fetch calls +const fetchCalls: { url: string; init: RequestInit }[] = []; +const mockFetch = vi.fn().mockImplementation((url: string, init: RequestInit) => { + fetchCalls.push({ url, init }); + return Promise.resolve({ + ok: true, + json: async () => ({ combos: [] }), + }); +}); +vi.stubGlobal("fetch", mockFetch); + +// Import after mocks are in place +import CompressionHub from "../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getLastPutBody(): Record | null { + const putCall = [...fetchCalls].reverse().find( + (c) => c.init?.method === "PUT" + ); + if (!putCall) return null; + return JSON.parse(putCall.init.body as string); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("CompressionHub — PUT sends patch only, not full settings", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + fetchCalls.length = 0; + mockFetch.mockClear(); + + // GET /api/settings/compression returns a config with extra fields that + // are NOT in compressionSettingsUpdateSchema (simulates contextBudget etc.) + mockFetch.mockImplementationOnce((_url: string) => + Promise.resolve({ + ok: true, + json: async () => ({ + enabled: true, + defaultMode: "lite", + activeComboId: null, + contextEditing: { enabled: false }, + // Field present in CompressionConfig but NOT in the update schema: + contextBudget: { mode: "floor", floorTokens: 4096 }, + // Engine step type not in stackedPipelineStepSchema discriminated union: + stackedPipeline: [ + { engine: "headroom", intensity: "standard" }, + { engine: "caveman", intensity: "lite" }, + ], + }), + }) + ); + + // GET /api/settings/compression/combos + mockFetch.mockImplementationOnce((_url: string) => + Promise.resolve({ + ok: true, + json: async () => ({ combos: [{ id: "c1", name: "My Combo", pipeline: [] }] }), + }) + ); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + document.body.removeChild(container); + }); + + it("sends only the changed field when activeComboId is updated", async () => { + await act(async () => { + root.render(); + }); + + // Find the combo selector and change it to "c1" + const select = container.querySelector("select"); + expect(select).not.toBeNull(); + + await act(async () => { + if (select) { + Object.defineProperty(select, "value", { writable: true, value: "c1" }); + select.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const body = getLastPutBody(); + expect(body).not.toBeNull(); + + // Must contain the changed field + expect(body).toHaveProperty("activeComboId", "c1"); + + // Must NOT contain fields from the full settings that would fail strict + // schema validation (contextBudget, stackedPipeline with unknown engines) + expect(body).not.toHaveProperty("contextBudget"); + expect(body).not.toHaveProperty("stackedPipeline"); + expect(body).not.toHaveProperty("enabled"); + expect(body).not.toHaveProperty("defaultMode"); + }); + + it("sends only the toggle field when contextEditing is toggled", async () => { + await act(async () => { + root.render(); + }); + + // Find the context editing toggle button + const toggleButtons = container.querySelectorAll("button[role='switch']"); + expect(toggleButtons.length).toBeGreaterThan(0); + + await act(async () => { + (toggleButtons[0] as HTMLButtonElement).click(); + }); + + const body = getLastPutBody(); + expect(body).not.toBeNull(); + + // Should only include contextEditing, not the full settings + expect(body).toHaveProperty("contextEditing"); + expect(body).not.toHaveProperty("contextBudget"); + expect(body).not.toHaveProperty("stackedPipeline"); + }); +});