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:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

337
convex/addresses.test.ts Normal file
View File

@@ -0,0 +1,337 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
const shippingAddress = {
type: "shipping" as const,
firstName: "Alice",
lastName: "Smith",
phone: "+447911123456",
addressLine1: "10 Downing Street",
city: "London",
postalCode: "SW1A 2AA",
country: "GB",
};
describe("addresses", () => {
it("add sets default correctly and second add unsets previous default", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list1 = await asA.query(api.addresses.list, {});
expect(list1).toHaveLength(1);
expect(list1[0].isDefault).toBe(true);
await asA.mutation(api.addresses.add, {
...shippingAddress,
addressLine1: "221B Baker Street",
isDefault: true,
});
const list2 = await asA.query(api.addresses.list, {});
expect(list2).toHaveLength(2);
expect(list2[0].isDefault).toBe(true);
expect(list2[1].isDefault).toBe(false);
});
it("delete default address promotes next to default", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.add, {
...shippingAddress,
addressLine1: "221B Baker Street",
isDefault: false,
});
const listBefore = await asA.query(api.addresses.list, {});
expect(listBefore).toHaveLength(2);
const defaultId = listBefore[0]._id;
await asA.mutation(api.addresses.remove, { id: defaultId });
const listAfter = await asA.query(api.addresses.list, {});
expect(listAfter).toHaveLength(1);
expect(listAfter[0].isDefault).toBe(true);
});
it("add derives fullName from firstName + lastName", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].fullName).toBe("Alice Smith");
});
it("add persists additionalInformation when provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
additionalInformation: "Flat 4B",
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBe("Flat 4B");
});
it("add stores undefined for additionalInformation when not provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBeUndefined();
});
it("address records do not have state or addressLine2 fields", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
const addr = list[0] as Record<string, unknown>;
expect(addr).not.toHaveProperty("state");
expect(addr).not.toHaveProperty("addressLine2");
});
it("firstName and lastName are required and stored", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].firstName).toBe("Alice");
expect(list[0].lastName).toBe("Smith");
});
it("isValidated defaults to false when passed via add mutation", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list).toHaveLength(1);
expect(list[0].isValidated).toBe(false);
});
it("add mutation persists isValidated: true when provided", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
isValidated: true,
});
const list = await asA.query(api.addresses.list, {});
expect(list[0].isValidated).toBe(true);
});
it("update mutation patches isValidated", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.update, { id, isValidated: true });
const list1 = await asA.query(api.addresses.list, {});
expect(list1[0].isValidated).toBe(true);
await asA.mutation(api.addresses.update, { id, isValidated: false });
const list2 = await asA.query(api.addresses.list, {});
expect(list2[0].isValidated).toBe(false);
});
it("update mutation patches additionalInformation", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.update, { id, additionalInformation: "Floor 3" });
const list = await asA.query(api.addresses.list, {});
expect(list[0].additionalInformation).toBe("Floor 3");
});
it("markValidated sets isValidated to true", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
expect((await asA.query(api.addresses.list, {}))[0].isValidated).toBe(false);
await asA.mutation(api.addresses.markValidated, { id, isValidated: true });
const list = await asA.query(api.addresses.list, {});
expect(list[0].isValidated).toBe(true);
});
it("markValidated throws if user does not own address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
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, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await expect(
asB.mutation(api.addresses.markValidated, { id, isValidated: true }),
).rejects.toThrow(/Unauthorized|does not belong/);
});
it("markValidated throws for non-existent address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
await asA.mutation(api.users.store, {});
const fakeId = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await asA.mutation(api.addresses.remove, { id: fakeId });
await expect(
asA.mutation(api.addresses.markValidated, { id: fakeId, isValidated: true }),
).rejects.toThrow(/not found/i);
});
it("update throws if user does not own the address", async () => {
const t = convexTest(schema, modules);
const asA = t.withIdentity({
name: "Alice",
email: "alice@example.com",
subject: "clerk_alice_123",
});
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, {});
const id = await asA.mutation(api.addresses.add, {
...shippingAddress,
isDefault: true,
});
await expect(
asB.mutation(api.addresses.update, { id, fullName: "Hacker" }),
).rejects.toThrow(/Unauthorized|does not belong/);
await asA.mutation(api.addresses.update, { id, fullName: "Alice Updated" });
const list = await asA.query(api.addresses.list, {});
expect(list[0].fullName).toBe("Alice Updated");
});
});