Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
332 lines
9.7 KiB
TypeScript
332 lines
9.7 KiB
TypeScript
import { convexTest } from "convex-test";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { api } from "./_generated/api";
|
|
import schema from "./schema";
|
|
import type { Id } from "./_generated/dataModel";
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
|
|
const identity = {
|
|
name: "Alice",
|
|
email: "alice@example.com",
|
|
subject: "clerk_alice_123",
|
|
};
|
|
|
|
const baseAddress = {
|
|
firstName: "Alice",
|
|
lastName: "Smith",
|
|
phone: "+447911123456",
|
|
addressLine1: "10 Downing Street",
|
|
city: "London",
|
|
postalCode: "SW1A 2AA",
|
|
country: "GB",
|
|
};
|
|
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.fn();
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123");
|
|
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "addr_warehouse_001");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
function mockShippoShipmentResponse(overrides?: {
|
|
shipmentId?: string;
|
|
rates?: Array<{
|
|
object_id: string;
|
|
provider: string;
|
|
servicelevel: { name: string; token: string };
|
|
amount: string;
|
|
currency: string;
|
|
estimated_days: number;
|
|
duration_terms: string;
|
|
arrives_by?: string | null;
|
|
carrier_account: string;
|
|
}>;
|
|
}) {
|
|
const defaultRates = [
|
|
{
|
|
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",
|
|
},
|
|
];
|
|
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () =>
|
|
Promise.resolve({
|
|
object_id: overrides?.shipmentId ?? "shp_test_123",
|
|
rates: overrides?.rates ?? defaultRates,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function setupFullCheckoutContext(
|
|
t: ReturnType<typeof convexTest>,
|
|
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
|
|
) {
|
|
const asA = t.withIdentity(identity);
|
|
const userId = await asA.mutation(api.users.store, {});
|
|
|
|
const addressId = await asA.mutation(api.addresses.add, {
|
|
...baseAddress,
|
|
type: "shipping",
|
|
additionalInformation: "Flat 2",
|
|
isDefault: true,
|
|
isValidated: true,
|
|
});
|
|
|
|
let productId: Id<"products">;
|
|
let variantId: Id<"productVariants">;
|
|
|
|
await t.run(async (ctx) => {
|
|
const categoryId = await ctx.db.insert("categories", {
|
|
name: "Dog Food",
|
|
slug: "dog-food",
|
|
});
|
|
productId = await ctx.db.insert("products", {
|
|
name: "Premium Kibble",
|
|
slug: "premium-kibble",
|
|
status: "active",
|
|
categoryId,
|
|
tags: [],
|
|
parentCategorySlug: "dogs",
|
|
childCategorySlug: "dog-food",
|
|
});
|
|
variantId = await ctx.db.insert("productVariants", {
|
|
productId,
|
|
name: "1kg Bag",
|
|
sku: "PK-001",
|
|
price: overrides?.price ?? 2499,
|
|
stockQuantity: overrides?.stockQuantity ?? 50,
|
|
isActive: overrides?.isActive ?? true,
|
|
weight: 1000,
|
|
weightUnit: "g",
|
|
length: 30,
|
|
width: 20,
|
|
height: 10,
|
|
dimensionUnit: "cm",
|
|
});
|
|
await ctx.db.insert("productImages", {
|
|
productId,
|
|
url: "https://example.com/kibble.jpg",
|
|
position: 0,
|
|
});
|
|
await ctx.db.insert("carts", {
|
|
userId,
|
|
items: [
|
|
{
|
|
productId,
|
|
variantId,
|
|
quantity: 2,
|
|
price: overrides?.price ?? 2499,
|
|
},
|
|
],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
expiresAt: Date.now() + 86400000,
|
|
});
|
|
});
|
|
|
|
return { userId, addressId, productId: productId!, variantId: variantId!, asA };
|
|
}
|
|
|
|
describe("checkoutActions.getShippingRate", () => {
|
|
it("throws when user is not authenticated", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const asA = t.withIdentity(identity);
|
|
await asA.mutation(api.users.store, {});
|
|
|
|
const addressId = await asA.mutation(api.addresses.add, {
|
|
...baseAddress,
|
|
type: "shipping",
|
|
isDefault: true,
|
|
});
|
|
|
|
await expect(
|
|
t.action(api.checkoutActions.getShippingRate, { addressId }),
|
|
).rejects.toThrow(/signed in/i);
|
|
});
|
|
|
|
it("throws when cart is empty", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const asA = t.withIdentity(identity);
|
|
await asA.mutation(api.users.store, {});
|
|
|
|
const addressId = await asA.mutation(api.addresses.add, {
|
|
...baseAddress,
|
|
type: "shipping",
|
|
isDefault: true,
|
|
});
|
|
|
|
await expect(
|
|
asA.action(api.checkoutActions.getShippingRate, { addressId }),
|
|
).rejects.toThrow(/cart is empty/i);
|
|
});
|
|
|
|
it("throws when cart has blocking issues (out of stock)", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t, {
|
|
stockQuantity: 0,
|
|
});
|
|
|
|
mockShippoShipmentResponse();
|
|
|
|
await expect(
|
|
asA.action(api.checkoutActions.getShippingRate, { addressId }),
|
|
).rejects.toThrow(/cart has issues/i);
|
|
});
|
|
|
|
it("returns a complete ShippingRateResult on success", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
mockShippoShipmentResponse();
|
|
|
|
const result = await asA.action(api.checkoutActions.getShippingRate, {
|
|
addressId,
|
|
});
|
|
|
|
expect(result.shipmentObjectId).toBe("shp_test_123");
|
|
|
|
expect(result.selectedRate.provider).toBe("DPD UK");
|
|
expect(result.selectedRate.serviceName).toBe("Next Day");
|
|
expect(result.selectedRate.serviceToken).toBe("dpd_uk_next_day");
|
|
expect(result.selectedRate.amount).toBe(5.5);
|
|
expect(result.selectedRate.currency).toBe("GBP");
|
|
expect(result.selectedRate.estimatedDays).toBe(1);
|
|
expect(result.selectedRate.durationTerms).toBe("1-2 business days");
|
|
expect(result.selectedRate.carrierAccount).toBe("ca_dpd_001");
|
|
|
|
expect(result.alternativeRates).toHaveLength(1);
|
|
expect(result.alternativeRates[0].provider).toBe("UPS");
|
|
|
|
expect(result.cartSubtotal).toBe(2499 * 2);
|
|
expect(result.shippingTotal).toBe(5.5);
|
|
expect(result.orderTotal).toBe(2499 * 2 + 5.5);
|
|
});
|
|
|
|
it("sends correct Shippo request with mapped address fields", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
mockShippoShipmentResponse();
|
|
|
|
await asA.action(api.checkoutActions.getShippingRate, { addressId });
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
const [url, opts] = fetchSpy.mock.calls[0];
|
|
expect(url).toBe("https://api.goshippo.com/shipments/");
|
|
expect(opts.method).toBe("POST");
|
|
|
|
const body = JSON.parse(opts.body);
|
|
expect(body.address_from).toBe("addr_warehouse_001");
|
|
expect(body.address_to.name).toBe("Alice Smith");
|
|
expect(body.address_to.street1).toBe("10 Downing Street");
|
|
expect(body.address_to.street2).toBe("Flat 2");
|
|
expect(body.address_to.city).toBe("London");
|
|
expect(body.address_to.zip).toBe("SW1A 2AA");
|
|
expect(body.address_to.country).toBe("GB");
|
|
expect(body.address_to.phone).toBe("+447911123456");
|
|
expect(body.async).toBe(false);
|
|
expect(body.parcels).toHaveLength(1);
|
|
expect(body.parcels[0].weight).toBe("2000");
|
|
expect(body.parcels[0].mass_unit).toBe("g");
|
|
});
|
|
|
|
it("throws when SHIPPO_SOURCE_ADDRESS_ID env var is missing", async () => {
|
|
vi.stubEnv("SHIPPO_SOURCE_ADDRESS_ID", "");
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
await expect(
|
|
asA.action(api.checkoutActions.getShippingRate, { addressId }),
|
|
).rejects.toThrow(/missing source address/i);
|
|
});
|
|
|
|
it("throws when Shippo API returns no rates", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
mockShippoShipmentResponse({ rates: [] });
|
|
|
|
await expect(
|
|
asA.action(api.checkoutActions.getShippingRate, { addressId }),
|
|
).rejects.toThrow(/no shipping rates available/i);
|
|
});
|
|
|
|
it("falls back to non-preferred carriers when no preferred carriers in response", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
mockShippoShipmentResponse({
|
|
rates: [
|
|
{
|
|
object_id: "rate_non_pref",
|
|
provider: "Royal Mail",
|
|
servicelevel: { name: "2nd Class", token: "royal_mail_2nd" },
|
|
amount: "3.00",
|
|
currency: "GBP",
|
|
estimated_days: 3,
|
|
duration_terms: "2-4 business days",
|
|
arrives_by: null,
|
|
carrier_account: "ca_rm_001",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await asA.action(api.checkoutActions.getShippingRate, {
|
|
addressId,
|
|
});
|
|
|
|
expect(result.selectedRate.provider).toBe("Royal Mail");
|
|
expect(result.selectedRate.amount).toBe(3.0);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
"No preferred carriers returned rates. Falling back to all carriers.",
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("correctly computes parcel from cart items with dimensions", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { addressId, asA } = await setupFullCheckoutContext(t);
|
|
|
|
mockShippoShipmentResponse();
|
|
|
|
await asA.action(api.checkoutActions.getShippingRate, { addressId });
|
|
|
|
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
|
const parcel = body.parcels[0];
|
|
expect(parcel.length).toBe("30");
|
|
expect(parcel.width).toBe("20");
|
|
expect(parcel.height).toBe("20");
|
|
expect(parcel.distance_unit).toBe("cm");
|
|
});
|
|
});
|