Ctrl K
ComunidadGuías…

test/shared/google-wallet.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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> = {},
): 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<string, string>[] },
  id: string,
): Record<string, string> =>
  obj.textModulesData.find((m) => m.id === id) as Record<string, string>;

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<string, string>) => 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<string, unknown[]>;
      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);
    });
  });
});