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