Skip to content

Commit 556620d

Browse files
committed
add loop form validation tests
1 parent 618aebf commit 556620d

1 file changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import type { LoopSchemas } from "@posthog/api-client/loops";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
defaultLoopBehaviors,
5+
emptyLoopFormValues,
6+
formValuesToLoopWrite,
7+
isAutoFixEnabled,
8+
isLoopFormValid,
9+
isTriggerDraftValid,
10+
type LoopFormValues,
11+
type LoopTriggerDraft,
12+
loopToFormValues,
13+
normalizeLoopFormValues,
14+
withAutoFix,
15+
} from "./loopFormTypes";
16+
17+
function scheduleTrigger(
18+
config: LoopSchemas.LoopScheduleTriggerConfig,
19+
): LoopTriggerDraft {
20+
return { key: "k1", type: "schedule", enabled: true, config };
21+
}
22+
23+
function githubTrigger(
24+
config: Partial<LoopSchemas.LoopGithubTriggerConfig> = {},
25+
): LoopTriggerDraft {
26+
return {
27+
key: "k2",
28+
type: "github",
29+
enabled: true,
30+
config: {
31+
github_integration_id: 7,
32+
repository: "posthog/posthog",
33+
events: ["push"],
34+
...config,
35+
},
36+
};
37+
}
38+
39+
function validFormValues(): LoopFormValues {
40+
return {
41+
...emptyLoopFormValues(),
42+
name: "Standup digest",
43+
instructions: "Summarize the day.",
44+
};
45+
}
46+
47+
describe("isTriggerDraftValid", () => {
48+
it.each([
49+
{
50+
name: "schedule with a cron",
51+
trigger: scheduleTrigger({ cron_expression: "0 9 * * *" }),
52+
expected: true,
53+
},
54+
{
55+
name: "schedule with a run_at",
56+
trigger: scheduleTrigger({ run_at: "2026-07-16T10:00:00Z" }),
57+
expected: true,
58+
},
59+
{
60+
name: "schedule with neither cron nor run_at",
61+
trigger: scheduleTrigger({}),
62+
expected: false,
63+
},
64+
{
65+
name: "complete github trigger",
66+
trigger: githubTrigger(),
67+
expected: true,
68+
},
69+
{
70+
name: "github without repository",
71+
trigger: githubTrigger({ repository: "" }),
72+
expected: false,
73+
},
74+
{
75+
name: "github without integration id",
76+
trigger: githubTrigger({ github_integration_id: 0 }),
77+
expected: false,
78+
},
79+
{
80+
name: "github without events",
81+
trigger: githubTrigger({ events: [] }),
82+
expected: false,
83+
},
84+
{
85+
name: "api trigger",
86+
trigger: {
87+
key: "k3",
88+
type: "api",
89+
enabled: true,
90+
config: {},
91+
} as LoopTriggerDraft,
92+
expected: true,
93+
},
94+
])("$name → $expected", ({ trigger, expected }) => {
95+
expect(isTriggerDraftValid(trigger)).toBe(expected);
96+
});
97+
});
98+
99+
describe("isLoopFormValid", () => {
100+
it("accepts a named form with instructions and no triggers", () => {
101+
expect(isLoopFormValid(validFormValues())).toBe(true);
102+
});
103+
104+
it.each([
105+
{ name: "blank name", patch: { name: " " } },
106+
{ name: "blank instructions", patch: { instructions: "\n" } },
107+
{
108+
name: "context target on a personal loop",
109+
patch: {
110+
contextTarget: {
111+
folderId: "f1",
112+
name: "growth",
113+
outputs: {
114+
post_to_feed: true,
115+
update_context: false,
116+
canvas_id: null,
117+
},
118+
},
119+
visibility: "personal" as const,
120+
},
121+
},
122+
{
123+
name: "an invalid trigger",
124+
patch: { triggers: [scheduleTrigger({})] },
125+
},
126+
])("rejects $name", ({ patch }) => {
127+
expect(isLoopFormValid({ ...validFormValues(), ...patch })).toBe(false);
128+
});
129+
});
130+
131+
describe("normalizeLoopFormValues", () => {
132+
it("forces team visibility when a context target is set", () => {
133+
const values = {
134+
...validFormValues(),
135+
visibility: "personal" as const,
136+
contextTarget: {
137+
folderId: "f1",
138+
name: "growth",
139+
outputs: { post_to_feed: true, update_context: false, canvas_id: null },
140+
},
141+
};
142+
expect(normalizeLoopFormValues(values).visibility).toBe("team");
143+
});
144+
145+
it("leaves unattached loops untouched", () => {
146+
const values = validFormValues();
147+
expect(normalizeLoopFormValues(values)).toBe(values);
148+
});
149+
});
150+
151+
describe("formValuesToLoopWrite", () => {
152+
it("trims name, description and model", () => {
153+
const write = formValuesToLoopWrite({
154+
...validFormValues(),
155+
name: " Digest ",
156+
description: " daily ",
157+
model: " claude-sonnet-5 ",
158+
});
159+
expect(write.name).toBe("Digest");
160+
expect(write.description).toBe("daily");
161+
expect(write.model).toBe("claude-sonnet-5");
162+
});
163+
164+
it("maps a context target to snake_case and null when detached", () => {
165+
const attached = formValuesToLoopWrite({
166+
...validFormValues(),
167+
contextTarget: {
168+
folderId: "f1",
169+
name: "growth",
170+
outputs: { post_to_feed: true, update_context: false, canvas_id: null },
171+
},
172+
});
173+
expect(attached.context_target).toEqual({
174+
folder_id: "f1",
175+
name: "growth",
176+
outputs: { post_to_feed: true, update_context: false, canvas_id: null },
177+
});
178+
expect(formValuesToLoopWrite(validFormValues()).context_target).toBeNull();
179+
});
180+
181+
it("carries trigger ids through so the backend updates in place", () => {
182+
const write = formValuesToLoopWrite({
183+
...validFormValues(),
184+
triggers: [
185+
{ ...githubTrigger(), id: "trigger-1" },
186+
scheduleTrigger({ cron_expression: "0 9 * * *" }),
187+
],
188+
});
189+
expect(write.triggers?.map((t) => t.id)).toEqual(["trigger-1", undefined]);
190+
});
191+
});
192+
193+
describe("loopToFormValues round trip", () => {
194+
it("maps a loop back into form values that write the same shape", () => {
195+
const loop = {
196+
id: "loop-1",
197+
team_id: 1,
198+
created_by_id: 1,
199+
name: "Digest",
200+
description: "daily",
201+
visibility: "team",
202+
instructions: "Summarize.",
203+
runtime_adapter: "claude",
204+
model: "claude-sonnet-5",
205+
reasoning_effort: "medium",
206+
repositories: [
207+
{ github_integration_id: 7, full_name: "posthog/posthog" },
208+
],
209+
sandbox_environment_id: null,
210+
enabled: true,
211+
disabled_reason: null,
212+
overlap_policy: "skip",
213+
behaviors: defaultLoopBehaviors(),
214+
connectors: { mcp_installation_ids: [], posthog_mcp_scopes: "read_only" },
215+
notifications: {
216+
push: { enabled: false, events: [], params: {} },
217+
email: { enabled: false, events: [], params: {} },
218+
slack: { enabled: false, events: [], params: {} },
219+
},
220+
context_target: {
221+
folder_id: "f1",
222+
name: "growth",
223+
outputs: { post_to_feed: true, update_context: false, canvas_id: null },
224+
},
225+
internal: false,
226+
origin_product: "user_created",
227+
last_run_at: null,
228+
last_run_status: null,
229+
last_error: null,
230+
consecutive_failures: 0,
231+
created_at: "2026-07-01T00:00:00Z",
232+
updated_at: "2026-07-01T00:00:00Z",
233+
triggers: [
234+
{
235+
id: "trigger-1",
236+
loop_id: "loop-1",
237+
type: "schedule",
238+
enabled: true,
239+
config: { cron_expression: "0 9 * * *", timezone: "UTC" },
240+
schedule_sync_status: "synced",
241+
last_fired_at: null,
242+
created_at: "2026-07-01T00:00:00Z",
243+
updated_at: "2026-07-01T00:00:00Z",
244+
},
245+
],
246+
} satisfies LoopSchemas.Loop;
247+
248+
const values = loopToFormValues(loop);
249+
expect(values.triggers).toEqual([
250+
{
251+
key: "trigger-1",
252+
id: "trigger-1",
253+
type: "schedule",
254+
enabled: true,
255+
config: { cron_expression: "0 9 * * *", timezone: "UTC" },
256+
},
257+
]);
258+
expect(values.contextTarget).toEqual({
259+
folderId: "f1",
260+
name: "growth",
261+
outputs: { post_to_feed: true, update_context: false, canvas_id: null },
262+
});
263+
264+
const write = formValuesToLoopWrite(values);
265+
expect(write.name).toBe(loop.name);
266+
expect(write.visibility).toBe(loop.visibility);
267+
expect(write.context_target).toEqual(loop.context_target);
268+
expect(write.triggers).toEqual([
269+
{
270+
id: "trigger-1",
271+
type: "schedule",
272+
enabled: true,
273+
config: { cron_expression: "0 9 * * *", timezone: "UTC" },
274+
},
275+
]);
276+
});
277+
});
278+
279+
describe("auto-fix behaviors", () => {
280+
it.each([
281+
{ watch_ci: true, fix_review_comments: true, expected: true },
282+
{ watch_ci: true, fix_review_comments: false, expected: false },
283+
{ watch_ci: false, fix_review_comments: true, expected: false },
284+
{ watch_ci: false, fix_review_comments: false, expected: false },
285+
])(
286+
"reads on only when both flags are on (watch_ci=$watch_ci, fix=$fix_review_comments)",
287+
({ watch_ci, fix_review_comments, expected }) => {
288+
expect(
289+
isAutoFixEnabled({
290+
...defaultLoopBehaviors(),
291+
watch_ci,
292+
fix_review_comments,
293+
}),
294+
).toBe(expected);
295+
},
296+
);
297+
298+
it("withAutoFix sets both flags together and preserves the rest", () => {
299+
const behaviors = {
300+
...defaultLoopBehaviors(),
301+
create_prs: false,
302+
max_fix_iterations: 5,
303+
};
304+
expect(withAutoFix(behaviors, true)).toEqual({
305+
create_prs: false,
306+
watch_ci: true,
307+
fix_review_comments: true,
308+
max_fix_iterations: 5,
309+
});
310+
expect(withAutoFix(behaviors, false).watch_ci).toBe(false);
311+
});
312+
});

0 commit comments

Comments
 (0)