feat: initial commit — storefront, convex backend, and shared packages
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>
This commit is contained in:
490
convex/stripeActions.test.ts
Normal file
490
convex/stripeActions.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
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 { mockCustomersCreate, mockSessionsCreate, mockSessionsRetrieve } =
|
||||
vi.hoisted(() => ({
|
||||
mockCustomersCreate: vi.fn(),
|
||||
mockSessionsCreate: vi.fn(),
|
||||
mockSessionsRetrieve: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("stripe", () => {
|
||||
return {
|
||||
default: class Stripe {
|
||||
customers = { create: mockCustomersCreate };
|
||||
checkout = {
|
||||
sessions: {
|
||||
create: mockSessionsCreate,
|
||||
retrieve: mockSessionsRetrieve,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
const identity = {
|
||||
name: "Alice Smith",
|
||||
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",
|
||||
};
|
||||
|
||||
const shippingRate = {
|
||||
provider: "DPD UK",
|
||||
serviceName: "Next Day",
|
||||
serviceToken: "dpd_uk_next_day",
|
||||
amount: 5.5,
|
||||
currency: "GBP",
|
||||
estimatedDays: 1,
|
||||
durationTerms: "1-2 business days",
|
||||
carrierAccount: "ca_dpd_001",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("STRIPE_SECRET_KEY", "sk_test_fake_key");
|
||||
vi.stubEnv("STOREFRONT_URL", "http://localhost:3000");
|
||||
|
||||
mockCustomersCreate.mockResolvedValue({ id: "cus_new_123" });
|
||||
mockSessionsCreate.mockResolvedValue({
|
||||
id: "cs_test_session_123",
|
||||
client_secret: "cs_test_secret_abc",
|
||||
status: "open",
|
||||
payment_status: "unpaid",
|
||||
});
|
||||
mockSessionsRetrieve.mockResolvedValue({
|
||||
id: "cs_test_session_123",
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
customer_details: { email: "alice@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
mockCustomersCreate.mockReset();
|
||||
mockSessionsCreate.mockReset();
|
||||
mockSessionsRetrieve.mockReset();
|
||||
});
|
||||
|
||||
async function setupFullCheckoutContext(
|
||||
t: ReturnType<typeof convexTest>,
|
||||
overrides?: {
|
||||
stockQuantity?: number;
|
||||
price?: number;
|
||||
isActive?: boolean;
|
||||
stripeCustomerId?: string;
|
||||
},
|
||||
) {
|
||||
const asA = t.withIdentity(identity);
|
||||
const userId = await asA.mutation(api.users.store, {});
|
||||
|
||||
if (overrides?.stripeCustomerId) {
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.patch(userId, {
|
||||
stripeCustomerId: overrides.stripeCustomerId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 ?? 24.99,
|
||||
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 ?? 24.99,
|
||||
},
|
||||
],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400000,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
userId,
|
||||
addressId,
|
||||
productId: productId!,
|
||||
variantId: variantId!,
|
||||
asA,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── createCheckoutSession ───────────────────────────────────────────────────
|
||||
|
||||
describe("stripeActions.createCheckoutSession", () => {
|
||||
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.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
}),
|
||||
).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.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
}),
|
||||
).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,
|
||||
});
|
||||
|
||||
await expect(
|
||||
asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
}),
|
||||
).rejects.toThrow(/cart has issues/i);
|
||||
});
|
||||
|
||||
it("creates a Stripe customer when user has none and patches the user", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
mockCustomersCreate.mockResolvedValue({ id: "cus_fresh_456" });
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
expect(mockCustomersCreate).toHaveBeenCalledOnce();
|
||||
expect(mockCustomersCreate).toHaveBeenCalledWith({
|
||||
email: "alice@example.com",
|
||||
name: "Alice Smith",
|
||||
metadata: { convexUserId: userId },
|
||||
});
|
||||
|
||||
const updatedUser = await t.run(async (ctx) => ctx.db.get(userId));
|
||||
expect(updatedUser?.stripeCustomerId).toBe("cus_fresh_456");
|
||||
});
|
||||
|
||||
it("reuses existing Stripe customer and does not create a new one", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t, {
|
||||
stripeCustomerId: "cus_existing_789",
|
||||
});
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
expect(mockCustomersCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns clientSecret on success", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
const result = await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ clientSecret: "cs_test_secret_abc" });
|
||||
});
|
||||
|
||||
it("builds correct line items from cart", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
const createCall = mockSessionsCreate.mock.calls[0][0];
|
||||
expect(createCall.line_items).toHaveLength(1);
|
||||
expect(createCall.line_items[0]).toEqual({
|
||||
price_data: {
|
||||
currency: "gbp",
|
||||
product_data: { name: "Premium Kibble — 1kg Bag" },
|
||||
unit_amount: Math.round(24.99 * 100),
|
||||
},
|
||||
quantity: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes correct shipping options with amount in pence", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
const createCall = mockSessionsCreate.mock.calls[0][0];
|
||||
expect(createCall.shipping_options).toHaveLength(1);
|
||||
|
||||
const rateData = createCall.shipping_options[0].shipping_rate_data;
|
||||
expect(rateData.fixed_amount.amount).toBe(550);
|
||||
expect(rateData.fixed_amount.currency).toBe("gbp");
|
||||
expect(rateData.display_name).toBe("DPD UK — Next Day");
|
||||
expect(rateData.delivery_estimate.minimum).toEqual({
|
||||
unit: "business_day",
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes correct metadata including all shipping fields", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { userId, addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
const createCall = mockSessionsCreate.mock.calls[0][0];
|
||||
expect(createCall.metadata).toEqual({
|
||||
convexUserId: userId,
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingMethod: "DPD UK — Next Day",
|
||||
shippingServiceCode: "dpd_uk_next_day",
|
||||
carrier: "DPD UK",
|
||||
carrierAccount: "ca_dpd_001",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates session with ui_mode custom and correct return_url", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
const createCall = mockSessionsCreate.mock.calls[0][0];
|
||||
expect(createCall.mode).toBe("payment");
|
||||
expect(createCall.ui_mode).toBe("custom");
|
||||
expect(createCall.return_url).toBe(
|
||||
"http://localhost:3000/checkout/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes Stripe customer ID to session creation", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t, {
|
||||
stripeCustomerId: "cus_existing_789",
|
||||
});
|
||||
|
||||
await asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
});
|
||||
|
||||
const createCall = mockSessionsCreate.mock.calls[0][0];
|
||||
expect(createCall.customer).toBe("cus_existing_789");
|
||||
});
|
||||
|
||||
it("throws when STOREFRONT_URL env var is missing", async () => {
|
||||
vi.stubEnv("STOREFRONT_URL", "");
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await expect(
|
||||
asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
}),
|
||||
).rejects.toThrow(/STOREFRONT_URL/i);
|
||||
});
|
||||
|
||||
it("throws when Stripe returns no client_secret", async () => {
|
||||
mockSessionsCreate.mockResolvedValue({
|
||||
id: "cs_test_no_secret",
|
||||
client_secret: null,
|
||||
});
|
||||
|
||||
const t = convexTest(schema, modules);
|
||||
const { addressId, asA } = await setupFullCheckoutContext(t);
|
||||
|
||||
await expect(
|
||||
asA.action(api.stripeActions.createCheckoutSession, {
|
||||
addressId,
|
||||
shipmentObjectId: "shp_test_123",
|
||||
shippingRate,
|
||||
}),
|
||||
).rejects.toThrow(/client_secret/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCheckoutSessionStatus ────────────────────────────────────────────────
|
||||
|
||||
describe("stripeActions.getCheckoutSessionStatus", () => {
|
||||
it("returns complete status with customer email", async () => {
|
||||
mockSessionsRetrieve.mockResolvedValue({
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
customer_details: { email: "alice@example.com" },
|
||||
});
|
||||
|
||||
const t = convexTest(schema, modules);
|
||||
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
|
||||
sessionId: "cs_test_session_123",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "complete",
|
||||
paymentStatus: "paid",
|
||||
customerEmail: "alice@example.com",
|
||||
});
|
||||
expect(mockSessionsRetrieve).toHaveBeenCalledWith("cs_test_session_123");
|
||||
});
|
||||
|
||||
it("returns expired status", async () => {
|
||||
mockSessionsRetrieve.mockResolvedValue({
|
||||
status: "expired",
|
||||
payment_status: "unpaid",
|
||||
customer_details: null,
|
||||
});
|
||||
|
||||
const t = convexTest(schema, modules);
|
||||
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
|
||||
sessionId: "cs_expired_456",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "expired",
|
||||
paymentStatus: "unpaid",
|
||||
customerEmail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns open status when payment not completed", async () => {
|
||||
mockSessionsRetrieve.mockResolvedValue({
|
||||
status: "open",
|
||||
payment_status: "unpaid",
|
||||
customer_details: { email: "alice@example.com" },
|
||||
});
|
||||
|
||||
const t = convexTest(schema, modules);
|
||||
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
|
||||
sessionId: "cs_open_789",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "open",
|
||||
paymentStatus: "unpaid",
|
||||
customerEmail: "alice@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null email when customer_details is absent", async () => {
|
||||
mockSessionsRetrieve.mockResolvedValue({
|
||||
status: "complete",
|
||||
payment_status: "paid",
|
||||
customer_details: undefined,
|
||||
});
|
||||
|
||||
const t = convexTest(schema, modules);
|
||||
const result = await t.action(api.stripeActions.getCheckoutSessionStatus, {
|
||||
sessionId: "cs_no_email",
|
||||
});
|
||||
|
||||
expect(result.customerEmail).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user