← 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();
  });
});