import { assertArrayIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import type { WalletPassData } from "#routes/tickets/token-utils.ts"; import { startOfHour } from "#shared/dates.ts"; import { buildGoogleWalletUrl, buildJwtPayload, signJwt, } from "#shared/google-wallet.ts"; import { generateGoogleTestCreds } from "#test-utils/crypto.ts"; const makePassData = ( overrides: Partial = {}, ): WalletPassData => ({ attendeeDate: null, checkinUrl: "https://example.com/checkin/ABC123", currencyCode: "GBP", listingDate: "2026-06-15T19:00:00Z", listingLocation: "Town Hall", listingName: "Summer Concert", organizationName: "Test Platform", pricePaid: 0, quantity: 1, serialNumber: "ABC123", ...overrides, }); const findTextModule = ( obj: { textModulesData: Record[] }, id: string, ): Record => obj.textModulesData.find((m) => m.id === id) as Record; describe("google-wallet", () => { // generateGoogleTestCreds() is memoized, so one call yields the shared creds // for every test. const creds = generateGoogleTestCreds(); describe("buildGoogleWalletUrl integration", () => { /** Helper to extract JWT payload from a Google Wallet save URL */ const extractPayload = async (data: WalletPassData) => { const url = await buildGoogleWalletUrl(data, creds); const jwt = url.replace("https://pay.google.com/gp/v/save/", ""); const payloadPart = jwt.split(".")[1]!; const padded = payloadPart + "=".repeat((4 - (payloadPart.length % 4)) % 4); return JSON.parse(atob(padded.replace(/-/g, "+").replace(/_/g, "/"))); }; test("includes class with issuer name, listing name, and correct ids", async () => { const decoded = await extractPayload(makePassData()); const cls = decoded.payload.eventTicketClasses[0]; expect(cls.id).toBe("1234567890.ABC123-class"); expect(cls.issuerName).toBe("Test Platform"); expect(cls.eventName.defaultValue.value).toBe("Summer Concert"); expect(cls.eventName.defaultValue.language).toBe("en-US"); expect(cls.reviewStatus).toBe("UNDER_REVIEW"); }); test("includes object with id, classId, QR barcode, and ACTIVE state", async () => { const decoded = await extractPayload(makePassData()); const obj = decoded.payload.eventTicketObjects[0]; expect(obj.id).toBe("1234567890.ABC123"); expect(obj.classId).toBe("1234567890.ABC123-class"); expect(obj.state).toBe("ACTIVE"); expect(obj.barcode.type).toBe("QR_CODE"); expect(obj.barcode.value).toBe("https://example.com/checkin/ABC123"); }); test("includes dateTime when listingDate is present", async () => { const decoded = await extractPayload(makePassData()); const cls = decoded.payload.eventTicketClasses[0]; expect(cls.dateTime.start).toBe("2026-06-15T19:00:00Z"); }); test("omits dateTime when listingDate is empty", async () => { const decoded = await extractPayload(makePassData({ listingDate: "" })); const cls = decoded.payload.eventTicketClasses[0]; expect(cls.dateTime).toBeUndefined(); }); test("includes venue when listingLocation is present", async () => { const decoded = await extractPayload(makePassData()); const cls = decoded.payload.eventTicketClasses[0]; expect(cls.venue.name.defaultValue.value).toBe("Town Hall"); }); test("omits venue when listingLocation is empty", async () => { const decoded = await extractPayload( makePassData({ listingLocation: "" }), ); const cls = decoded.payload.eventTicketClasses[0]; expect(cls.venue).toBeUndefined(); }); test("omits textModulesData when no optional fields", async () => { const decoded = await extractPayload(makePassData()); const obj = decoded.payload.eventTicketObjects[0]; expect(obj.textModulesData).toBeUndefined(); }); test("includes booking date when attendeeDate is present", async () => { const decoded = await extractPayload( makePassData({ attendeeDate: "2026-06-15" }), ); const obj = decoded.payload.eventTicketObjects[0]; const bookingDate = obj.textModulesData.find( (m: Record) => m.id === "booking-date", ); expect(bookingDate).toBeDefined(); expect(bookingDate.body).toBe("2026-06-15"); }); test("includes quantity when greater than 1", async () => { const decoded = await extractPayload(makePassData({ quantity: 3 })); const obj = decoded.payload.eventTicketObjects[0]; const qty = findTextModule(obj, "qty"); expect(qty).toBeDefined(); expect(qty.body).toBe("3"); }); test("omits quantity when equal to 1", async () => { const decoded = await extractPayload(makePassData({ quantity: 1 })); const obj = decoded.payload.eventTicketObjects[0]; expect(obj.textModulesData).toBeUndefined(); }); test("includes price with 2-decimal currency (GBP)", async () => { const decoded = await extractPayload( makePassData({ currencyCode: "EUR", pricePaid: 2500 }), ); const obj = decoded.payload.eventTicketObjects[0]; const price = findTextModule(obj, "price"); expect(price).toBeDefined(); expect(price.body).toBe("25 EUR"); }); test("includes price for one minor unit (GBP)", async () => { const decoded = await extractPayload( makePassData({ currencyCode: "GBP", pricePaid: 1 }), ); const obj = decoded.payload.eventTicketObjects[0]; const price = findTextModule(obj, "price"); expect(price.body).toBe("0.01 GBP"); }); test("omits price when zero", async () => { const decoded = await extractPayload(makePassData({ pricePaid: 0 })); const obj = decoded.payload.eventTicketObjects[0]; expect(obj.textModulesData).toBeUndefined(); }); test("formats price with 0-decimal currency (JPY)", async () => { const decoded = await extractPayload( makePassData({ currencyCode: "JPY", pricePaid: 1000 }), ); const obj = decoded.payload.eventTicketObjects[0]; const price = findTextModule(obj, "price"); expect(price.body).toBe("1000 JPY"); }); }); describe("buildJwtPayload", () => { test("includes required JWT claims", async () => { // The issued time is the start of the current hour in whole seconds; the // hour can roll over between the two reads. const before = Math.floor(startOfHour(new Date()).getTime() / 1000); const payload = buildJwtPayload(makePassData(), creds); const after = Math.floor(startOfHour(new Date()).getTime() / 1000); expect(payload.iss).toBe("test@test-project.iam.gserviceaccount.com"); expect(payload.aud).toBe("google"); expect(payload.typ).toBe("savetowallet"); assertArrayIncludes([before, after], [payload.iat]); }); test("includes listing ticket class and object in payload", async () => { const jwt = buildJwtPayload(makePassData(), creds); const inner = jwt.payload as Record; expect(inner.eventTicketClasses).toHaveLength(1); expect(inner.eventTicketObjects).toHaveLength(1); }); }); describe("signJwt", () => { test("produces a valid three-part JWT", async () => { const payload = buildJwtPayload(makePassData(), creds); const jwt = await signJwt(payload, creds.serviceAccountKey); const parts = jwt.split("."); expect(parts).toHaveLength(3); // JWT segments carry no base64 padding. expect(jwt).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); }); test("header indicates RS256 algorithm", async () => { const payload = buildJwtPayload(makePassData(), creds); const jwt = await signJwt(payload, creds.serviceAccountKey); const headerJson = atob( jwt.split(".")[0]!.replace(/-/g, "+").replace(/_/g, "/"), ); const header = JSON.parse(headerJson); expect(header.alg).toBe("RS256"); expect(header.typ).toBe("JWT"); }); test("payload contains the original data", async () => { const payload = buildJwtPayload(makePassData(), creds); const jwt = await signJwt(payload, creds.serviceAccountKey); const payloadPart = jwt.split(".")[1]!; // Add padding if needed const padded = payloadPart + "=".repeat((4 - (payloadPart.length % 4)) % 4); const payloadJson = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); const decoded = JSON.parse(payloadJson); expect(decoded.iss).toBe(creds.serviceAccountEmail); expect(decoded.aud).toBe("google"); }); test("produces different JWTs for different serial numbers", async () => { const a = await signJwt( buildJwtPayload(makePassData({ serialNumber: "AAA" }), creds), creds.serviceAccountKey, ); const b = await signJwt( buildJwtPayload(makePassData({ serialNumber: "BBB" }), creds), creds.serviceAccountKey, ); expect(a).not.toBe(b); }); }); describe("buildGoogleWalletUrl", () => { test("produces a URL starting with Google Wallet save prefix", async () => { const url = await buildGoogleWalletUrl(makePassData(), creds); expect(url).toMatch(/^https:\/\/pay\.google\.com\/gp\/v\/save\//); }); test("URL contains a valid JWT after the prefix", async () => { const url = await buildGoogleWalletUrl(makePassData(), creds); const jwt = url.replace("https://pay.google.com/gp/v/save/", ""); const parts = jwt.split("."); expect(parts).toHaveLength(3); }); }); });