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.
| 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 | ||
| ccec83a | 21 | describe("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 | ||
| 56801e1 | 51 | // --------------------------------------------------------------------------- |
| 52 | // Pure helpers — no DB, no AI client. | |
| 53 | // --------------------------------------------------------------------------- | |
| 54 | ||
| 55 | describe("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 | ||
| 68 | describe("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 | ||
| 171 | describe("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 | ||
| 203 | describe("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 | ||
| 222 | describe("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 | ||
| 253 | describe("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 | }); |