import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { validateAddressWithShippo, PREFERRED_CARRIERS, computeParcel, getShippingRatesFromShippo, selectBestRate, } from "./shippo"; import type { ValidatedCartItem } from "./checkout"; import type { ShippoRate } from "./checkout"; import type { Id } from "../_generated/dataModel"; const validInput = { addressLine1: "10 Downing Street", city: "London", postalCode: "SW1A 2AA", country: "GB", }; const shippoValidResponse = { original_address: { address_line_1: "10 Downing Street", address_line_2: undefined, city_locality: "London", state_province: "Westminster", postal_code: "SW1A 2AA", country_code: "GB", }, analysis: { validation_result: { value: "valid", reasons: [], }, address_type: "unknown", changed_attributes: [], }, }; const shippoPartiallyValidResponse = { original_address: { address_line_1: "10 Downing St", city_locality: "London", state_province: "Westminster", postal_code: "SW1A 2AA", country_code: "GB", }, recommended_address: { address_line_1: "10 Downing Street", address_line_2: "Flat 1", city_locality: "London", state_province: "Westminster", postal_code: "SW1A 2AA", country_code: "GB", complete_address: "10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM", confidence_result: { score: "high", code: "postal_data_match", description: "Matched via postal data", }, }, analysis: { validation_result: { value: "partially_valid", reasons: [{ code: "street_suffix", description: "Street suffix corrected" }], }, address_type: "unknown", changed_attributes: ["address_line_1", "address_line_2"], }, }; const shippoInvalidResponse = { original_address: { address_line_1: "999 Nowhere Lane", city_locality: "Faketown", state_province: "", postal_code: "ZZ99 9ZZ", country_code: "GB", }, analysis: { validation_result: { value: "invalid", reasons: [ { code: "address_not_found", description: "Address could not be found" }, { code: "invalid_postal_code", description: "Postal code is not valid" }, ], }, address_type: "unknown", }, }; let fetchSpy: ReturnType; beforeEach(() => { fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123"); }); afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); function mockFetchOk(body: unknown) { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(body), }); } describe("validateAddressWithShippo", () => { // ── Request construction ──────────────────────────────────────────── it("builds correct URL with mapped query params (no state_province)", async () => { mockFetchOk(shippoValidResponse); await validateAddressWithShippo({ addressLine1: "10 Downing Street", additionalInformation: "Flat 1", city: "London", postalCode: "SW1A 2AA", country: "GB", name: "Alice Smith", }); expect(fetchSpy).toHaveBeenCalledTimes(1); const url = new URL(fetchSpy.mock.calls[0][0]); expect(url.origin + url.pathname).toBe( "https://api.goshippo.com/v2/addresses/validate", ); expect(url.searchParams.get("address_line_1")).toBe("10 Downing Street"); expect(url.searchParams.get("address_line_2")).toBe("Flat 1"); expect(url.searchParams.get("city_locality")).toBe("London"); expect(url.searchParams.get("postal_code")).toBe("SW1A 2AA"); expect(url.searchParams.get("country_code")).toBe("GB"); expect(url.searchParams.get("name")).toBe("Alice Smith"); expect(url.searchParams.has("state_province")).toBe(false); }); it("omits optional params when not provided", async () => { mockFetchOk(shippoValidResponse); await validateAddressWithShippo(validInput); const url = new URL(fetchSpy.mock.calls[0][0]); expect(url.searchParams.has("address_line_2")).toBe(false); expect(url.searchParams.has("name")).toBe(false); expect(url.searchParams.has("state_province")).toBe(false); }); it("sends ShippoToken authorization header", async () => { mockFetchOk(shippoValidResponse); await validateAddressWithShippo(validInput); const opts = fetchSpy.mock.calls[0][1]; expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123"); expect(opts.method).toBe("GET"); }); // ── Valid address (no corrections) ────────────────────────────────── it("returns isValid: true for a fully valid address", async () => { mockFetchOk(shippoValidResponse); const result = await validateAddressWithShippo(validInput); expect(result.isValid).toBe(true); expect(result.validationValue).toBe("valid"); expect(result.reasons).toEqual([]); expect(result.addressType).toBe("unknown"); expect(result.changedAttributes).toEqual([]); expect(result.recommendedAddress).toBeUndefined(); }); it("maps originalAddress without state field", async () => { mockFetchOk(shippoValidResponse); const result = await validateAddressWithShippo(validInput); expect(result.originalAddress).toEqual({ addressLine1: "10 Downing Street", additionalInformation: undefined, city: "London", postalCode: "SW1A 2AA", country: "GB", }); expect(result.originalAddress).not.toHaveProperty("state"); }); // ── Partially valid address (with recommended) ───────────────────── it("returns partially_valid result with recommendedAddress mapped", async () => { mockFetchOk(shippoPartiallyValidResponse); const result = await validateAddressWithShippo(validInput); expect(result.isValid).toBe(false); expect(result.validationValue).toBe("partially_valid"); expect(result.addressType).toBe("unknown"); expect(result.changedAttributes).toEqual(["address_line_1", "address_line_2"]); expect(result.reasons).toEqual([ { code: "street_suffix", description: "Street suffix corrected" }, ]); }); it("maps recommended address with additionalInformation (no state)", async () => { mockFetchOk(shippoPartiallyValidResponse); const result = await validateAddressWithShippo(validInput); const rec = result.recommendedAddress; expect(rec).toBeDefined(); expect(rec!.addressLine1).toBe("10 Downing Street"); expect(rec!.additionalInformation).toBe("Flat 1"); expect(rec!.city).toBe("London"); expect(rec!.postalCode).toBe("SW1A 2AA"); expect(rec!.country).toBe("GB"); expect(rec!.completeAddress).toBe( "10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM", ); expect(rec!.confidenceScore).toBe("high"); expect(rec!.confidenceCode).toBe("postal_data_match"); expect(rec!.confidenceDescription).toBe("Matched via postal data"); expect(rec).not.toHaveProperty("state"); }); // ── Invalid address ──────────────────────────────────────────────── it("returns isValid: false with reasons for an invalid address", async () => { mockFetchOk(shippoInvalidResponse); const result = await validateAddressWithShippo({ addressLine1: "999 Nowhere Lane", city: "Faketown", postalCode: "ZZ99 9ZZ", country: "GB", }); expect(result.isValid).toBe(false); expect(result.validationValue).toBe("invalid"); expect(result.addressType).toBe("unknown"); expect(result.recommendedAddress).toBeUndefined(); expect(result.reasons).toHaveLength(2); expect(result.reasons[0].code).toBe("address_not_found"); expect(result.reasons[1].code).toBe("invalid_postal_code"); }); it("defaults changedAttributes to [] when missing from response", async () => { mockFetchOk(shippoInvalidResponse); const result = await validateAddressWithShippo({ addressLine1: "999 Nowhere Lane", city: "Faketown", postalCode: "ZZ99 9ZZ", country: "GB", }); expect(result.changedAttributes).toEqual([]); }); // ── Error handling ───────────────────────────────────────────────── it("throws when SHIPPO_API_KEY is missing", async () => { vi.stubEnv("SHIPPO_API_KEY", ""); await expect(validateAddressWithShippo(validInput)).rejects.toThrow( /missing API key/i, ); }); it("throws when fetch rejects (network error)", async () => { fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); await expect(validateAddressWithShippo(validInput)).rejects.toThrow( /unreachable/i, ); }); it("throws when Shippo returns non-200 status", async () => { fetchSpy.mockResolvedValue({ ok: false, status: 503, json: () => Promise.resolve({}), }); await expect(validateAddressWithShippo(validInput)).rejects.toThrow( /unavailable.*503/i, ); }); it("throws when response body is not valid JSON", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError("Unexpected token")), }); await expect(validateAddressWithShippo(validInput)).rejects.toThrow( /unexpected response/i, ); }); it("throws when response is missing analysis.validation_result", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ original_address: {}, analysis: {} }), }); await expect(validateAddressWithShippo(validInput)).rejects.toThrow( /malformed/i, ); }); }); // ─── Test helpers ─────────────────────────────────────────────────────────── function makeCartItem(overrides: Partial = {}): ValidatedCartItem { return { variantId: "variant1" as Id<"productVariants">, productId: "product1" as Id<"products">, quantity: 1, unitPrice: 1000, originalPrice: 1000, productName: "Test Product", variantName: "Default", sku: "TST-001", imageUrl: undefined, stockQuantity: 10, weight: 500, weightUnit: "g", length: undefined, width: undefined, height: undefined, dimensionUnit: undefined, productSlug: "test-product", parentCategorySlug: "parent", childCategorySlug: "child", ...overrides, }; } function makeShippoRate(overrides: Partial = {}): ShippoRate { return { objectId: "rate_abc123", provider: "DPD UK", servicelevelName: "Next Day", servicelevelToken: "dpd_uk_next_day", amount: "5.50", currency: "GBP", estimatedDays: 1, durationTerms: "1-2 business days", arrivesBy: null, carrierAccount: "ca_abc123", ...overrides, }; } // ─── PREFERRED_CARRIERS ───────────────────────────────────────────────────── describe("PREFERRED_CARRIERS", () => { it("contains the four expected carriers", () => { expect(PREFERRED_CARRIERS).toEqual(["DPD UK", "Evri UK", "UPS", "UDS"]); }); }); // ─── computeParcel ────────────────────────────────────────────────────────── describe("computeParcel", () => { // ── Weight normalization ──────────────────────────────────────────── it("sums weights in grams for items with weightUnit 'g'", () => { const items = [ makeCartItem({ weight: 200, weightUnit: "g", quantity: 2 }), makeCartItem({ weight: 300, weightUnit: "g", quantity: 1 }), ]; const result = computeParcel(items); expect(result.weight).toBe("700"); expect(result.mass_unit).toBe("g"); }); it("converts kg to grams", () => { const items = [makeCartItem({ weight: 1.5, weightUnit: "kg", quantity: 1 })]; const result = computeParcel(items); expect(result.weight).toBe("1500"); }); it("converts lb to grams", () => { const items = [makeCartItem({ weight: 1, weightUnit: "lb", quantity: 1 })]; const result = computeParcel(items); expect(result.weight).toBe("454"); }); it("converts oz to grams", () => { const items = [makeCartItem({ weight: 1, weightUnit: "oz", quantity: 1 })]; const result = computeParcel(items); expect(result.weight).toBe("28"); }); it("multiplies weight by quantity", () => { const items = [makeCartItem({ weight: 100, weightUnit: "g", quantity: 5 })]; const result = computeParcel(items); expect(result.weight).toBe("500"); }); it("handles mixed weight units across items", () => { const items = [ makeCartItem({ weight: 500, weightUnit: "g", quantity: 1 }), makeCartItem({ weight: 1, weightUnit: "kg", quantity: 2 }), ]; const result = computeParcel(items); // 500g + (1kg * 2) = 500 + 2000 = 2500g expect(result.weight).toBe("2500"); }); // ── No dimensions ────────────────────────────────────────────────── it("omits dimension fields when no items have dimensions", () => { const items = [makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined })]; const result = computeParcel(items); expect(result).not.toHaveProperty("length"); expect(result).not.toHaveProperty("width"); expect(result).not.toHaveProperty("height"); expect(result).not.toHaveProperty("distance_unit"); }); it("omits dimensions when only some dimension fields present", () => { const items = [makeCartItem({ length: 10, width: undefined, height: 5, dimensionUnit: "cm" })]; const result = computeParcel(items); expect(result).not.toHaveProperty("length"); }); // ── With dimensions ──────────────────────────────────────────────── it("computes dimensions in cm: max length, max width, sum height", () => { const items = [ makeCartItem({ length: 30, width: 20, height: 5, dimensionUnit: "cm", quantity: 2 }), makeCartItem({ length: 25, width: 25, height: 3, dimensionUnit: "cm", quantity: 1 }), ]; const result = computeParcel(items); expect(result.length).toBe("30"); expect(result.width).toBe("25"); // height: (5*2) + (3*1) = 13 expect(result.height).toBe("13"); expect(result.distance_unit).toBe("cm"); }); it("converts inches to cm for dimensions", () => { const items = [ makeCartItem({ length: 10, width: 8, height: 4, dimensionUnit: "in", quantity: 1 }), ]; const result = computeParcel(items); expect(result.length).toBe("25.4"); expect(result.width).toBe("20.32"); expect(result.height).toBe("10.16"); expect(result.distance_unit).toBe("cm"); }); it("ignores items without full dimensions when computing parcel dimensions", () => { const withDims = makeCartItem({ length: 20, width: 15, height: 10, dimensionUnit: "cm", quantity: 1, weight: 200, weightUnit: "g" }); const withoutDims = makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined, quantity: 1, weight: 300, weightUnit: "g" }); const result = computeParcel([withDims, withoutDims]); expect(result.length).toBe("20"); expect(result.width).toBe("15"); expect(result.height).toBe("10"); // weight still sums both items expect(result.weight).toBe("500"); }); it("handles mixed dimension units across items", () => { const items = [ makeCartItem({ length: 10, width: 10, height: 5, dimensionUnit: "in", quantity: 1 }), makeCartItem({ length: 30, width: 20, height: 10, dimensionUnit: "cm", quantity: 1 }), ]; const result = computeParcel(items); // max length: max(25.4, 30) = 30 expect(result.length).toBe("30"); // max width: max(25.4, 20) = 25.4 expect(result.width).toBe("25.4"); // total height: 12.7 + 10 = 22.7 expect(result.height).toBe("22.7"); }); it("handles a single item with quantity > 1 stacking height", () => { const items = [makeCartItem({ length: 20, width: 15, height: 3, dimensionUnit: "cm", quantity: 4 })]; const result = computeParcel(items); expect(result.height).toBe("12"); }); }); // ─── getShippingRatesFromShippo ───────────────────────────────────────────── describe("getShippingRatesFromShippo", () => { const validShipmentsInput = { sourceAddressId: "addr_source_123", destinationAddress: { name: "John Doe", street1: "10 Downing Street", city: "London", zip: "SW1A 2AA", country: "GB", }, parcels: [{ weight: "500", mass_unit: "g" }], }; const shippoShipmentsResponse = { object_id: "shp_abc123", rates: [ { object_id: "rate_001", provider: "DPD UK", servicelevel: { name: "Next Day", token: "dpd_uk_next_day" }, amount: "5.50", currency: "GBP", estimated_days: 1, duration_terms: "1-2 business days", arrives_by: null, carrier_account: "ca_dpd_001", }, { object_id: "rate_002", provider: "UPS", servicelevel: { name: "Standard", token: "ups_standard" }, amount: "7.99", currency: "GBP", estimated_days: 3, duration_terms: "3-5 business days", carrier_account: "ca_ups_001", }, ], }; it("sends correct POST request to Shippo /shipments/ endpoint", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(shippoShipmentsResponse), }); await getShippingRatesFromShippo(validShipmentsInput); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, opts] = fetchSpy.mock.calls[0]; expect(url).toBe("https://api.goshippo.com/shipments/"); expect(opts.method).toBe("POST"); expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123"); expect(opts.headers["Content-Type"]).toBe("application/json"); const body = JSON.parse(opts.body); expect(body.address_from).toBe("addr_source_123"); expect(body.address_to.name).toBe("John Doe"); expect(body.address_to.street1).toBe("10 Downing Street"); expect(body.parcels).toHaveLength(1); expect(body.async).toBe(false); }); it("returns shipmentObjectId and mapped rates", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(shippoShipmentsResponse), }); const result = await getShippingRatesFromShippo(validShipmentsInput); expect(result.shipmentObjectId).toBe("shp_abc123"); expect(result.rates).toHaveLength(2); const rate1 = result.rates[0]; expect(rate1.objectId).toBe("rate_001"); expect(rate1.provider).toBe("DPD UK"); expect(rate1.servicelevelName).toBe("Next Day"); expect(rate1.servicelevelToken).toBe("dpd_uk_next_day"); expect(rate1.amount).toBe("5.50"); expect(rate1.currency).toBe("GBP"); expect(rate1.estimatedDays).toBe(1); expect(rate1.durationTerms).toBe("1-2 business days"); expect(rate1.arrivesBy).toBeNull(); expect(rate1.carrierAccount).toBe("ca_dpd_001"); }); it("maps arrives_by to null when absent from response", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(shippoShipmentsResponse), }); const result = await getShippingRatesFromShippo(validShipmentsInput); expect(result.rates[1].arrivesBy).toBeNull(); }); it("maps arrives_by when present in response", async () => { const responseWithArrival = { object_id: "shp_abc123", rates: [ { ...shippoShipmentsResponse.rates[0], arrives_by: "2025-03-05T18:00:00Z", }, ], }; fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(responseWithArrival), }); const result = await getShippingRatesFromShippo(validShipmentsInput); expect(result.rates[0].arrivesBy).toBe("2025-03-05T18:00:00Z"); }); it("throws when SHIPPO_API_KEY is missing", async () => { vi.stubEnv("SHIPPO_API_KEY", ""); await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow( /missing API key/i, ); }); it("throws when fetch rejects (network error)", async () => { fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow( /unreachable/i, ); }); it("throws when Shippo returns non-200 status", async () => { fetchSpy.mockResolvedValue({ ok: false, status: 422, json: () => Promise.resolve({}), }); await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow( /unavailable.*422/i, ); }); it("throws when response body is not valid JSON", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError("Unexpected token")), }); await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow( /unexpected response/i, ); }); it("returns empty rates array when Shippo returns no rates", async () => { fetchSpy.mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ object_id: "shp_empty", rates: [] }), }); const result = await getShippingRatesFromShippo(validShipmentsInput); expect(result.shipmentObjectId).toBe("shp_empty"); expect(result.rates).toEqual([]); }); }); // ─── selectBestRate ───────────────────────────────────────────────────────── describe("selectBestRate", () => { it("throws when rates array is empty", () => { expect(() => selectBestRate([])).toThrow( /no shipping rates available/i, ); }); it("selects preferred carrier rate with fewest transit days", () => { const rates = [ makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "4.00" }), makeShippoRate({ provider: "Evri UK", estimatedDays: 1, amount: "6.00" }), makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "3.00" }), ]; const { selected } = selectBestRate(rates); expect(selected.provider).toBe("Evri UK"); expect(selected.estimatedDays).toBe(1); }); it("breaks ties by cheapest amount among same transit days", () => { const rates = [ makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "8.00" }), makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }), makeShippoRate({ provider: "Evri UK", estimatedDays: 2, amount: "6.00" }), ]; const { selected } = selectBestRate(rates); expect(selected.provider).toBe("DPD UK"); expect(selected.amount).toBe("5.50"); }); it("returns up to 2 alternatives from preferred carriers", () => { const rates = [ makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }), makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "7.00" }), makeShippoRate({ provider: "Evri UK", estimatedDays: 3, amount: "4.00" }), makeShippoRate({ provider: "UDS", estimatedDays: 4, amount: "3.50" }), ]; const { selected, alternatives } = selectBestRate(rates); expect(selected.provider).toBe("DPD UK"); expect(alternatives).toHaveLength(2); expect(alternatives[0].provider).toBe("UPS"); expect(alternatives[1].provider).toBe("Evri UK"); }); it("uses case-insensitive matching for carrier names", () => { const rates = [ makeShippoRate({ provider: "dpd uk", estimatedDays: 1, amount: "5.50" }), makeShippoRate({ provider: "EVRI UK", estimatedDays: 2, amount: "4.00" }), ]; const { selected } = selectBestRate(rates); expect(selected.provider).toBe("dpd uk"); }); it("falls back to all carriers when no preferred carriers present", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const rates = [ makeShippoRate({ provider: "Royal Mail", estimatedDays: 3, amount: "3.00" }), makeShippoRate({ provider: "Parcelforce", estimatedDays: 1, amount: "9.00" }), ]; const { selected } = selectBestRate(rates); expect(selected.provider).toBe("Parcelforce"); expect(selected.estimatedDays).toBe(1); expect(warnSpy).toHaveBeenCalledWith( "No preferred carriers returned rates. Falling back to all carriers.", ); warnSpy.mockRestore(); }); it("fallback sorts all carriers by days then price", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const rates = [ makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "7.00" }), makeShippoRate({ provider: "Parcelforce", estimatedDays: 2, amount: "5.00" }), makeShippoRate({ provider: "Hermes", estimatedDays: 3, amount: "3.00" }), ]; const { selected, alternatives } = selectBestRate(rates); expect(selected.provider).toBe("Parcelforce"); expect(selected.amount).toBe("5.00"); expect(alternatives).toHaveLength(2); expect(alternatives[0].provider).toBe("Royal Mail"); expect(alternatives[1].provider).toBe("Hermes"); warnSpy.mockRestore(); }); it("returns single preferred rate with empty alternatives", () => { const rates = [ makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }), makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "3.00" }), ]; const { selected, alternatives } = selectBestRate(rates); expect(selected.provider).toBe("DPD UK"); expect(alternatives).toHaveLength(0); }); it("filters out non-preferred carriers from selection when preferred exist", () => { const rates = [ makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "2.00" }), makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }), ]; const { selected } = selectBestRate(rates); // DPD UK is preferred, even though Royal Mail is faster and cheaper expect(selected.provider).toBe("DPD UK"); }); it("handles single rate in the array", () => { const rates = [makeShippoRate({ provider: "UPS", estimatedDays: 3, amount: "10.00" })]; const { selected, alternatives } = selectBestRate(rates); expect(selected.provider).toBe("UPS"); expect(alternatives).toHaveLength(0); }); });