Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit b5e2a87

Browse files
refactor(api-client): extract cloud task transport
Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22
1 parent ad560d6 commit b5e2a87

10 files changed

Lines changed: 1257 additions & 72 deletions

packages/api-client/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"src/**/*"
2626
],
2727
"dependencies": {
28-
"@posthog/agent": "workspace:*",
2928
"@posthog/shared": "workspace:*"
3029
}
3130
}

packages/api-client/src/fetcher.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,45 @@ describe("buildApiFetcher", () => {
5353
expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe(
5454
"Bearer my-token",
5555
);
56+
expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
57+
"posthog/desktop.hog.dev; version: test",
58+
);
59+
});
60+
61+
it("uses an injected fetch implementation and custom user agent", async () => {
62+
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
63+
const fetcher = buildApiFetcher({
64+
getAccessToken: vi.fn().mockResolvedValue("token"),
65+
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
66+
appVersion: "1.2.3",
67+
fetch: injectedFetch,
68+
userAgent: "posthog/mobile; version: 1.2.3",
69+
});
70+
71+
await fetcher.fetch(mockInput);
72+
73+
expect(injectedFetch).toHaveBeenCalledTimes(1);
74+
expect(mockFetch).not.toHaveBeenCalled();
75+
expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
76+
"posthog/mobile; version: 1.2.3",
77+
);
78+
});
79+
80+
it("omits the user agent when explicitly disabled", async () => {
81+
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
82+
const fetcher = buildApiFetcher({
83+
getAccessToken: vi.fn().mockResolvedValue("token"),
84+
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
85+
appVersion: "1.2.3",
86+
fetch: injectedFetch,
87+
userAgent: null,
88+
});
89+
90+
await fetcher.fetch(mockInput);
91+
92+
expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe(
93+
false,
94+
);
5695
});
5796

5897
it("retries once with a freshly fetched token on 401", async () => {

packages/api-client/src/fetcher.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import type { createApiClient } from "./generated";
22

3+
export type FetchImplementation = (
4+
input: string | URL | Request,
5+
init?: RequestInit,
6+
) => Promise<Response>;
7+
38
export type ApiFetcherConfig = {
49
getAccessToken: () => Promise<string>;
510
refreshAccessToken: () => Promise<string>;
611
appVersion: string;
12+
fetch?: FetchImplementation;
13+
userAgent?: string | null;
714
};
815

916
/**
@@ -13,11 +20,13 @@ export type ApiFetcherConfig = {
1320
*/
1421
export class ApiRequestError extends Error {
1522
readonly status: number;
23+
readonly body: unknown;
1624

17-
constructor(status: number, serializedBody: string) {
25+
constructor(status: number, serializedBody: string, body?: unknown) {
1826
super(`Failed request: [${status}] ${serializedBody}`);
1927
this.name = "ApiRequestError";
2028
this.status = status;
29+
this.body = body;
2130
}
2231
}
2332

@@ -29,7 +38,11 @@ export function requestErrorStatus(error: unknown): number | undefined {
2938
export const buildApiFetcher: (
3039
config: ApiFetcherConfig,
3140
) => Parameters<typeof createApiClient>[0] = (config) => {
32-
const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`;
41+
const fetchImpl = config.fetch ?? globalThis.fetch;
42+
const userAgent =
43+
config.userAgent === undefined
44+
? `posthog/desktop.hog.dev; version: ${config.appVersion}`
45+
: config.userAgent;
3346

3447
const makeRequest = async (
3548
input: Parameters<Parameters<typeof createApiClient>[0]["fetch"]>[0],
@@ -38,7 +51,9 @@ export const buildApiFetcher: (
3851
const headers = new Headers();
3952
headers.set("Authorization", `Bearer ${token}`);
4053
headers.set("Content-Type", "application/json");
41-
headers.set("User-Agent", userAgent);
54+
if (userAgent) {
55+
headers.set("User-Agent", userAgent);
56+
}
4257

4358
if (input.urlSearchParams) {
4459
input.url.search = input.urlSearchParams.toString();
@@ -59,7 +74,7 @@ export const buildApiFetcher: (
5974
}
6075

6176
try {
62-
const response = await fetch(input.url, {
77+
const response = await fetchImpl(input.url, {
6378
method: input.method.toUpperCase(),
6479
...(body && { body }),
6580
headers,
@@ -114,6 +129,7 @@ export const buildApiFetcher: (
114129
throw new ApiRequestError(
115130
response.status,
116131
JSON.stringify(errorResponse),
132+
errorResponse,
117133
);
118134
}
119135
}
@@ -128,6 +144,7 @@ export const buildApiFetcher: (
128144
throw new ApiRequestError(
129145
response.status,
130146
JSON.stringify(errorResponse),
147+
errorResponse,
131148
);
132149
}
133150

packages/api-client/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import "./generated.augment";
22

3-
export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher";
3+
export {
4+
type ApiFetcherConfig,
5+
buildApiFetcher,
6+
type FetchImplementation,
7+
} from "./fetcher";
48
export { createApiClient, type Schemas } from "./generated";
59
export {
610
createLoop,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
PostHogAPIClient,
4+
TaskAutomationValidationError,
5+
} from "./posthog-client";
6+
7+
const automationPayload = {
8+
id: "automation-1",
9+
name: "Daily PRs",
10+
prompt: "Check PRs",
11+
repository: "posthog/posthog",
12+
github_integration: 7,
13+
cron_expression: "0 9 * * *",
14+
timezone: "Europe/London",
15+
template_id: "llm-skill:daily-prs",
16+
enabled: true,
17+
last_run_at: null,
18+
last_run_status: null,
19+
last_task_id: null,
20+
last_task_run_id: null,
21+
last_error: null,
22+
created_at: "2026-07-21T00:00:00Z",
23+
updated_at: "2026-07-21T00:00:00Z",
24+
};
25+
26+
function jsonResponse(body: unknown, status = 200): Response {
27+
return new Response(JSON.stringify(body), {
28+
status,
29+
headers: { "Content-Type": "application/json" },
30+
});
31+
}
32+
33+
describe("PostHogAPIClient task automations", () => {
34+
const fetch = vi.fn();
35+
const client = new PostHogAPIClient(
36+
"https://app.posthog.test",
37+
async () => "access-token",
38+
async () => "refreshed-token",
39+
42,
40+
{ appVersion: "test", fetch },
41+
);
42+
43+
beforeEach(() => {
44+
fetch.mockReset();
45+
});
46+
47+
it("lists automations and normalizes optional response fields", async () => {
48+
const minimalPayload = {
49+
...automationPayload,
50+
github_integration: undefined,
51+
timezone: undefined,
52+
template_id: undefined,
53+
enabled: undefined,
54+
};
55+
fetch.mockResolvedValueOnce(
56+
jsonResponse({
57+
count: 1,
58+
next: null,
59+
previous: null,
60+
results: [minimalPayload],
61+
}),
62+
);
63+
64+
await expect(client.listTaskAutomations()).resolves.toEqual([
65+
expect.objectContaining({
66+
id: "automation-1",
67+
github_integration: null,
68+
timezone: null,
69+
template_id: null,
70+
enabled: true,
71+
}),
72+
]);
73+
expect(fetch).toHaveBeenCalledWith(
74+
new URL(
75+
"https://app.posthog.test/api/projects/42/task_automations/?limit=500",
76+
),
77+
expect.objectContaining({ method: "GET" }),
78+
);
79+
});
80+
81+
it("gets and creates automations through generated endpoints", async () => {
82+
fetch
83+
.mockResolvedValueOnce(jsonResponse(automationPayload))
84+
.mockResolvedValueOnce(jsonResponse(automationPayload, 201));
85+
86+
await expect(client.getTaskAutomation("automation-1")).resolves.toEqual(
87+
automationPayload,
88+
);
89+
await expect(
90+
client.createTaskAutomation({
91+
name: "Daily PRs",
92+
prompt: "Check PRs",
93+
repository: "posthog/posthog",
94+
github_integration: 7,
95+
cron_expression: "0 9 * * *",
96+
timezone: "Europe/London",
97+
template_id: "llm-skill:daily-prs",
98+
enabled: true,
99+
}),
100+
).resolves.toEqual(automationPayload);
101+
102+
expect(fetch).toHaveBeenNthCalledWith(
103+
2,
104+
new URL("https://app.posthog.test/api/projects/42/task_automations/"),
105+
expect.objectContaining({
106+
method: "POST",
107+
body: JSON.stringify({
108+
name: "Daily PRs",
109+
prompt: "Check PRs",
110+
repository: "posthog/posthog",
111+
github_integration: 7,
112+
cron_expression: "0 9 * * *",
113+
timezone: "Europe/London",
114+
template_id: "llm-skill:daily-prs",
115+
enabled: true,
116+
}),
117+
}),
118+
);
119+
});
120+
121+
it("updates, deletes, and runs automations", async () => {
122+
fetch
123+
.mockResolvedValueOnce(
124+
jsonResponse({ ...automationPayload, enabled: false }),
125+
)
126+
.mockResolvedValueOnce(new Response(null, { status: 204 }))
127+
.mockResolvedValueOnce(jsonResponse(automationPayload));
128+
129+
await expect(
130+
client.updateTaskAutomation("automation-1", { enabled: false }),
131+
).resolves.toMatchObject({ enabled: false });
132+
await expect(
133+
client.deleteTaskAutomation("automation-1"),
134+
).resolves.toBeUndefined();
135+
await expect(client.runTaskAutomation("automation-1")).resolves.toEqual(
136+
automationPayload,
137+
);
138+
139+
expect(fetch).toHaveBeenNthCalledWith(
140+
1,
141+
new URL(
142+
"https://app.posthog.test/api/projects/42/task_automations/automation-1/",
143+
),
144+
expect.objectContaining({
145+
method: "PATCH",
146+
body: JSON.stringify({ enabled: false }),
147+
}),
148+
);
149+
expect(fetch).toHaveBeenNthCalledWith(
150+
3,
151+
new URL(
152+
"https://app.posthog.test/api/projects/42/task_automations/automation-1/run/",
153+
),
154+
expect.objectContaining({ method: "POST" }),
155+
);
156+
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
157+
});
158+
159+
it("preserves validation detail, code, and field attribution", async () => {
160+
fetch.mockResolvedValueOnce(
161+
new Response(
162+
JSON.stringify({
163+
type: "validation_error",
164+
code: "invalid_input",
165+
detail: "Enter a valid cron expression.",
166+
attr: "cron_expression",
167+
}),
168+
{
169+
status: 400,
170+
statusText: "Bad Request",
171+
headers: { "Content-Type": "application/json" },
172+
},
173+
),
174+
);
175+
176+
const request = client.createTaskAutomation({
177+
name: "Daily PRs",
178+
prompt: "Check PRs",
179+
repository: "posthog/posthog",
180+
cron_expression: "not a cron",
181+
timezone: "Europe/London",
182+
});
183+
184+
await expect(request).rejects.toBeInstanceOf(TaskAutomationValidationError);
185+
await expect(request).rejects.toMatchObject({
186+
status: 400,
187+
code: "invalid_input",
188+
attr: "cron_expression",
189+
message: "Enter a valid cron expression.",
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)