CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
email-digest.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 08420cd | 1 | /** |
| 2 | * Block I7 — Weekly email digest tests. | |
| 3 | * | |
| 4 | * Pure helper coverage for textToHtml / escapeHtml / fmtRange, plus route auth | |
| 5 | * smoke on /settings/digest/preview and the admin trigger endpoints. DB-backed | |
| 6 | * calls (composeDigest / sendDigestForUser / sendDigestsToAll) are exercised | |
| 7 | * against the live server. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import app from "../app"; | |
| 12 | import { __internal } from "../lib/email-digest"; | |
| 13 | ||
| 14 | const { textToHtml, escapeHtml, fmtRange } = __internal; | |
| 15 | ||
| 16 | describe("email-digest — escapeHtml", () => { | |
| 17 | it("escapes <, >, & and quotes", () => { | |
| 18 | expect(escapeHtml(`<a href="x">&</a>`)).toBe( | |
| 19 | `<a href="x">&</a>` | |
| 20 | ); | |
| 21 | }); | |
| 22 | ||
| 23 | it("leaves plain text alone", () => { | |
| 24 | expect(escapeHtml("hello world")).toBe("hello world"); | |
| 25 | }); | |
| 26 | }); | |
| 27 | ||
| 28 | describe("email-digest — fmtRange", () => { | |
| 29 | it("returns a single date when from === to (by day)", () => { | |
| 30 | const d = new Date("2025-06-01T00:00:00Z"); | |
| 31 | expect(fmtRange(d, d)).toBe("2025-06-01"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("joins distinct dates with arrow", () => { | |
| 35 | const a = new Date("2025-06-01T00:00:00Z"); | |
| 36 | const b = new Date("2025-06-08T00:00:00Z"); | |
| 37 | const out = fmtRange(a, b); | |
| 38 | expect(out).toContain("2025-06-01"); | |
| 39 | expect(out).toContain("2025-06-08"); | |
| 40 | expect(out).toContain("\u2192"); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("email-digest — textToHtml", () => { | |
| 45 | it("wraps H2 headings and list items", () => { | |
| 46 | const html = textToHtml("## Section\n- item 1", "https://gluecron.com"); | |
| 47 | expect(html).toContain("<h3"); | |
| 48 | expect(html).toContain("Section"); | |
| 49 | expect(html).toContain("<li>item 1</li>"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("renders <hr> for --- separator", () => { | |
| 53 | const html = textToHtml("hello\n---\nfooter", "https://gluecron.com"); | |
| 54 | expect(html).toContain("<hr"); | |
| 55 | }); | |
| 56 | ||
| 57 | it("escapes user-controlled text in paragraphs", () => { | |
| 58 | const html = textToHtml("<script>alert(1)</script>", "https://gluecron.com"); | |
| 59 | expect(html).not.toContain("<script>alert"); | |
| 60 | expect(html).toContain("<script>"); | |
| 61 | }); | |
| 62 | ||
| 63 | it("includes the base URL footer", () => { | |
| 64 | const html = textToHtml("body", "https://gluecron.com"); | |
| 65 | expect(html).toContain("https://gluecron.com"); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("email-digest — route auth", () => { | |
| 70 | it("GET /settings/digest/preview without auth → 302 /login", async () => { | |
| 71 | const res = await app.request("/settings/digest/preview"); | |
| 72 | expect(res.status).toBe(302); | |
| 73 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("POST /admin/digests/run without auth → 302 /login", async () => { | |
| 77 | const res = await app.request("/admin/digests/run", { method: "POST" }); | |
| 78 | expect(res.status).toBe(302); | |
| 79 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 80 | }); | |
| 81 | ||
| 82 | it("POST /admin/digests/preview without auth → 302 /login", async () => { | |
| 83 | const res = await app.request("/admin/digests/preview", { | |
| 84 | method: "POST", | |
| 85 | body: new URLSearchParams({ username: "alice" }), | |
| 86 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 87 | }); | |
| 88 | expect(res.status).toBe(302); | |
| 89 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 90 | }); | |
| 91 | ||
| 92 | it("GET /admin/digests without auth → 302 /login", async () => { | |
| 93 | const res = await app.request("/admin/digests"); | |
| 94 | expect(res.status).toBe(302); | |
| 95 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 96 | }); | |
| 97 | }); | |
| 98 | ||
| 99 | describe("email-digest — settings form", () => { | |
| 100 | it("POST /settings/notifications without auth → 302 /login", async () => { | |
| 101 | const res = await app.request("/settings/notifications", { | |
| 102 | method: "POST", | |
| 103 | body: new URLSearchParams({ notify_email_digest_weekly: "1" }), | |
| 104 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 105 | }); | |
| 106 | expect(res.status).toBe(302); | |
| 107 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 108 | }); | |
| 109 | }); |