| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 3 | assessMigrationCapacity, |
| 4 | buildFirebaseGroups, |
| 5 | canonicalJSONString, |
| 6 | classifyMigrationGroup, |
| 7 | contentDigest, |
| 8 | firebaseOAuthGrantType, |
| 9 | runWrangler, |
| 10 | wranglerD1MaxBufferBytes, |
| 11 | } from "../scripts/migrate-firebase-data.mjs"; |
| 12 | |
| 13 | describe("Firebase retained-sample migration", () => { |
| 14 | it("uses the OAuth JWT bearer grant expected by Google's token endpoint", () => { |
| 15 | expect(firebaseOAuthGrantType).toBe("urn:ietf:params:oauth:grant-type:jwt-bearer"); |
| 16 | }); |
| 17 | |
| 18 | it("explains active reservations without treating stale open groups as automatically eligible", () => { |
| 19 | const now = new Date("2026-08-25T00:00:00Z"); |
| 20 | const assessment = assessMigrationCapacity([ |
| 21 | { status: "open", last_seen: "2026-08-20T00:00:00Z", fingerprint: "private", message: "private" }, |
| 22 | { status: "open", last_seen: "2026-07-10T00:00:00Z" }, |
| 23 | { status: "open", last_seen: "2026-06-01T00:00:00Z" }, |
| 24 | { status: "resolved", last_seen: "2026-08-20T00:00:00Z" }, |
| 25 | { status: "resolved", last_seen: "2026-07-10T00:00:00Z" }, |
| 26 | { status: "ignored", last_seen: "2026-06-01T00:00:00Z" }, |
| 27 | { status: "resolved", last_seen: "invalid" }, |
| 28 | { status: "future-status", last_seen: "2026-06-01T00:00:00Z" }, |
| 29 | ], now); |
| 30 | |
| 31 | expect(assessment.statusTotals).toEqual({ open: 3, resolved: 3, ignored: 1, other: 1 }); |
| 32 | expect(assessment.activeReasons).toEqual({ |
| 33 | open: 3, |
| 34 | otherStatus: 1, |
| 35 | recentResolvedOrIgnored: 1, |
| 36 | invalidResolvedOrIgnored: 1, |
| 37 | }); |
| 38 | expect(assessment.automaticRetention).toEqual({ compacted: 1, archived: 1 }); |
| 39 | expect(assessment.manualReview).toEqual({ open30to59d: 1, open60dPlus: 1, otherStatus30dPlus: 1 }); |
| 40 | expect(JSON.stringify(assessment)).not.toContain("private"); |
| 41 | }); |
| 42 | |
| 43 | it("captures a full retained-report page without using Node's default child-process buffer", () => { |
| 44 | let configuredMaxBuffer = 0; |
| 45 | const rows = runWrangler("/tmp/crash-worker", "reasonix-crash", "SELECT 1", ( |
| 46 | _command: string, |
| 47 | _args: string[], |
| 48 | options: { maxBuffer?: number }, |
| 49 | ) => { |
| 50 | configuredMaxBuffer = options.maxBuffer ?? 0; |
| 51 | return { status: 0, stdout: '[{"results":[{"value":1}]}]' }; |
| 52 | }); |
| 53 | |
| 54 | expect(rows).toEqual([{ value: 1 }]); |
| 55 | expect(configuredMaxBuffer).toBe(wranglerD1MaxBufferBytes); |
| 56 | expect(configuredMaxBuffer).toBeGreaterThan(200 * 6 * 96 * 1024); |
| 57 | }); |
| 58 | |
| 59 | it("keeps the first sample and maps the newest five into absolute ring slots", () => { |
| 60 | const fingerprint = "a".repeat(64); |
| 61 | const groups = buildFirebaseGroups([{ |
| 62 | fingerprint, |
| 63 | kind: "crash", |
| 64 | count: 8, |
| 65 | first_seen: "2026-08-01T00:00:00Z", |
| 66 | last_seen: "2026-08-08T00:00:00Z", |
| 67 | first_version: "v1", |
| 68 | last_version: "v2", |
| 69 | status: "open", |
| 70 | severity: "high", |
| 71 | }], [1, 4, 5, 6, 7, 8].map((id) => ({ |
| 72 | id, |
| 73 | fingerprint, |
| 74 | kind: "crash", |
| 75 | version: id === 1 ? "v1" : "v2", |
| 76 | os: "linux", |
| 77 | arch: "amd64", |
| 78 | message: `sample-${id}`, |
| 79 | created_at: `2026-08-0${id}T00:00:00Z`, |
| 80 | device: "{}", |
| 81 | breadcrumbs: "[]", |
| 82 | }))); |
| 83 | const entry = groups.get(fingerprint) as unknown as { state: string; value: { |
| 84 | meta: { count: number }; |
| 85 | samples: { first: { message: string; groupCount: number }; latest: Record<string, { message: string; eventId: string; sampleEpoch: number }> }; |
| 86 | } }; |
| 87 | const value = entry.value; |
| 88 | expect(entry.state).toBe("active"); |
| 89 | expect(value.meta.count).toBe(8); |
| 90 | expect(value.samples.first.message).toBe("sample-1"); |
| 91 | expect(value.samples.first.groupCount).toBe(1); |
| 92 | expect(Object.values(value.samples.latest).map((sample) => sample.message).sort()).toEqual([ |
| 93 | "sample-4", "sample-5", "sample-6", "sample-7", "sample-8", |
| 94 | ]); |
| 95 | expect(value.samples.latest[2].message).toBe("sample-8"); |
| 96 | expect(value.samples.latest[2].eventId).toMatch(/^[0-9a-f]{32}$/); |
| 97 | expect(value.samples.latest[2]).toMatchObject({ groupCount: 8, sampleEpoch: 1 }); |
| 98 | expect(JSON.stringify(value)).not.toContain("installId"); |
| 99 | }); |
| 100 | |
| 101 | it("classifies resolved retention windows and canonicalizes readback digests", () => { |
| 102 | const now = new Date("2026-08-25T00:00:00Z"); |
| 103 | expect(classifyMigrationGroup({ status: "open", last_seen: "2020-01-01T00:00:00Z" }, now)).toBe("active"); |
| 104 | expect(classifyMigrationGroup({ status: "resolved", last_seen: "2026-07-10T00:00:00Z" }, now)).toBe("compacted"); |
| 105 | expect(classifyMigrationGroup({ status: "ignored", last_seen: "2026-06-01T00:00:00Z" }, now)).toBe("archived"); |
| 106 | expect(canonicalJSONString({ b: 2, a: { d: 4, c: 3 } })).toBe('{"a":{"c":3,"d":4},"b":2}'); |
| 107 | expect(contentDigest({ b: 2, a: 1 })).toBe(contentDigest({ a: 1, b: 2 })); |
| 108 | }); |
| 109 | }); |
| 110 |