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.
| 56801e1 | 1 | /** |
| 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 | ||
| 9 | import { describe, it, expect } from "bun:test"; | |
| 10 | import { | |
| 11 | __test, | |
| 12 | classifyMaterial, | |
| 13 | deliverStandup, | |
| 14 | generateStandup, | |
| 15 | hasStandupForToday, | |
| 16 | utcDayKey, | |
| 17 | } from "../lib/ai-standup"; | |
| 18 | ||
| 19 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 20 | ||
| 21 | // --------------------------------------------------------------------------- | |
| 22 | // Pure helpers — no DB, no AI client. | |
| 23 | // --------------------------------------------------------------------------- | |
| 24 | ||
| 25 | describe("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 | ||
| 38 | describe("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 | ||
| 141 | describe("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 | ||
| 173 | describe("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 | ||
| 192 | describe("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 | ||
| 223 | describe("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 | }); |