|
1 | 1 | import { describe, expect, it } from "vitest"; |
2 | 2 | import { createApp } from "../../src/api/routes"; |
3 | 3 | import { getDb } from "../../src/db/client"; |
4 | | -import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_COMPOSITE_PK_TABLES, RETENTION_PK_COLUMN, RETENTION_POLICY, retentionCutoffIsoForTable } from "../../src/db/retention"; |
| 4 | +import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_COMPOSITE_PK_TABLES, RETENTION_PK_COLUMN, RETENTION_POLICY, retentionCutoffIsoForTable, retentionDaysForTable } from "../../src/db/retention"; |
5 | 5 | import { computeFleetAnalytics } from "../../src/orb/analytics"; |
6 | 6 | import { listFleetInstances } from "../../src/orb/fleet-admin"; |
7 | 7 | import { getOrbGlobalStats } from "../../src/orb/outcomes"; |
@@ -417,6 +417,69 @@ describe("pruneExpiredRecords", () => { |
417 | 417 | const remaining = await env.DB.prepare("SELECT count(*) AS n FROM ai_review_cache").first<{ n: number }>(); |
418 | 418 | expect(remaining?.n).toBe(0); |
419 | 419 | }); |
| 420 | + |
| 421 | + // #10058: submitter_outcome_log and alert_dedup_claims both store CURRENT_TIMESTAMP-format timestamps |
| 422 | + // (`'YYYY-MM-DD HH:MM:SS'`, no `T`, no zone) because both writers omit the column and let the DB default |
| 423 | + // supply it -- unlike every other policy table's ISO-8601 `column`. pruneExpiredRecords binds an ISO cutoff |
| 424 | + // and compares as text; seeding in the exact writer-produced format (not `daysAgo`'s ISO string) is what |
| 425 | + // proves the comparison still resolves the right rows on both sides of the cutoff. |
| 426 | + it("prunes submitter_outcome_log older than its 90-day window and keeps recent rows (#10058)", async () => { |
| 427 | + const env = createTestEnv(); |
| 428 | + await env.DB.prepare( |
| 429 | + `INSERT INTO submitter_outcome_log (project, submitter, pull_number, outcome, recorded_at) |
| 430 | + VALUES |
| 431 | + ('acme/widgets', 'alice', 1, 'merged', '2026-01-01 00:00:00'), |
| 432 | + ('acme/widgets', 'alice', 2, 'merged', '2026-06-12 00:00:00')`, |
| 433 | + ).run(); |
| 434 | + |
| 435 | + const rule = RETENTION_POLICY.find((r) => r.table === "submitter_outcome_log"); |
| 436 | + expect(rule).toEqual({ table: "submitter_outcome_log", column: "recorded_at", days: 90 }); |
| 437 | + |
| 438 | + const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!] }); |
| 439 | + expect(results[0]?.deleted).toBe(1); |
| 440 | + const rows = await env.DB.prepare("SELECT pull_number FROM submitter_outcome_log").all<{ pull_number: number }>(); |
| 441 | + expect(rows.results.map((row) => row.pull_number)).toEqual([2]); |
| 442 | + }); |
| 443 | + |
| 444 | + it("prunes alert_dedup_claims older than its 14-day window and keeps recent rows (#10058)", async () => { |
| 445 | + const env = createTestEnv(); |
| 446 | + await env.DB.prepare( |
| 447 | + `INSERT INTO alert_dedup_claims (id, project, target_id, notification_key, status, created_at) |
| 448 | + VALUES |
| 449 | + ('adc-old', 'acme/widgets', '__healthcheck__', 'hash-old', 'sent', '2026-01-01 00:00:00'), |
| 450 | + ('adc-recent', 'acme/widgets', '__healthcheck__', 'hash-recent', 'sent', '2026-06-12 00:00:00')`, |
| 451 | + ).run(); |
| 452 | + |
| 453 | + const rule = RETENTION_POLICY.find((r) => r.table === "alert_dedup_claims"); |
| 454 | + expect(rule).toEqual({ table: "alert_dedup_claims", column: "created_at", days: 14 }); |
| 455 | + |
| 456 | + const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!] }); |
| 457 | + expect(results[0]?.deleted).toBe(1); |
| 458 | + const rows = await env.DB.prepare("SELECT id FROM alert_dedup_claims").all<{ id: string }>(); |
| 459 | + expect(rows.results.map((row) => row.id)).toEqual(["adc-recent"]); |
| 460 | + }); |
| 461 | + |
| 462 | + // pkColumnFor's two arms (src/db/retention.ts:211): alert_dedup_claims has a genuine `id` mapping in |
| 463 | + // RETENTION_PK_COLUMN, while submitter_outcome_log's composite PK means it falls back to the `?? "rowid"` |
| 464 | + // arm. A small batchSize also proves the batched-delete loop's `changes < batchSize` exit (line 420) fires |
| 465 | + // on a real partial-then-final pair of iterations, not only on an empty first pass. |
| 466 | + it("submitter_outcome_log's composite PK falls back to rowid ordering across multiple batches (#10058)", async () => { |
| 467 | + const env = createTestEnv(); |
| 468 | + await env.DB.prepare( |
| 469 | + `INSERT INTO submitter_outcome_log (project, submitter, pull_number, outcome, recorded_at) |
| 470 | + VALUES |
| 471 | + ('acme/widgets', 'alice', 1, 'merged', '2026-01-01 00:00:00'), |
| 472 | + ('acme/widgets', 'alice', 2, 'closed', '2026-01-02 00:00:00'), |
| 473 | + ('acme/widgets', 'alice', 3, 'merged', '2026-01-03 00:00:00'), |
| 474 | + ('acme/widgets', 'bob', 1, 'merged', '2026-06-12 00:00:00')`, |
| 475 | + ).run(); |
| 476 | + |
| 477 | + const rule = RETENTION_POLICY.find((r) => r.table === "submitter_outcome_log"); |
| 478 | + const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!], batchSize: 2 }); |
| 479 | + expect(results[0]?.deleted).toBe(3); |
| 480 | + const remaining = await env.DB.prepare("SELECT count(*) AS n FROM submitter_outcome_log").first<{ n: number }>(); |
| 481 | + expect(remaining?.n).toBe(1); |
| 482 | + }); |
420 | 483 | }); |
421 | 484 |
|
422 | 485 | describe("dedupeSignalSnapshots", () => { |
@@ -976,6 +1039,13 @@ describe("retentionCutoffIsoForTable (#9474)", () => { |
976 | 1039 | // Within a second of a locally computed 180-day cutoff -- pins the default-arg arm without clock flake. |
977 | 1040 | expect(Math.abs(Date.parse(cutoff!) - (Date.now() - 180 * 86_400_000))).toBeLessThan(1000); |
978 | 1041 | }); |
| 1042 | + |
| 1043 | + // #10058 regression: pins the two windows so a future edit that drops or shrinks either entry fails loudly |
| 1044 | + // rather than silently restoring unbounded growth on submitter_outcome_log or alert_dedup_claims. |
| 1045 | + it("submitter_outcome_log is 90 days and alert_dedup_claims is 14 days (#10058)", () => { |
| 1046 | + expect(retentionDaysForTable("submitter_outcome_log")).toBe(90); |
| 1047 | + expect(retentionDaysForTable("alert_dedup_claims")).toBe(14); |
| 1048 | + }); |
979 | 1049 | }); |
980 | 1050 |
|
981 | 1051 | // #9783: orb_signals is a PUBLIC data source (computeFleetAnalytics' /fairness headline, #9775's weekly fleet |
|
0 commit comments