diff --git a/src/lib/adapters.test.ts b/src/lib/adapters.test.ts index a23089d..f38d034 100644 --- a/src/lib/adapters.test.ts +++ b/src/lib/adapters.test.ts @@ -120,3 +120,40 @@ describe("adaptBounty — field coverage audit (#86)", () => { } }); }); + + +describe("adaptBounty — status validation and coercion (#279)", () => { + it("preserves valid BountyStatus values", () => { + const validStatuses = [ + "open", + "funded", + "claimed", + "in_review", + "merged", + "paid", + "refunded", + "expired", + ] as const; + + for (const status of validStatuses) { + const raw = rawBounty({ status }); + expect(adaptBounty(raw).status).toBe(status); + } + }); + + it("coerces an unrecognized backend status to the safe fallback 'open'", () => { + const raw = rawBounty({ status: "some_unrecognized_future_status" as any }); + expect(adaptBounty(raw).status).toBe("open"); + }); + + it("coerces null or undefined or empty status to 'open'", () => { + const rawNull = rawBounty({ status: null as any }); + expect(adaptBounty(rawNull).status).toBe("open"); + + const rawUndefined = rawBounty({ status: undefined as any }); + expect(adaptBounty(rawUndefined).status).toBe("open"); + + const rawEmpty = rawBounty({ status: "" as any }); + expect(adaptBounty(rawEmpty).status).toBe("open"); + }); +}); diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 4bc707d..753c5e5 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -1,5 +1,6 @@ import type { Bounty, + BountyStatus, Milestone, MaintenancePool, TeamSplit, @@ -13,6 +14,27 @@ import { validateTeamSplits, } from "./utils"; +export const VALID_BOUNTY_STATUSES: readonly BountyStatus[] = [ + "open", + "funded", + "claimed", + "in_review", + "merged", + "paid", + "refunded", + "expired", +] as const; + +export function coerceBountyStatus( + status: unknown, + fallback: BountyStatus = "open", +): BountyStatus { + if (typeof status === "string" && (VALID_BOUNTY_STATUSES as readonly string[]).includes(status)) { + return status as BountyStatus; + } + return fallback; +} + // Shapes returned by mergefi-backend's TypeORM entities (see // mergefi-backend/src/common/entities). These are intentionally loose since // we only read the fields the UI needs. @@ -49,7 +71,7 @@ export interface RawBounty { amount: string; asset: "USDC" | "XLM"; difficulty: Difficulty; - status: Bounty["status"]; + status: Bounty["status"] | string; deadline: string | null; escrowId: string | null; issue?: RawIssue; @@ -107,7 +129,7 @@ export function adaptBounty(raw: RawBounty): Bounty & { teamSplitsValid?: { vali reward: coerceNonNegative(raw.amount), asset: raw.asset, difficulty: raw.difficulty, - status: raw.status, + status: coerceBountyStatus(raw.status), deadline: raw.deadline ?? new Date().toISOString(), labels: raw.issue?.labels ?? [], claimedBy: raw.claimedBy?.username,