Some checks failed
CI / Lint, Typecheck & Test (push) Failing after 2m13s
- carts.test: add required product fields (parentCategorySlug, childCategorySlug) and variant fields (weight, weightUnit) - stripeActions.test: use price in cents (2499) for variant/cart and expect unit_amount: 2499 in line_items assertion - useShippingRate.test: expect fallback error message for plain Error rejections - scaffold.test: enable @ alias in root vitest.config for storefront imports - useCartSession.test: mock useConvexAuth instead of ConvexProviderWithClerk for reliable unit tests Made-with: Cursor
491 lines
14 KiB
TypeScript
491 lines
14 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 { 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 ?? 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,
|
|
};
|
|
}
|
|
|
|
// ─── 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: 2499,
|
|
},
|
|
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();
|
|
});
|
|
});
|