← back to Govarbitrage
src/lib/listing-detail.test.ts
86 lines
import { describe, expect, it } from "vitest";
import { Prisma } from "@prisma/client";
import { decimalsToNumbers } from "./listing-detail";
// Regression test for TK-10279: the listing DETAIL endpoint (unlike the LIST
// endpoint's flattenListing(), which already ran every Decimal through
// num()/numN()) used to return raw Prisma Decimal instances straight to
// NextResponse.json(). JSON.stringify() serializes a Decimal via its own
// toJSON() -> a STRING ("103.93"), which the mobile client's Number.isFinite()
// render guards (fmtUSD/fmtPct/fmtScore) correctly treat as non-finite and
// render "—" -- so a fully-researched listing showed an all-null Valuation
// table + null Recommended Max Bid on the detail screen while the LIST screen
// (same listing, same underlying data) showed real numbers. This test locks
// in the fix: decimalsToNumbers() must turn every Decimal in the object graph
// into a real `number` so it survives a JSON round-trip as a JSON number, not
// a string.
describe("decimalsToNumbers (TK-10279 detail-screen null-valuation regression)", () => {
it("converts a top-level Decimal to a real number", () => {
const out = decimalsToNumbers(new Prisma.Decimal("103.93"));
expect(typeof out).toBe("number");
expect(out).toBe(103.93);
});
it("converts nested Decimals inside relation objects (costBreakdown / research)", () => {
const listing = {
id: "l1",
currentBid: new Prisma.Decimal("10"),
research: {
newRetail: new Prisma.Decimal("900"),
avgRetail: new Prisma.Decimal("828"),
usedLow: null,
},
costBreakdown: {
recommendedMaxBid: new Prisma.Decimal("103.93"),
expectedNetProfit: new Prisma.Decimal("182.96"),
roi: 1.91, // Float column — already a plain number, must pass through unchanged
},
scores: [{ value: 82.07, risk: "MEDIUM" }],
};
const fixed = decimalsToNumbers(listing);
expect(typeof fixed.currentBid).toBe("number");
expect(fixed.currentBid).toBe(10);
expect(typeof fixed.research!.newRetail).toBe("number");
expect(fixed.research!.newRetail).toBe(900);
expect(fixed.research!.usedLow).toBeNull();
expect(typeof fixed.costBreakdown!.recommendedMaxBid).toBe("number");
expect(fixed.costBreakdown!.recommendedMaxBid).toBe(103.93);
expect(fixed.costBreakdown!.roi).toBe(1.91);
expect(fixed.scores[0].value).toBe(82.07);
});
it("survives a JSON.stringify round-trip as a JSON number, not a string (the actual client-visible bug)", () => {
const raw = { costBreakdown: { recommendedMaxBid: new Prisma.Decimal("4736.72") } };
// BEFORE the fix: JSON.stringify(raw) directly would produce
// '{"costBreakdown":{"recommendedMaxBid":"4736.72"}}' — a STRING.
const buggyRoundTrip = JSON.parse(JSON.stringify(raw));
expect(typeof buggyRoundTrip.costBreakdown.recommendedMaxBid).toBe("string");
expect(Number.isFinite(buggyRoundTrip.costBreakdown.recommendedMaxBid)).toBe(false);
// AFTER the fix: decimalsToNumbers() first, then serialize.
const fixedRoundTrip = JSON.parse(JSON.stringify(decimalsToNumbers(raw)));
expect(typeof fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe("number");
expect(Number.isFinite(fixedRoundTrip.costBreakdown.recommendedMaxBid)).toBe(true);
expect(fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe(4736.72);
});
it("leaves non-Decimal values (strings, Dates, null, arrays) untouched", () => {
const now = new Date("2026-09-03T00:00:00Z");
const out = decimalsToNumbers({
title: "Late 2013 Apple iMac 21.5\"",
sourceAuctionId: "29250-189",
closingAt: now,
imageUrls: ["https://example.com/a.jpg"],
description: null,
});
expect(out.title).toBe("Late 2013 Apple iMac 21.5\"");
expect(out.sourceAuctionId).toBe("29250-189");
expect(out.closingAt).toBe(now);
expect(out.imageUrls).toEqual(["https://example.com/a.jpg"]);
expect(out.description).toBeNull();
});
});