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.tsBlame355 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 computeHoursSaved,
22 type SleepModeReport,
23} from "../lib/sleep-mode";
24import {
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,
73 securityIssuesAutoFixed: 0,
74 gateFailuresAutoRepaired: 0,
75 })
76 ).toBe(0);
77 });
78
79 it("applies the documented heuristic (rounded to 1 decimal)", () => {
80 // 2*0.3 + 1*1.5 + 3*0.25 + (1+2)*0.5 = 0.6 + 1.5 + 0.75 + 1.5 = 4.35 -> 4.4
81 const v = computeHoursSaved({
82 prsAutoMerged: 2,
83 issuesBuiltByAi: 1,
84 aiReviewsPosted: 3,
85 securityIssuesAutoFixed: 1,
86 gateFailuresAutoRepaired: 2,
87 });
88 expect(v).toBe(4.4);
89 });
90
91 it("rounds .25 down per HALF_EVEN-ish .5-bias of Math.round", () => {
92 // 1*0.25 = 0.25 -> rounded *10 = 2.5 -> Math.round(2.5)=3 -> 0.3
93 expect(
94 computeHoursSaved({
95 prsAutoMerged: 0,
96 issuesBuiltByAi: 0,
97 aiReviewsPosted: 1,
98 securityIssuesAutoFixed: 0,
99 gateFailuresAutoRepaired: 0,
100 })
101 ).toBe(0.3);
102 });
103});
104
105// ---------------------------------------------------------------------------
106// renderSleepModeDigest
107// ---------------------------------------------------------------------------
108
109describe("sleep-mode — renderSleepModeDigest", () => {
110 it("produces valid plaintext + html for an empty report", () => {
111 const out = renderSleepModeDigest(emptyReport(), { username: "alice" });
112 expect(out.subject).toContain("quiet night");
113 expect(out.text).toContain("Hi alice");
114 expect(out.text).toContain("Quiet night");
115 expect(out.html).toContain("<html>");
116 expect(out.html).toContain("Good morning, alice");
117 // No section headers on the empty report — nothing to list.
118 expect(out.html).not.toContain("PRs auto-merged</h3>");
119 });
120
121 it("produces a busy-night subject and lists every section", () => {
122 const out = renderSleepModeDigest(busyReport(), { username: "alice" });
123 expect(out.subject).toContain("Claude shipped");
124 // 2 PRs + 1 issue + 3 reviews + 1 sec + 2 gates = 9 items
125 expect(out.subject).toContain("9");
126 expect(out.html).toContain("PRs auto-merged");
127 expect(out.html).toContain("Issues built by AI");
128 expect(out.html).toContain("Automated guardrails");
129 expect(out.text).toContain("## PRs auto-merged");
130 expect(out.text).toContain("## Issues built by AI");
131 expect(out.text).toContain("## Automated guardrails");
132 });
133
134 it("escapes user-controlled titles, repo names, and usernames (no XSS)", () => {
135 const malicious: SleepModeReport = {
136 ...emptyReport(),
137 prsAutoMerged: [
138 {
139 number: 1,
140 title: `<script>alert('pr')</script>`,
141 repo: `<img src=x onerror=1>`,
142 },
143 ],
144 issuesBuiltByAi: [
145 {
146 number: 2,
147 title: `<svg/onload=alert(1)>`,
148 repo: `"><script>x</script>`,
149 },
150 ],
151 };
152 const out = renderSleepModeDigest(malicious, {
153 username: `<b>boss</b>`,
154 });
155 const lower = out.html.toLowerCase();
156 // No raw <script> tags — they must be escaped to &lt;script&gt;.
157 expect(lower).not.toContain("<script>");
158 expect(lower).not.toContain("</script>");
159 // No live attribute injection — the `<img` open-tag and `<svg` open-tag
160 // must be escaped. (Substring search for `onerror=` would yield a false
161 // positive because the escaped &lt;img&gt; still contains the literal
162 // characters, but inside escaped angle-brackets they can't execute.)
163 expect(lower).not.toContain("<img");
164 expect(lower).not.toContain("<svg");
165 // The escaped form must be present.
166 expect(out.html).toContain("&lt;script&gt;");
167 expect(out.html).toContain("&lt;b&gt;boss&lt;/b&gt;");
168 expect(out.html).toContain("&lt;img src=x onerror=1&gt;");
169 expect(out.html).toContain("&lt;svg/onload=alert(1)&gt;");
170 // Plaintext should still contain the un-escaped strings (it IS plain text).
171 expect(out.text).toContain("<script>alert('pr')</script>");
172 });
173
174 it("subject is singular vs plural for total=1 case", () => {
175 const r: SleepModeReport = {
176 ...emptyReport(),
177 prsAutoMerged: [{ number: 1, title: "x", repo: "r" }],
178 };
179 const out = renderSleepModeDigest(r, { username: "alice" });
180 expect(out.subject).toContain("shipped 1 thing");
181 expect(out.subject).not.toContain("shipped 1 things");
182 });
183});
184
185// ---------------------------------------------------------------------------
186// composeSleepModeReport (DB-touching; graceful when DB unavailable)
187// ---------------------------------------------------------------------------
188
189describe("sleep-mode — composeSleepModeReport", () => {
190 it("returns a zero-valued report for a user with no repos (graceful)", async () => {
191 // Use a random UUID — guaranteed no owned repos. Either the DB query
192 // returns empty (and we get an empty report) or the DB is unavailable
193 // (and we fall through the catch block to the same empty report).
194 // Either way the function must NEVER throw and must return all-zeros.
195 const r = await composeSleepModeReport(
196 "00000000-0000-0000-0000-000000000000"
197 );
198 expect(r.prsAutoMerged).toEqual([]);
199 expect(r.issuesBuiltByAi).toEqual([]);
200 expect(r.aiReviewsPosted).toBe(0);
201 expect(r.securityIssuesAutoFixed).toBe(0);
202 expect(r.gateFailuresAutoRepaired).toBe(0);
203 expect(r.hoursSaved).toBe(0);
204 expect(r.windowHours).toBe(24);
205 });
206
207 it("respects custom sinceHoursAgo", async () => {
208 const r = await composeSleepModeReport(
209 "00000000-0000-0000-0000-000000000000",
210 { sinceHoursAgo: 48 }
211 );
212 expect(r.windowHours).toBe(48);
213 });
214});
215
216// ---------------------------------------------------------------------------
217// runSleepModeDigestTaskOnce
218// ---------------------------------------------------------------------------
219
220describe("sleep-mode — autopilot task (runSleepModeDigestTaskOnce)", () => {
221 const sentinelNow = new Date("2026-05-13T09:00:00Z"); // UTC hour = 9
222
223 function cand(
224 overrides: Partial<SleepModeDigestCandidate> = {}
225 ): SleepModeDigestCandidate {
226 return {
227 userId: "u-1",
228 digestHourUtc: 9,
229 lastDigestSentAt: null,
230 ...overrides,
231 };
232 }
233
234 it("sends for users whose current UTC hour matches their digestHourUtc and cooldown is clear", async () => {
235 const sent: string[] = [];
236 const summary = await runSleepModeDigestTaskOnce({
237 findCandidates: async () => [cand({ userId: "alice" })],
238 sendOne: async (id) => {
239 sent.push(id);
240 return { ok: true };
241 },
242 now: () => sentinelNow,
243 });
244 expect(sent).toEqual(["alice"]);
245 expect(summary).toEqual({ sent: 1, skipped: 0 });
246 });
247
248 it("skips users whose digestHourUtc does NOT match the current UTC hour", async () => {
249 const sent: string[] = [];
250 const summary = await runSleepModeDigestTaskOnce({
251 findCandidates: async () => [
252 cand({ userId: "alice", digestHourUtc: 9 }),
253 cand({ userId: "bob", digestHourUtc: 10 }),
254 cand({ userId: "carol", digestHourUtc: 8 }),
255 ],
256 sendOne: async (id) => {
257 sent.push(id);
258 return { ok: true };
259 },
260 now: () => sentinelNow,
261 });
262 expect(sent).toEqual(["alice"]);
263 expect(summary).toEqual({ sent: 1, skipped: 2 });
264 });
265
266 it("skips users whose last digest was within the 23h cooldown", async () => {
267 const sent: string[] = [];
268 // Sent 1h ago — within cooldown.
269 const recent = new Date(sentinelNow.getTime() - 60 * 60 * 1000);
270 // Sent 24h ago — past cooldown.
271 const old = new Date(sentinelNow.getTime() - 24 * 60 * 60 * 1000);
272 const summary = await runSleepModeDigestTaskOnce({
273 findCandidates: async () => [
274 cand({ userId: "recent-user", lastDigestSentAt: recent }),
275 cand({ userId: "old-user", lastDigestSentAt: old }),
276 cand({ userId: "never-user", lastDigestSentAt: null }),
277 ],
278 sendOne: async (id) => {
279 sent.push(id);
280 return { ok: true };
281 },
282 now: () => sentinelNow,
283 });
284 expect(sent.sort()).toEqual(["never-user", "old-user"]);
285 expect(summary).toEqual({ sent: 2, skipped: 1 });
286 });
287
288 it("counts sendOne ok:false as skipped (not sent)", async () => {
289 const summary = await runSleepModeDigestTaskOnce({
290 findCandidates: async () => [cand({ userId: "alice" })],
291 sendOne: async () => ({ ok: false, reason: "no email provider" }),
292 now: () => sentinelNow,
293 });
294 expect(summary).toEqual({ sent: 0, skipped: 1 });
295 });
296
297 it("isolates per-user failures — a thrown sendOne doesn't stop later users", async () => {
298 const sent: string[] = [];
299 const summary = await runSleepModeDigestTaskOnce({
300 findCandidates: async () => [
301 cand({ userId: "first" }),
302 cand({ userId: "second" }),
303 ],
304 sendOne: async (id) => {
305 if (id === "first") throw new Error("kaboom");
306 sent.push(id);
307 return { ok: true };
308 },
309 now: () => sentinelNow,
310 });
311 expect(sent).toEqual(["second"]);
312 expect(summary).toEqual({ sent: 1, skipped: 1 });
313 });
314
315 it("returns zero summary if findCandidates throws", async () => {
316 const summary = await runSleepModeDigestTaskOnce({
317 findCandidates: async () => {
318 throw new Error("db down");
319 },
320 now: () => sentinelNow,
321 });
322 expect(summary).toEqual({ sent: 0, skipped: 0 });
323 });
324
325 it("honours a custom cap parameter", async () => {
326 let capRequested = -1;
327 await runSleepModeDigestTaskOnce({
328 findCandidates: async (cap) => {
329 capRequested = cap;
330 return [];
331 },
332 now: () => sentinelNow,
333 cap: 7,
334 });
335 expect(capRequested).toBe(7);
336 });
337});
338
339// ---------------------------------------------------------------------------
340// /sleep-mode public route
341// ---------------------------------------------------------------------------
342
343describe("sleep-mode — public marketing page", () => {
344 it("GET /sleep-mode returns 200 with the pitch", async () => {
345 const res = await app.request("/sleep-mode");
346 expect(res.status).toBe(200);
347 const body = await res.text();
348 expect(body).toContain("Sleep Mode");
349 expect(body).toContain("Wake up to a digest");
350 // Sample digest is rendered inline as part of the page.
351 expect(body).toContain("Good morning");
352 // CTA link target.
353 expect(body).toContain('href="/settings"');
354 });
355});