Files
the-pet-loft/convex/checkout.test.ts
ianshaloom cc15338ad9 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>
2026-03-04 09:31:18 +03:00

448 lines
14 KiB
TypeScript

import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api, internal } 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",
};
describe("checkout.getShippingAddresses", () => {
it("returns only shipping addresses (excludes billing)", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "billing",
addressLine1: "1 Treasury Place",
isDefault: false,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "221B Baker Street",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(2);
expect(result.every((a) => a.type === "shipping")).toBe(true);
});
it("sorts default address first, then by recency descending", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "First added",
isDefault: false,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Second added (default)",
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Third added",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(3);
expect(result[0].addressLine1).toBe("Second added (default)");
expect(result[0].isDefault).toBe(true);
expect(result[1].addressLine1).toBe("Third added");
expect(result[2].addressLine1).toBe("First added");
});
it("returns empty array when user has no shipping addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toEqual([]);
});
it("returns empty array when user only has billing addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "billing",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toEqual([]);
});
it("includes isValidated field on returned addresses", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
isValidated: true,
});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "221B Baker Street",
isDefault: false,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(2);
expect(result[0].isValidated).toBe(true);
expect(result[1].isValidated).toBe(false);
});
it("includes additionalInformation when present", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
additionalInformation: "Flat 4B",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
expect(result).toHaveLength(1);
expect(result[0].additionalInformation).toBe("Flat 4B");
});
it("returned addresses have no state or addressLine2 fields", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
const result = await asA.query(api.checkout.getShippingAddresses, {});
const addr = result[0] as Record<string, unknown>;
expect(addr).not.toHaveProperty("state");
expect(addr).not.toHaveProperty("addressLine2");
});
it("throws for unauthenticated users", async () => {
const t = convexTest(schema, modules);
await expect(
t.query(api.checkout.getShippingAddresses, {}),
).rejects.toThrow(/Unauthenticated/i);
});
it("does not return addresses belonging to other users", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const asB = t.withIdentity({
name: "Bob",
email: "bob@example.com",
subject: "clerk_bob_456",
});
await asA.mutation(api.users.store, {});
await asB.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
isDefault: true,
});
await asB.mutation(api.addresses.add, {
...baseAddress,
type: "shipping",
addressLine1: "Bob's address",
isDefault: true,
});
const resultA = await asA.query(api.checkout.getShippingAddresses, {});
expect(resultA).toHaveLength(1);
expect(resultA[0].addressLine1).toBe("10 Downing Street");
const resultB = await asB.query(api.checkout.getShippingAddresses, {});
expect(resultB).toHaveLength(1);
expect(resultB[0].addressLine1).toBe("Bob's address");
});
});
// ─── getAddressById (internal query) ─────────────────────────────────────────
describe("checkout.getAddressById", () => {
it("returns the address document when it exists", 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,
});
const result = await t.query(internal.checkout.getAddressById, {
addressId,
});
expect(result.addressLine1).toBe("10 Downing Street");
expect(result.fullName).toBe("Alice Smith");
expect(result.city).toBe("London");
expect(result.postalCode).toBe("SW1A 2AA");
expect(result.country).toBe("GB");
});
it("throws when the address does not exist", 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 asA.mutation(api.addresses.remove, { id: addressId });
await expect(
t.query(internal.checkout.getAddressById, { addressId }),
).rejects.toThrow(/Address not found/i);
});
it("returns additionalInformation when present on address", 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",
additionalInformation: "Flat 4B",
isDefault: true,
});
const result = await t.query(internal.checkout.getAddressById, {
addressId,
});
expect(result.additionalInformation).toBe("Flat 4B");
});
});
// ─── getCurrentUserId (internal query) ───────────────────────────────────────
describe("checkout.getCurrentUserId", () => {
it("returns the user ID for an authenticated user", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const result = await asA.query(internal.checkout.getCurrentUserId);
expect(result).toBe(userId);
});
it("throws for unauthenticated requests", async () => {
const t = convexTest(schema, modules);
await expect(
t.query(internal.checkout.getCurrentUserId),
).rejects.toThrow(/Unauthenticated/i);
});
});
// ─── validateCartInternal (internal query) ───────────────────────────────────
describe("checkout.validateCartInternal", () => {
async function setupProductAndVariant(
t: ReturnType<typeof convexTest>,
overrides?: { stockQuantity?: number; price?: number; isActive?: boolean },
) {
let productId: Id<"products">;
let variantId: Id<"productVariants">;
let categoryId: Id<"categories">;
await t.run(async (ctx) => {
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,
});
});
return { productId: productId!, variantId: variantId!, categoryId: categoryId! };
}
it("returns null when the user has no cart", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).toBeNull();
});
it("returns null when the cart has no items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).toBeNull();
});
it("returns a valid cart validation result with enriched items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const { productId, variantId } = await setupProductAndVariant(t);
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [{ productId, variantId, quantity: 2, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.items).toHaveLength(1);
expect(result!.items[0].productName).toBe("Premium Kibble");
expect(result!.items[0].variantName).toBe("1kg Bag");
expect(result!.items[0].quantity).toBe(2);
expect(result!.items[0].weight).toBe(1000);
expect(result!.items[0].weightUnit).toBe("g");
expect(result!.subtotal).toBe(2499 * 2);
});
it("returns issues for out-of-stock items", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity(identity);
const userId = await asA.mutation(api.users.store, {});
const { productId, variantId } = await setupProductAndVariant(t, {
stockQuantity: 0,
});
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
userId,
items: [{ productId, variantId, quantity: 1, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
userId,
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(false);
expect(result!.issues).toHaveLength(1);
expect(result!.issues[0].type).toBe("out_of_stock");
});
it("resolves cart by sessionId when userId not provided", async () => {
const t = convexTest(schema, modules);
const { productId, variantId } = await setupProductAndVariant(t);
await t.run(async (ctx) => {
await ctx.db.insert("carts", {
sessionId: "sess_abc123",
items: [{ productId, variantId, quantity: 1, price: 2499 }],
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86400000,
});
});
const result = await t.query(internal.checkout.validateCartInternal, {
sessionId: "sess_abc123",
});
expect(result).not.toBeNull();
expect(result!.valid).toBe(true);
expect(result!.items).toHaveLength(1);
});
});