import { describe, expect, it } from "vitest"; import { CERTIFICATION_CARD_ASPECT, CERTIFICATION_CARD_ASPECT_CLASS, CERTIFICATION_EXPIRING_SOON_DAYS, certificationExpiryBadgeVariant, certificationExpiryLabel, certificationExpiryStatus, certificationFileVersion, certificationRenewals, renewables, } from "./certification"; import type { CertificationFileInfo } from "./api/certifications"; const TODAY = "2026-08-10"; const file = ( overrides: Partial = {}, ): CertificationFileInfo => ({ uuid: "019fe94e-c13c-7166-9dad-5af54eb08319", side: "front", content_type: "image/png", byte_size: 1234, original_filename: "card.png", updated_at: null, ...overrides, }); describe("certificationFileVersion", () => { it("is null when there is no file", () => { expect(certificationFileVersion(null)).toBeNull(); expect(certificationFileVersion(undefined)).toBeNull(); }); it("changes when a file is replaced", () => { // The regression this exists for: replacing a card image keeps the same // certification, the same side and the same row, so anything keyed on those // alone leaves the *old* image on screen. Only `updated_at` moves. const before = certificationFileVersion(file()); const after = certificationFileVersion( file({ updated_at: "2026-08-10T02:00:08.371436Z" }), ); expect(after).not.toBe(before); }); it("changes again on a second replace", () => { const first = certificationFileVersion( file({ updated_at: "2026-08-10T02:00:08.371436Z" }), ); const second = certificationFileVersion( file({ updated_at: "2026-08-10T02:04:11.902001Z" }), ); expect(second).not.toBe(first); }); it("changes when a file is deleted and re-uploaded", () => { // That path inserts a new row rather than updating the old one, so the uuid // moves while `updated_at` goes back to null - which would otherwise collide // with the original version token exactly. const before = certificationFileVersion(file()); const after = certificationFileVersion( file({ uuid: "019fe966-119e-755e-9957-9f0f63d8101a" }), ); expect(after).not.toBe(before); }); it("is stable while the file is unchanged", () => { // Repeat views must keep hitting the same cache entry rather than refetching // megabytes on every mount. expect(certificationFileVersion(file())).toBe( certificationFileVersion(file()), ); }); it("does not change for metadata that doesn't affect the bytes served", () => { // `side` is already part of the URL, so folding it in here would only cause // needless refetches. expect(certificationFileVersion(file({ side: "back" }))).toBe( certificationFileVersion(file()), ); }); }); describe("certificationExpiryStatus", () => { it("is null when there is no expiry date", () => { // Most recreational certifications never expire, so this is the common case. expect(certificationExpiryStatus(null, TODAY)).toBeNull(); expect(certificationExpiryStatus(undefined, TODAY)).toBeNull(); expect(certificationExpiryStatus("", TODAY)).toBeNull(); }); it("is null for an expiry comfortably in the future", () => { expect(certificationExpiryStatus("2027-08-10", TODAY)).toBeNull(); }); it("flags an expiry in the past", () => { expect(certificationExpiryStatus("2026-08-09", TODAY)).toBe("expired"); expect(certificationExpiryStatus("2020-01-01", TODAY)).toBe("expired"); }); it("treats an expiry today as still valid, not expired", () => { // A card is good through its printed expiry date - a shop would accept it. expect(certificationExpiryStatus(TODAY, TODAY)).toBe("expiring_soon"); }); it("flags an expiry inside the notice window", () => { expect(certificationExpiryStatus("2026-09-01", TODAY)).toBe( "expiring_soon", ); }); it("includes the last day of the notice window", () => { // 2026-08-10 + 90 days expect(certificationExpiryStatus("2026-11-08", TODAY)).toBe( "expiring_soon", ); }); it("excludes the day after the notice window", () => { expect(certificationExpiryStatus("2026-11-09", TODAY)).toBeNull(); }); it("uses a 90-day window", () => { expect(CERTIFICATION_EXPIRING_SOON_DAYS).toBe(90); }); it("crosses a year boundary correctly", () => { expect(certificationExpiryStatus("2027-01-05", "2026-12-20")).toBe( "expiring_soon", ); expect(certificationExpiryStatus("2026-12-20", "2027-01-05")).toBe( "expired", ); }); }); describe("certificationRenewals", () => { const card = (name: string, expires_on: string | null) => ({ name, expires_on, }); it("is empty when nothing needs renewing", () => { // The normal case for a recreational diver, and what keeps the notifications // bell's renewals section out of it entirely. expect( certificationRenewals( [card("OW", null), card("AOW", "2030-01-01")], TODAY, ), ).toEqual([]); }); it("keeps only the cards inside the notice window or already gone", () => { const renewals = certificationRenewals( [ card("OW", null), card("Rescue", "2026-09-01"), card("Nitrox", "2030-01-01"), card("EFR", "2026-01-01"), ], TODAY, ); expect(renewals.map((renewal) => renewal.certification.name)).toEqual([ "EFR", "Rescue", ]); }); it("puts expired cards ahead of ones merely expiring soon", () => { // Sorting is by date alone; this is the property that behaviour rests on, so it // gets its own test rather than being assumed from the implementation. const renewals = certificationRenewals( [card("Rescue", "2026-10-30"), card("EFR", "2026-08-09")], TODAY, ); expect(renewals.map((renewal) => renewal.status)).toEqual([ "expired", "expiring_soon", ]); }); it("orders several expired cards oldest first", () => { const renewals = certificationRenewals( [card("EFR", "2025-01-01"), card("Rescue", "2019-06-30")], TODAY, ); expect(renewals.map((renewal) => renewal.certification.name)).toEqual([ "Rescue", "EFR", ]); }); it("narrows the expiry date to a plain string", () => { // What lets the card render a date without a non-null assertion of its own. const [renewal] = certificationRenewals([card("EFR", "2026-09-01")], TODAY); expect(renewal.expiresOn).toBe("2026-09-01"); }); }); describe("renewables", () => { it("lists every dated policy beside the cards, keyed by place and named by provider", () => { const rows = renewables( [ { uuid: "cert-1", agency: "padi", name: "Rescue Diver", expires_on: "2026-09-01", }, ], [ { provider: "DAN Europe", number: "P-1", expires_on: "2026-08-20" }, { provider: "Never expires", number: null, expires_on: null }, { provider: "DiveAssure", number: null, expires_on: "2026-08-30" }, ], ); expect(rows.map(({ key, kind, title }) => [key, kind, title])).toEqual([ ["cert-1", "certification", "Rescue Diver"], ["insurance-0", "insurance", "DAN Europe"], ["insurance-2", "insurance", "DiveAssure"], ]); expect(rows[1]).toMatchObject({ detail: "Dive insurance", href: "/settings/checkin", expires_on: "2026-08-20", }); }); }); describe("expiry presentation", () => { it("labels each status", () => { expect(certificationExpiryLabel("expired")).toBe("Expired"); expect(certificationExpiryLabel("expiring_soon")).toBe("Expiring soon"); }); it("reserves the loudest badge for an already-expired card", () => { expect(certificationExpiryBadgeVariant("expired")).toBe("destructive"); expect(certificationExpiryBadgeVariant("expiring_soon")).toBe("coral"); }); }); // The number drives the cropper and the class drives every frame the app draws, so // the two disagreeing means a card is cropped to one shape and shown in another - // which is exactly the letterboxing this pair exists to remove, reintroduced where // nothing would look for it. describe("the card aspect ratio has one value in two spellings", () => { it("writes the same ratio in the Tailwind class as in the number", () => { const [, width, height] = CERTIFICATION_CARD_ASPECT_CLASS.match(/^aspect-\[(\d+)\/(\d+)\]$/) ?? []; expect(width).toBeDefined(); expect(Number(width) / Number(height)).toBeCloseTo( CERTIFICATION_CARD_ASPECT, 10, ); }); });