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

ai-standup.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.

ai-standup.test.tsBlame392 lines · 2 contributors
56801e1Claude1/**
2 * AI Standup tests.
3 *
4 * Pure-helper coverage runs unconditionally. The DB-backed branch
5 * (notification insertion + same-day dedupe) is gated on DATABASE_URL
6 * matching the project convention — see api-tokens.test.ts and friends.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 __test,
12 classifyMaterial,
13 deliverStandup,
14 generateStandup,
15 hasStandupForToday,
16 utcDayKey,
17} from "../lib/ai-standup";
18
19const HAS_DB = Boolean(process.env.DATABASE_URL);
20
ccec83accantynz-alt21describe("standups routes — anonymous access", () => {
22 // Regression test: all three routes did `c.get("user")!` and crashed with
23 // a raw 500 (TypeError: null is not an object) for anonymous callers.
24 // requireAuth was wired via a wildcard pattern ("/standups*") that didn't
25 // actually cover /api/standups/preview at all, and live evidence showed
26 // it didn't reliably cover bare /standups either. Found via a full
27 // production HTTP sweep, not a doc or a prior test — there was no
28 // coverage of these routes through app.request() before this.
29 it("GET /standups redirects to login instead of crashing", async () => {
30 const app = (await import("../app")).default;
31 const res = await app.request("/standups");
32 expect(res.status).toBe(302);
33 expect(res.headers.get("location")).toContain("/login");
34 });
35
36 it("POST /standups/preview redirects to login instead of crashing", async () => {
37 const app = (await import("../app")).default;
38 const res = await app.request("/standups/preview", { method: "POST" });
39 expect(res.status).toBe(302);
40 expect(res.headers.get("location")).toContain("/login");
41 });
42
43 it("GET /api/standups/preview redirects to login instead of crashing", async () => {
44 const app = (await import("../app")).default;
45 const res = await app.request("/api/standups/preview");
46 expect(res.status).toBe(302);
47 expect(res.headers.get("location")).toContain("/login");
48 });
49});
50
56801e1Claude51// ---------------------------------------------------------------------------
52// Pure helpers — no DB, no AI client.
53// ---------------------------------------------------------------------------
54
55describe("ai-standup — utcDayKey", () => {
56 it("returns YYYY-MM-DD for a Date", () => {
57 expect(utcDayKey(new Date("2026-05-25T08:00:00.000Z"))).toBe(
58 "2026-05-25"
59 );
60 });
61 it("buckets two timestamps on the same UTC day to the same key", () => {
62 const a = new Date("2026-05-25T01:00:00.000Z");
63 const b = new Date("2026-05-25T23:59:59.000Z");
64 expect(utcDayKey(a)).toBe(utcDayKey(b));
65 });
66});
67
68describe("ai-standup — classifyMaterial", () => {
69 const now = new Date("2026-05-25T09:00:00.000Z");
70 const fourDaysAgo = new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000);
71 const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
72
73 it("buckets merged PRs as shipped", () => {
74 const out = classifyMaterial({
75 now,
76 deploys: [],
77 issues: [],
78 prs: [
79 {
80 id: "p1",
81 number: 12,
82 title: "Add /metrics",
83 state: "merged",
84 isAiBuilt: false,
85 mergedAt: oneHourAgo,
86 updatedAt: oneHourAgo,
87 createdAt: oneHourAgo,
88 repo: "demo/repo",
89 },
90 ],
91 });
92 expect(out.shipped.length).toBe(1);
93 expect(out.shipped[0]).toContain("Merged");
94 expect(out.inFlight.length).toBe(0);
95 expect(out.atRisk.length).toBe(0);
96 });
97
98 it("flags open PRs older than 3 days as at-risk", () => {
99 const out = classifyMaterial({
100 now,
101 deploys: [],
102 issues: [],
103 prs: [
104 {
105 id: "p1",
106 number: 7,
107 title: "WIP refactor",
108 state: "open",
109 isAiBuilt: false,
110 mergedAt: null,
111 updatedAt: fourDaysAgo,
112 createdAt: fourDaysAgo,
113 repo: "demo/repo",
114 },
115 ],
116 });
117 expect(out.atRisk.length).toBe(1);
118 expect(out.atRisk[0]).toContain("Stale");
119 expect(out.inFlight.length).toBe(0);
120 });
121
122 it("surfaces ai:build PRs in the aiHighlights bucket", () => {
123 const out = classifyMaterial({
124 now,
125 deploys: [],
126 issues: [],
127 prs: [
128 {
129 id: "p1",
130 number: 1,
131 title: "ai:build add tests for /standups",
132 state: "merged",
133 isAiBuilt: true,
134 mergedAt: oneHourAgo,
135 updatedAt: oneHourAgo,
136 createdAt: oneHourAgo,
137 repo: "demo/repo",
138 },
139 ],
140 });
141 expect(out.aiHighlights.length).toBe(1);
142 });
143
144 it("counts failed deploys as at-risk and succeeded as shipped", () => {
145 const out = classifyMaterial({
146 now,
147 issues: [],
148 prs: [],
149 deploys: [
150 {
151 runId: "r1",
152 sha: "deadbeefdeadbeef",
153 status: "succeeded",
154 startedAt: oneHourAgo,
155 finishedAt: oneHourAgo,
156 },
157 {
158 runId: "r2",
159 sha: "abcdefabcdefabcd",
160 status: "failed",
161 startedAt: oneHourAgo,
162 finishedAt: oneHourAgo,
163 },
164 ],
165 });
166 expect(out.shipped.some((s) => s.includes("succeeded"))).toBe(true);
167 expect(out.atRisk.some((s) => s.includes("failed"))).toBe(true);
168 });
169});
170
171describe("ai-standup — renderFallbackSummary", () => {
172 it("renders all three sections when material is empty", () => {
173 const out = __test.renderFallbackSummary("daily", {
174 shipped: [],
175 inFlight: [],
176 atRisk: [],
177 aiHighlights: [],
178 });
179 expect(out).toContain("Daily standup");
180 expect(out).toContain("Shipped");
181 expect(out).toContain("In flight");
182 expect(out).toContain("At risk");
183 });
184
185 it("includes the AI section only when highlights exist", () => {
186 const empty = __test.renderFallbackSummary("daily", {
187 shipped: ["one"],
188 inFlight: [],
189 atRisk: [],
190 aiHighlights: [],
191 });
192 expect(empty).not.toContain("AI-driven changes");
193 const filled = __test.renderFallbackSummary("daily", {
194 shipped: [],
195 inFlight: [],
196 atRisk: [],
197 aiHighlights: ["AI-authored PR #1"],
198 });
199 expect(filled).toContain("AI-driven changes");
200 });
201});
202
203describe("ai-standup — buildPrompt", () => {
204 it("includes scope-specific wording", () => {
205 const daily = __test.buildPrompt("daily", {
206 shipped: [],
207 inFlight: [],
208 atRisk: [],
209 aiHighlights: [],
210 });
211 expect(daily).toContain("last 24 hours");
212 const weekly = __test.buildPrompt("weekly", {
213 shipped: [],
214 inFlight: [],
215 atRisk: [],
216 aiHighlights: [],
217 });
218 expect(weekly).toContain("last 7 days");
219 });
220});
221
222describe("ai-standup — deliverStandup (canned generator, DI'd)", () => {
223 it("dedupes when alreadyDelivered returns true", async () => {
224 let generated = 0;
225 const res = await deliverStandup({
226 userId: "u-1",
227 scope: "daily",
228 alreadyDelivered: async () => true,
229 generate: async () => {
230 generated += 1;
231 return {
232 summary: "should not be called",
233 shippedItems: [],
234 blockedItems: [],
235 atRiskItems: [],
236 windowStart: new Date(),
237 windowEnd: new Date(),
238 aiAvailable: false,
239 };
240 },
241 });
242 expect(generated).toBe(0);
243 expect(res.ok).toBe(false);
244 expect(res.reason).toContain("already");
245 expect(res.notified).toBe(false);
246 });
247});
248
249// ---------------------------------------------------------------------------
250// DB-backed: insert a standup, then confirm the notification + dedupe.
251// ---------------------------------------------------------------------------
252
253describe("ai-standup — DB-backed delivery", () => {
254 it.skipIf(!HAS_DB)(
255 "creates a notification + dedupes on the same UTC day",
256 async () => {
257 const { db } = await import("../db");
258 const { users, notifications } = await import("../db/schema");
259 const { aiStandups, userStandupPrefs } = await import(
260 "../db/schema-standup"
261 );
262 const { eq } = await import("drizzle-orm");
263
264 const uname = "stand-" + Math.random().toString(36).slice(2, 10);
265 const [user] = await db
266 .insert(users)
267 .values({
268 username: uname,
269 email: `${uname}@example.com`,
270 passwordHash: "x",
271 })
272 .returning();
273
274 try {
275 const cannedGen = async () => ({
276 summary: "## 🚀 Shipped\n- demo\n\n## 🚧 In flight\n- nothing\n\n## ⚠️ At risk\n- nothing",
277 shippedItems: ["demo PR"],
278 blockedItems: [],
279 atRiskItems: [],
280 windowStart: new Date(Date.now() - 24 * 3600 * 1000),
281 windowEnd: new Date(),
282 aiAvailable: true,
283 });
284
285 // First call should insert + notify.
286 const first = await deliverStandup({
287 userId: user.id,
288 scope: "daily",
289 generate: cannedGen,
290 });
291 expect(first.ok).toBe(true);
292 expect(first.notified).toBe(true);
293 expect(first.standupId).toBeTruthy();
294
295 // The notification row should exist with the standup body and a
296 // /standups URL.
297 const notifs = await db
298 .select()
299 .from(notifications)
300 .where(eq(notifications.userId, user.id));
301 expect(notifs.length).toBeGreaterThanOrEqual(1);
302 const standupNotif = notifs.find((n) =>
303 (n.url || "").startsWith("/standups")
304 );
305 expect(standupNotif).toBeTruthy();
306 expect(standupNotif?.body || "").toContain("Shipped");
307
308 // Second call on the same UTC day should be deduped.
309 const second = await deliverStandup({
310 userId: user.id,
311 scope: "daily",
312 generate: cannedGen,
313 });
314 expect(second.ok).toBe(false);
315 expect(second.reason).toContain("already");
316
317 // hasStandupForToday should also report true.
318 const dupe = await hasStandupForToday(user.id, "daily", new Date());
319 expect(dupe).toBe(true);
320 } finally {
321 // Clean up rows we created — best-effort.
322 try {
323 await db
324 .delete(aiStandups)
325 .where(eq(aiStandups.userId, user.id));
326 } catch {
327 /* ignore */
328 }
329 try {
330 await db
331 .delete(userStandupPrefs)
332 .where(eq(userStandupPrefs.userId, user.id));
333 } catch {
334 /* ignore */
335 }
336 try {
337 await db
338 .delete(notifications)
339 .where(eq(notifications.userId, user.id));
340 } catch {
341 /* ignore */
342 }
343 try {
344 await db.delete(users).where(eq(users.id, user.id));
345 } catch {
346 /* ignore */
347 }
348 }
349 }
350 );
351
352 it.skipIf(!HAS_DB)(
353 "generateStandup returns a fallback body when AI key is absent",
354 async () => {
355 // Save and clear the key for this single assertion.
356 const prev = process.env.ANTHROPIC_API_KEY;
357 delete process.env.ANTHROPIC_API_KEY;
358 try {
359 const { db } = await import("../db");
360 const { users } = await import("../db/schema");
361 const { eq } = await import("drizzle-orm");
362
363 const uname = "stand2-" + Math.random().toString(36).slice(2, 10);
364 const [user] = await db
365 .insert(users)
366 .values({
367 username: uname,
368 email: `${uname}@example.com`,
369 passwordHash: "x",
370 })
371 .returning();
372 try {
373 const res = await generateStandup({
374 userId: user.id,
375 scope: "daily",
376 });
377 expect(typeof res.summary).toBe("string");
378 expect(res.summary.length).toBeGreaterThan(0);
379 expect(res.aiAvailable).toBe(false);
380 } finally {
381 try {
382 await db.delete(users).where(eq(users.id, user.id));
383 } catch {
384 /* ignore */
385 }
386 }
387 } finally {
388 if (prev) process.env.ANTHROPIC_API_KEY = prev;
389 }
390 }
391 );
392});