From 24865fcb43ec40143a379efcec2bd0d739b8776a Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 10:20:57 -0700 Subject: [PATCH 1/6] fix: wrap GET input params in SuperJSON { json } envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apiGet sent query params as a bare JSON object in ?input=, but the Dokploy server runs tRPC with the SuperJSON transformer, which deserializes a bare object to undefined — every parameterized GET command failed with HTTP 400 while POSTs and parameterless GETs worked. Send the input in SuperJSON's serialized shape ({ json: params }), the same envelope apiPost already uses for request bodies. Adds regression tests asserting the exact request URL with and without params. Fixes #1 --- src/client.ts | 5 +++- tests/client.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index dc31d14..f94300b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,8 +98,11 @@ export async function apiGet( params?: Record, ) { const client = createClient(); + // The server runs tRPC with the SuperJSON transformer, so GET input must + // be sent in SuperJSON's serialized shape ({ json: ... }); a bare object + // deserializes to undefined and every parameterized query fails with 400. const query = params - ? `?input=${encodeURIComponent(JSON.stringify(params))}` + ? `?input=${encodeURIComponent(JSON.stringify({ json: params }))}` : ""; const response = await client.get(`/trpc/${endpoint}${query}`); return response.data?.result?.data?.json ?? response.data; diff --git a/tests/client.test.ts b/tests/client.test.ts index 849213f..b4a8de7 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,5 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("axios", () => { + return { + default: { + create: () => ({ + get: () => Promise.resolve({ data: {} }), + post: () => Promise.resolve({ data: {} }), + }), + }, + }; +}); + describe("readAuthConfig", () => { const originalEnv = { ...process.env }; @@ -48,6 +59,52 @@ describe("readAuthConfig", () => { }); }); +describe("apiGet", () => { + it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { + process.env.DOKPLOY_URL = "https://test.dokploy.com"; + process.env.DOKPLOY_API_KEY = "test-key-123"; + + const urls: string[] = []; + const axiosMock = await import("axios"); + axiosMock.default.create = () => + ({ + get: async (url: string) => { + urls.push(url); + return { data: {} }; + }, + }) as never; + + const { apiGet } = await import("../src/client.js"); + await apiGet("compose.one", { composeId: "abc123", limit: 1 }); + + expect(urls[0]).toBe( + `/trpc/compose.one?input=${encodeURIComponent( + JSON.stringify({ json: { composeId: "abc123", limit: 1 } }), + )}`, + ); + }); + + it("should omit the input query string when no params are passed", async () => { + process.env.DOKPLOY_URL = "https://test.dokploy.com"; + process.env.DOKPLOY_API_KEY = "test-key-123"; + + const urls: string[] = []; + const axiosMock = await import("axios"); + axiosMock.default.create = () => + ({ + get: async (url: string) => { + urls.push(url); + return { data: {} }; + }, + }) as never; + + const { apiGet } = await import("../src/client.js"); + await apiGet("project.all"); + + expect(urls[0]).toBe("/trpc/project.all"); + }); +}); + describe("saveAuthConfig", () => { it("should write config with correct structure", async () => { const { saveAuthConfig } = await import("../src/client.js"); From 3521ad57ab7f51342c61610b6c6afba63e0dc963 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 10:31:49 -0700 Subject: [PATCH 2/6] test: cover apiGet with the empty opts object generated commands pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated parameterless commands always pass Commander's opts, which is {} once --json is stripped. The existing no-params test called apiGet with undefined — a path no generated command takes. Add a regression test for apiGet("project.all", {}) asserting the encoded URL, and keep the undefined-path test as documentation of the public contract. No production change: empty opts correctly takes the envelope branch, and no-input procedures ignore the deserialized {} on the server. --- tests/client.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/client.test.ts b/tests/client.test.ts index b4a8de7..faa1d9f 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -103,6 +103,30 @@ describe("apiGet", () => { expect(urls[0]).toBe("/trpc/project.all"); }); + + it("should encode the empty opts object generated parameterless commands pass", async () => { + process.env.DOKPLOY_URL = "https://test.dokploy.com"; + process.env.DOKPLOY_API_KEY = "test-key-123"; + + const urls: string[] = []; + const axiosMock = await import("axios"); + axiosMock.default.create = () => + ({ + get: async (url: string) => { + urls.push(url); + return { data: {} }; + }, + }) as never; + + const { apiGet } = await import("../src/client.js"); + // Generated commands always pass Commander's opts object, which is {} + // for parameterless commands once --json is stripped. + await apiGet("project.all", {}); + + expect(urls[0]).toBe( + `/trpc/project.all?input=${encodeURIComponent(JSON.stringify({ json: {} }))}`, + ); + }); }); describe("saveAuthConfig", () => { From e0302ef960f138de0f6c2a1c02ba05d03a6f8cb8 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 10:40:21 -0700 Subject: [PATCH 3/6] test: consolidate apiGet tests around the real generated call shapes Two tests now cover the two ways generated commands actually call apiGet: with params, and with the empty opts object Commander hands parameterless commands. The undefined-argument test covered a branch no generated caller takes, so it is dropped. Shared beforeEach replaces the per-test axios mock plumbing, and the client comment is trimmed to the durable contract (SuperJSON envelope required, bare object 400s). --- src/client.ts | 3 +-- tests/client.test.ts | 46 ++++++++------------------------------------ 2 files changed, 9 insertions(+), 40 deletions(-) diff --git a/src/client.ts b/src/client.ts index f94300b..1af9dba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,8 +99,7 @@ export async function apiGet( ) { const client = createClient(); // The server runs tRPC with the SuperJSON transformer, so GET input must - // be sent in SuperJSON's serialized shape ({ json: ... }); a bare object - // deserializes to undefined and every parameterized query fails with 400. + // use its serialized { json: ... } envelope; a bare object gets a 400. const query = params ? `?input=${encodeURIComponent(JSON.stringify({ json: params }))}` : ""; diff --git a/tests/client.test.ts b/tests/client.test.ts index faa1d9f..c3c6b7d 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -60,11 +60,12 @@ describe("readAuthConfig", () => { }); describe("apiGet", () => { - it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { + const urls: string[] = []; + + beforeEach(async () => { process.env.DOKPLOY_URL = "https://test.dokploy.com"; process.env.DOKPLOY_API_KEY = "test-key-123"; - - const urls: string[] = []; + urls.length = 0; const axiosMock = await import("axios"); axiosMock.default.create = () => ({ @@ -73,7 +74,9 @@ describe("apiGet", () => { return { data: {} }; }, }) as never; + }); + it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { const { apiGet } = await import("../src/client.js"); await apiGet("compose.one", { composeId: "abc123", limit: 1 }); @@ -84,43 +87,10 @@ describe("apiGet", () => { ); }); - it("should omit the input query string when no params are passed", async () => { - process.env.DOKPLOY_URL = "https://test.dokploy.com"; - process.env.DOKPLOY_API_KEY = "test-key-123"; - - const urls: string[] = []; - const axiosMock = await import("axios"); - axiosMock.default.create = () => - ({ - get: async (url: string) => { - urls.push(url); - return { data: {} }; - }, - }) as never; - - const { apiGet } = await import("../src/client.js"); - await apiGet("project.all"); - - expect(urls[0]).toBe("/trpc/project.all"); - }); - it("should encode the empty opts object generated parameterless commands pass", async () => { - process.env.DOKPLOY_URL = "https://test.dokploy.com"; - process.env.DOKPLOY_API_KEY = "test-key-123"; - - const urls: string[] = []; - const axiosMock = await import("axios"); - axiosMock.default.create = () => - ({ - get: async (url: string) => { - urls.push(url); - return { data: {} }; - }, - }) as never; - const { apiGet } = await import("../src/client.js"); - // Generated commands always pass Commander's opts object, which is {} - // for parameterless commands once --json is stripped. + // Generated commands always pass Commander's opts, which is {} for + // parameterless commands once --json is stripped. await apiGet("project.all", {}); expect(urls[0]).toBe( From 1d65db1665fb4ae1f74646281981a111ebca262d Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 10:43:33 -0700 Subject: [PATCH 4/6] test: simplify apiGet tests with a hoisted GET spy Replace per-test env setup, dynamic axios imports, create overrides, URL arrays, closures, and as-never casts with one vi.hoisted vi.fn GET spy in the axios mock, shared describe setup/reset, and direct toHaveBeenCalledWith assertions. The three cases are the apiGet input contract: nonempty params, undefined params, and an empty params object. Trim the client comment to the single durable invariant. --- src/client.ts | 3 +-- tests/client.test.ts | 33 +++++++++++++++------------------ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1af9dba..4482da5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -98,8 +98,7 @@ export async function apiGet( params?: Record, ) { const client = createClient(); - // The server runs tRPC with the SuperJSON transformer, so GET input must - // use its serialized { json: ... } envelope; a bare object gets a 400. + // tRPC SuperJSON input uses the { json: ... } envelope. const query = params ? `?input=${encodeURIComponent(JSON.stringify({ json: params }))}` : ""; diff --git a/tests/client.test.ts b/tests/client.test.ts index c3c6b7d..b5da610 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const getSpy = vi.hoisted(() => vi.fn()); + vi.mock("axios", () => { return { default: { create: () => ({ - get: () => Promise.resolve({ data: {} }), + get: getSpy, post: () => Promise.resolve({ data: {} }), }), }, @@ -60,40 +62,35 @@ describe("readAuthConfig", () => { }); describe("apiGet", () => { - const urls: string[] = []; - - beforeEach(async () => { + beforeEach(() => { process.env.DOKPLOY_URL = "https://test.dokploy.com"; process.env.DOKPLOY_API_KEY = "test-key-123"; - urls.length = 0; - const axiosMock = await import("axios"); - axiosMock.default.create = () => - ({ - get: async (url: string) => { - urls.push(url); - return { data: {} }; - }, - }) as never; + getSpy.mockReset().mockResolvedValue({ data: {} }); }); it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { const { apiGet } = await import("../src/client.js"); await apiGet("compose.one", { composeId: "abc123", limit: 1 }); - expect(urls[0]).toBe( + expect(getSpy).toHaveBeenCalledWith( `/trpc/compose.one?input=${encodeURIComponent( JSON.stringify({ json: { composeId: "abc123", limit: 1 } }), )}`, ); }); - it("should encode the empty opts object generated parameterless commands pass", async () => { + it("should omit the input query string when params are undefined", async () => { + const { apiGet } = await import("../src/client.js"); + await apiGet("project.all"); + + expect(getSpy).toHaveBeenCalledWith("/trpc/project.all"); + }); + + it("should send an empty { json: {} } envelope when params are an empty object", async () => { const { apiGet } = await import("../src/client.js"); - // Generated commands always pass Commander's opts, which is {} for - // parameterless commands once --json is stripped. await apiGet("project.all", {}); - expect(urls[0]).toBe( + expect(getSpy).toHaveBeenCalledWith( `/trpc/project.all?input=${encodeURIComponent(JSON.stringify({ json: {} }))}`, ); }); From c8b5df97a594fe65d6ca39498a2cb76b58fbc252 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 11:00:19 -0700 Subject: [PATCH 5/6] test: import apiGet statically and drop the unused post stub --- tests/client.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/client.test.ts b/tests/client.test.ts index b5da610..fcbae2d 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiGet } from "../src/client.js"; const getSpy = vi.hoisted(() => vi.fn()); @@ -7,7 +8,6 @@ vi.mock("axios", () => { default: { create: () => ({ get: getSpy, - post: () => Promise.resolve({ data: {} }), }), }, }; @@ -69,7 +69,6 @@ describe("apiGet", () => { }); it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { - const { apiGet } = await import("../src/client.js"); await apiGet("compose.one", { composeId: "abc123", limit: 1 }); expect(getSpy).toHaveBeenCalledWith( @@ -80,14 +79,12 @@ describe("apiGet", () => { }); it("should omit the input query string when params are undefined", async () => { - const { apiGet } = await import("../src/client.js"); await apiGet("project.all"); expect(getSpy).toHaveBeenCalledWith("/trpc/project.all"); }); it("should send an empty { json: {} } envelope when params are an empty object", async () => { - const { apiGet } = await import("../src/client.js"); await apiGet("project.all", {}); expect(getSpy).toHaveBeenCalledWith( From 04076add3790cae094dfafa32b5cf75ceba82443 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 21 Aug 2026 11:03:39 -0700 Subject: [PATCH 6/6] test: restore env after apiGet suite to prevent cross-suite leakage --- tests/client.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/client.test.ts b/tests/client.test.ts index fcbae2d..3fbcfba 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -62,12 +62,18 @@ describe("readAuthConfig", () => { }); describe("apiGet", () => { + const originalEnv = { ...process.env }; + beforeEach(() => { process.env.DOKPLOY_URL = "https://test.dokploy.com"; process.env.DOKPLOY_API_KEY = "test-key-123"; getSpy.mockReset().mockResolvedValue({ data: {} }); }); + afterEach(() => { + process.env = { ...originalEnv }; + }); + it("should wrap GET params in the SuperJSON { json: ... } input envelope", async () => { await apiGet("compose.one", { composeId: "abc123", limit: 1 });