1import { assert, describe, it } from "@effect/vitest";
2
3import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts";
4
5const githubPullRequest: WebhookRequest = {
6 method: "POST",
7 path: "/api/hooks/task-1",
8 query: "source=github",
9 headers: { "content-type": "application/json", "x-github-event": "pull_request" },
10 bodyText: JSON.stringify({
11 action: "opened",
12 pull_request: { url: "https://github.com/org/repo/pull/45", number: 45, labels: ["bug"] },
13 }),
14};
15
16describe("renderWebhookPrompt", () => {
17 it("sends only what the template names", () => {
18 const rendered = renderWebhookPrompt(
19 "Review this PR: {{body.pull_request.url}}",
20 githubPullRequest,
21 );
22 assert.equal(rendered.prompt, "Review this PR: https://github.com/org/repo/pull/45");
23 assert.deepEqual(rendered.missing, []);
24 });
25
26 it("reads headers case-insensitively, query parameters and array indexes", () => {
27 const rendered = renderWebhookPrompt(
28 "{{ headers.X-GitHub-Event }} #{{body.pull_request.number}} {{body.pull_request.labels.0}} {{query.source}}",
29 githubPullRequest,
30 );
31 assert.equal(rendered.prompt, "pull_request #45 bug github");
32 });
33
34 it("renders objects as JSON and the raw body and request on request", () => {
35 const rendered = renderWebhookPrompt(
36 "{{body.pull_request.labels}}|{{body}}",
37 githubPullRequest,
38 );
39 assert.equal(rendered.prompt, `[\n "bug"\n]|${githubPullRequest.bodyText}`);
40 const request = renderWebhookPrompt("{{request}}", githubPullRequest).prompt;
41 assert.isTrue(request.startsWith("POST /api/hooks/task-1?source=github\n"));
42 assert.include(request, "x-github-event: pull_request");
43 assert.isTrue(request.endsWith(githubPullRequest.bodyText));
44 });
45
46 it("renders missing fields empty and reports them once", () => {
47 const rendered = renderWebhookPrompt(
48 "a={{body.nope}} b={{body.nope}} c={{headers.x-missing}} d={{unknown}}",
49 githubPullRequest,
50 );
51 assert.equal(rendered.prompt, "a= b= c= d=");
52 assert.deepEqual(rendered.missing, ["body.nope", "headers.x-missing", "unknown"]);
53 });
54
55 it("trims placeholder whitespace before resolving and reporting missing fields", () => {
56 const rendered = renderWebhookPrompt(
57 "{{\tbody.action\n}}|{{ body.nope }}|{{\nbody.nope\t}}|{{ \t\n }}",
58 githubPullRequest,
59 );
60 assert.equal(rendered.prompt, "opened|||");
61 assert.deepEqual(rendered.missing, ["body.nope", ""]);
62 });
63
64 it("preserves unclosed whitespace-heavy placeholders and resolves later valid ones", () => {
65 const unfinished = `Notify {{${" ".repeat(1024)}body`;
66 const rendered = renderWebhookPrompt(
67 `${unfinished}\n{{body.pull_request.number}}`,
68 githubPullRequest,
69 );
70 assert.equal(rendered.prompt, `${unfinished}\n45`);
71 assert.deepEqual(rendered.missing, []);
72 });
73
74 it("addresses form-encoded bodies and leaves other bodies to {{body}}", () => {
75 const form = renderWebhookPrompt("{{body.text}} by {{body.user_name}}", {
76 ...githubPullRequest,
77 headers: { "content-type": "application/x-www-form-urlencoded" },
78 bodyText: "text=deploy+prod&user_name=alice",
79 });
80 assert.equal(form.prompt, "deploy prod by alice");
81
82 const plain = renderWebhookPrompt("{{body.field}}{{body}}", {
83 ...githubPullRequest,
84 headers: { "content-type": "text/plain" },
85 bodyText: "build failed",
86 });
87 assert.equal(plain.prompt, "build failed");
88 assert.deepEqual(plain.missing, ["body.field"]);
89 });
90
91 it("redacts credentials in whole-request placeholders but not named ones", () => {
92 const request: WebhookRequest = {
93 ...githubPullRequest,
94 query: "source=github&access_token=q-secret",
95 headers: {
96 ...githubPullRequest.headers,
97 authorization: "Bearer h-secret",
98 "x-hub-signature-256": "sha256=abc",
99 },
100 };
101 const whole = renderWebhookPrompt("{{request}}|{{headers}}|{{query}}", request).prompt;
102 for (const secret of ["q-secret", "h-secret", "sha256=abc"]) {
103 assert.notInclude(whole, secret);
104 }
105 assert.include(whole, "?source=github&access_token=[redacted]\n");
106 assert.include(whole, "authorization: [redacted]");
107 assert.include(whole, "x-github-event: pull_request");
108
109 const named = renderWebhookPrompt(
110 "{{headers.authorization}} {{query.access_token}}",
111 request,
112 ).prompt;
113 assert.equal(named, "Bearer h-secret q-secret");
114 });
115
116 it("does not resolve inherited object properties", () => {
117 const rendered = renderWebhookPrompt(
118 "{{body.constructor}}{{body.__proto__}}",
119 githubPullRequest,
120 );
121 assert.equal(rendered.prompt, "");
122 });
123});