Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sleep-mode.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.

sleep-mode.test.tsBlame363 lines · 1 contributor
46d6165Claude1/**
2 * Block L1 — Sleep Mode tests.
3 *
4 * Covers:
5 * - `renderSleepModeDigest` HTML + plaintext output, including XSS resistance
6 * - `computeHoursSaved` heuristic
7 * - autopilot `sleep-mode-digest` task: cooldown, hour-match, enabled filter,
8 * per-tick cap, per-user failure isolation
9 * - `/sleep-mode` public marketing page returns 200
10 * - `composeSleepModeReport` zero report for a user with no repos
11 *
12 * DI test pattern follows K3's `autopilot-ai-tasks.test.ts` — every DB call is
13 * dependency-injected so tests run without a real DB.
14 */
15
16import { describe, it, expect } from "bun:test";
17import app from "../app";
18import {
19 renderSleepModeDigest,
20 composeSleepModeReport,
21 type SleepModeReport,
22} from "../lib/sleep-mode";
516b91eClaude23import { computeHoursSaved } from "../lib/ai-hours-saved";
46d6165Claude24import {
25 runSleepModeDigestTaskOnce,
26 type SleepModeDigestCandidate,
27} from "../lib/autopilot";
28
29// ---------------------------------------------------------------------------
30// Fixtures
31// ---------------------------------------------------------------------------
32
33function emptyReport(): SleepModeReport {
34 return {
35 windowHours: 24,
36 prsAutoMerged: [],
37 issuesBuiltByAi: [],
38 aiReviewsPosted: 0,
39 securityIssuesAutoFixed: 0,
40 gateFailuresAutoRepaired: 0,
41 hoursSaved: 0,
42 };
43}
44
45function busyReport(): SleepModeReport {
46 return {
47 windowHours: 24,
48 prsAutoMerged: [
49 { number: 1, title: "Bump axios", repo: "api" },
50 { number: 2, title: "Fix retry", repo: "billing" },
51 ],
52 issuesBuiltByAi: [
53 { number: 7, title: "Add /metrics", repo: "api", prNumber: 8 },
54 ],
55 aiReviewsPosted: 3,
56 securityIssuesAutoFixed: 1,
57 gateFailuresAutoRepaired: 2,
58 hoursSaved: 0,
59 };
60}
61
62// ---------------------------------------------------------------------------
63// computeHoursSaved
64// ---------------------------------------------------------------------------
65
66describe("sleep-mode — computeHoursSaved", () => {
67 it("returns 0 for an empty report", () => {
68 expect(
69 computeHoursSaved({
70 prsAutoMerged: 0,
71 issuesBuiltByAi: 0,
72 aiReviewsPosted: 0,
516b91eClaude73 aiTriagesPosted: 0,
74 aiCommitMsgs: 0,
75 secretsAutoRepaired: 0,
76 gateAutoRepairs: 0,
46d6165Claude77 })
78 ).toBe(0);
79 });
80
81 it("applies the documented heuristic (rounded to 1 decimal)", () => {
516b91eClaude82 // 2*0.3 + 1*1.5 + 3*0.25 + 1*0.5 + 2*0.4 = 0.6 + 1.5 + 0.75 + 0.5 + 0.8 = 4.15 -> 4.2
83 // (secretsAutoRepaired=1 * 0.5, gateAutoRepairs=2 * 0.4)
46d6165Claude84 const v = computeHoursSaved({
85 prsAutoMerged: 2,
86 issuesBuiltByAi: 1,
87 aiReviewsPosted: 3,
516b91eClaude88 aiTriagesPosted: 0,
89 aiCommitMsgs: 0,
90 secretsAutoRepaired: 1,
91 gateAutoRepairs: 2,
46d6165Claude92 });
516b91eClaude93 // 0.6 + 1.5 + 0.75 + 0.5 + 0.8 = 4.15 -> Math.round(41.5)/10 = 4.2
94 expect(v).toBe(4.2);
46d6165Claude95 });
96
97 it("rounds .25 down per HALF_EVEN-ish .5-bias of Math.round", () => {
98 // 1*0.25 = 0.25 -> rounded *10 = 2.5 -> Math.round(2.5)=3 -> 0.3
99 expect(
100 computeHoursSaved({
101 prsAutoMerged: 0,
102 issuesBuiltByAi: 0,
103 aiReviewsPosted: 1,
516b91eClaude104 aiTriagesPosted: 0,
105 aiCommitMsgs: 0,
106 secretsAutoRepaired: 0,
107 gateAutoRepairs: 0,
46d6165Claude108 })
109 ).toBe(0.3);
110 });
111});
112
113// ---------------------------------------------------------------------------
114// renderSleepModeDigest
115// ---------------------------------------------------------------------------
116
117describe("sleep-mode — renderSleepModeDigest", () => {
118 it("produces valid plaintext + html for an empty report", () => {
119 const out = renderSleepModeDigest(emptyReport(), { username: "alice" });
120 expect(out.subject).toContain("quiet night");
121 expect(out.text).toContain("Hi alice");
122 expect(out.text).toContain("Quiet night");
123 expect(out.html).toContain("<html>");
124 expect(out.html).toContain("Good morning, alice");
125 // No section headers on the empty report — nothing to list.
126 expect(out.html).not.toContain("PRs auto-merged</h3>");
127 });
128
129 it("produces a busy-night subject and lists every section", () => {
130 const out = renderSleepModeDigest(busyReport(), { username: "alice" });
131 expect(out.subject).toContain("Claude shipped");
132 // 2 PRs + 1 issue + 3 reviews + 1 sec + 2 gates = 9 items
133 expect(out.subject).toContain("9");
134 expect(out.html).toContain("PRs auto-merged");
135 expect(out.html).toContain("Issues built by AI");
136 expect(out.html).toContain("Automated guardrails");
137 expect(out.text).toContain("## PRs auto-merged");
138 expect(out.text).toContain("## Issues built by AI");
139 expect(out.text).toContain("## Automated guardrails");
140 });
141
142 it("escapes user-controlled titles, repo names, and usernames (no XSS)", () => {
143 const malicious: SleepModeReport = {
144 ...emptyReport(),
145 prsAutoMerged: [
146 {
147 number: 1,
148 title: `<script>alert('pr')</script>`,
149 repo: `<img src=x onerror=1>`,
150 },
151 ],
152 issuesBuiltByAi: [
153 {
154 number: 2,
155 title: `<svg/onload=alert(1)>`,
156 repo: `"><script>x</script>`,
157 },
158 ],
159 };
160 const out = renderSleepModeDigest(malicious, {
161 username: `<b>boss</b>`,
162 });
163 const lower = out.html.toLowerCase();
164 // No raw <script> tags — they must be escaped to &lt;script&gt;.
165 expect(lower).not.toContain("<script>");
166 expect(lower).not.toContain("</script>");
167 // No live attribute injection — the `<img` open-tag and `<svg` open-tag
168 // must be escaped. (Substring search for `onerror=` would yield a false
169 // positive because the escaped &lt;img&gt; still contains the literal
170 // characters, but inside escaped angle-brackets they can't execute.)
171 expect(lower).not.toContain("<img");
172 expect(lower).not.toContain("<svg");
173 // The escaped form must be present.
174 expect(out.html).toContain("&lt;script&gt;");
175 expect(out.html).toContain("&lt;b&gt;boss&lt;/b&gt;");
176 expect(out.html).toContain("&lt;img src=x onerror=1&gt;");
177 expect(out.html).toContain("&lt;svg/onload=alert(1)&gt;");
178 // Plaintext should still contain the un-escaped strings (it IS plain text).
179 expect(out.text).toContain("<script>alert('pr')</script>");
180 });
181
182 it("subject is singular vs plural for total=1 case", () => {
183 const r: SleepModeReport = {
184 ...emptyReport(),
185 prsAutoMerged: [{ number: 1, title: "x", repo: "r" }],
186 };
187 const out = renderSleepModeDigest(r, { username: "alice" });
188 expect(out.subject).toContain("shipped 1 thing");
189 expect(out.subject).not.toContain("shipped 1 things");
190 });
191});
192
193// ---------------------------------------------------------------------------
194// composeSleepModeReport (DB-touching; graceful when DB unavailable)
195// ---------------------------------------------------------------------------
196
197describe("sleep-mode — composeSleepModeReport", () => {
198 it("returns a zero-valued report for a user with no repos (graceful)", async () => {
199 // Use a random UUID — guaranteed no owned repos. Either the DB query
200 // returns empty (and we get an empty report) or the DB is unavailable
201 // (and we fall through the catch block to the same empty report).
202 // Either way the function must NEVER throw and must return all-zeros.
203 const r = await composeSleepModeReport(
204 "00000000-0000-0000-0000-000000000000"
205 );
206 expect(r.prsAutoMerged).toEqual([]);
207 expect(r.issuesBuiltByAi).toEqual([]);
208 expect(r.aiReviewsPosted).toBe(0);
209 expect(r.securityIssuesAutoFixed).toBe(0);
210 expect(r.gateFailuresAutoRepaired).toBe(0);
211 expect(r.hoursSaved).toBe(0);
212 expect(r.windowHours).toBe(24);
213 });
214
215 it("respects custom sinceHoursAgo", async () => {
216 const r = await composeSleepModeReport(
217 "00000000-0000-0000-0000-000000000000",
218 { sinceHoursAgo: 48 }
219 );
220 expect(r.windowHours).toBe(48);
221 });
222});
223
224// ---------------------------------------------------------------------------
225// runSleepModeDigestTaskOnce
226// ---------------------------------------------------------------------------
227
228describe("sleep-mode — autopilot task (runSleepModeDigestTaskOnce)", () => {
229 const sentinelNow = new Date("2026-05-13T09:00:00Z"); // UTC hour = 9
230
231 function cand(
232 overrides: Partial<SleepModeDigestCandidate> = {}
233 ): SleepModeDigestCandidate {
234 return {
235 userId: "u-1",
236 digestHourUtc: 9,
e1fc7dbClaude237 lastSleepDigestSentAt: null,
46d6165Claude238 ...overrides,
239 };
240 }
241
242 it("sends for users whose current UTC hour matches their digestHourUtc and cooldown is clear", async () => {
243 const sent: string[] = [];
244 const summary = await runSleepModeDigestTaskOnce({
245 findCandidates: async () => [cand({ userId: "alice" })],
246 sendOne: async (id) => {
247 sent.push(id);
248 return { ok: true };
249 },
250 now: () => sentinelNow,
251 });
252 expect(sent).toEqual(["alice"]);
253 expect(summary).toEqual({ sent: 1, skipped: 0 });
254 });
255
256 it("skips users whose digestHourUtc does NOT match the current UTC hour", async () => {
257 const sent: string[] = [];
258 const summary = await runSleepModeDigestTaskOnce({
259 findCandidates: async () => [
260 cand({ userId: "alice", digestHourUtc: 9 }),
261 cand({ userId: "bob", digestHourUtc: 10 }),
262 cand({ userId: "carol", digestHourUtc: 8 }),
263 ],
264 sendOne: async (id) => {
265 sent.push(id);
266 return { ok: true };
267 },
268 now: () => sentinelNow,
269 });
270 expect(sent).toEqual(["alice"]);
271 expect(summary).toEqual({ sent: 1, skipped: 2 });
272 });
273
274 it("skips users whose last digest was within the 23h cooldown", async () => {
275 const sent: string[] = [];
276 // Sent 1h ago — within cooldown.
277 const recent = new Date(sentinelNow.getTime() - 60 * 60 * 1000);
278 // Sent 24h ago — past cooldown.
279 const old = new Date(sentinelNow.getTime() - 24 * 60 * 60 * 1000);
280 const summary = await runSleepModeDigestTaskOnce({
281 findCandidates: async () => [
e1fc7dbClaude282 cand({ userId: "recent-user", lastSleepDigestSentAt: recent }),
283 cand({ userId: "old-user", lastSleepDigestSentAt: old }),
284 cand({ userId: "never-user", lastSleepDigestSentAt: null }),
46d6165Claude285 ],
286 sendOne: async (id) => {
287 sent.push(id);
288 return { ok: true };
289 },
290 now: () => sentinelNow,
291 });
292 expect(sent.sort()).toEqual(["never-user", "old-user"]);
293 expect(summary).toEqual({ sent: 2, skipped: 1 });
294 });
295
296 it("counts sendOne ok:false as skipped (not sent)", async () => {
297 const summary = await runSleepModeDigestTaskOnce({
298 findCandidates: async () => [cand({ userId: "alice" })],
299 sendOne: async () => ({ ok: false, reason: "no email provider" }),
300 now: () => sentinelNow,
301 });
302 expect(summary).toEqual({ sent: 0, skipped: 1 });
303 });
304
305 it("isolates per-user failures — a thrown sendOne doesn't stop later users", async () => {
306 const sent: string[] = [];
307 const summary = await runSleepModeDigestTaskOnce({
308 findCandidates: async () => [
309 cand({ userId: "first" }),
310 cand({ userId: "second" }),
311 ],
312 sendOne: async (id) => {
313 if (id === "first") throw new Error("kaboom");
314 sent.push(id);
315 return { ok: true };
316 },
317 now: () => sentinelNow,
318 });
319 expect(sent).toEqual(["second"]);
320 expect(summary).toEqual({ sent: 1, skipped: 1 });
321 });
322
323 it("returns zero summary if findCandidates throws", async () => {
324 const summary = await runSleepModeDigestTaskOnce({
325 findCandidates: async () => {
326 throw new Error("db down");
327 },
328 now: () => sentinelNow,
329 });
330 expect(summary).toEqual({ sent: 0, skipped: 0 });
331 });
332
333 it("honours a custom cap parameter", async () => {
334 let capRequested = -1;
335 await runSleepModeDigestTaskOnce({
336 findCandidates: async (cap) => {
337 capRequested = cap;
338 return [];
339 },
340 now: () => sentinelNow,
341 cap: 7,
342 });
343 expect(capRequested).toBe(7);
344 });
345});
346
347// ---------------------------------------------------------------------------
348// /sleep-mode public route
349// ---------------------------------------------------------------------------
350
351describe("sleep-mode — public marketing page", () => {
352 it("GET /sleep-mode returns 200 with the pitch", async () => {
353 const res = await app.request("/sleep-mode");
354 expect(res.status).toBe(200);
355 const body = await res.text();
356 expect(body).toContain("Sleep Mode");
357 expect(body).toContain("Wake up to a digest");
358 // Sample digest is rendered inline as part of the page.
359 expect(body).toContain("Good morning");
360 // CTA link target.
361 expect(body).toContain('href="/settings"');
362 });
363});