| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | |
| 3 | import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts"; |
| 4 | |
| 5 | const 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 | |
| 16 | describe("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 | }); |