src/lib/certification.test.ts
Rama main · réplica de lectura del repositorio original.
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> = {},
): 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,
);
});
});