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:
291
convex/reviews.test.ts
Normal file
291
convex/reviews.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { api } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
import type { Doc } from "./_generated/dataModel";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
async function setupAdminUser(t: ReturnType<typeof convexTest>) {
|
||||
const asAdmin = t.withIdentity({
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
subject: "clerk_admin_123",
|
||||
});
|
||||
const userId = await asAdmin.mutation(api.users.store, {});
|
||||
await t.run(async (ctx) => {
|
||||
await ctx.db.patch(userId, { role: "admin" });
|
||||
});
|
||||
return asAdmin;
|
||||
}
|
||||
|
||||
async function setupCategory(t: ReturnType<typeof convexTest>) {
|
||||
let categoryId: import("./_generated/dataModel").Id<"categories">;
|
||||
await t.run(async (ctx) => {
|
||||
categoryId = await ctx.db.insert("categories", {
|
||||
name: "Pet Food",
|
||||
slug: "pet-food",
|
||||
});
|
||||
});
|
||||
return categoryId!;
|
||||
}
|
||||
|
||||
async function setupProduct(
|
||||
t: ReturnType<typeof convexTest>,
|
||||
asAdmin: ReturnType<ReturnType<typeof convexTest>["withIdentity"]>,
|
||||
) {
|
||||
const categoryId = await setupCategory(t);
|
||||
return await asAdmin.mutation(api.products.create, {
|
||||
name: "Review Product",
|
||||
slug: "review-product",
|
||||
status: "active",
|
||||
categoryId,
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("reviews", () => {
|
||||
it("create adds review with isApproved false and duplicate throws", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "customer@example.com",
|
||||
subject: "clerk_customer_123",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
const reviewId = await asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 5,
|
||||
title: "Great product",
|
||||
content: "Really liked it.",
|
||||
});
|
||||
expect(reviewId).toBeTruthy();
|
||||
|
||||
const byProduct = await t.query(api.reviews.listByProduct, {
|
||||
productId,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(byProduct.page).toHaveLength(0);
|
||||
|
||||
await expect(
|
||||
asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 4,
|
||||
title: "Second",
|
||||
content: "Duplicate.",
|
||||
}),
|
||||
).rejects.toThrow(/already reviewed/);
|
||||
});
|
||||
|
||||
it("approve updates product averageRating and reviewCount", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "customer@example.com",
|
||||
subject: "clerk_customer_123",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
const reviewId = await asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 5,
|
||||
title: "Great",
|
||||
content: "Content here.",
|
||||
});
|
||||
|
||||
const productBefore = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
|
||||
expect(productBefore?.averageRating).toBeUndefined();
|
||||
expect(productBefore?.reviewCount).toBeUndefined();
|
||||
|
||||
await asAdmin.mutation(api.reviews.approve, { id: reviewId });
|
||||
|
||||
const productAfter = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
|
||||
expect(productAfter?.averageRating).toBe(5);
|
||||
expect(productAfter?.reviewCount).toBe(1);
|
||||
|
||||
const byProduct = await t.query(api.reviews.listByProduct, {
|
||||
productId,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(byProduct.page).toHaveLength(1);
|
||||
expect(byProduct.page[0].rating).toBe(5);
|
||||
});
|
||||
|
||||
it("create throws when rating out of range", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "customer@example.com",
|
||||
subject: "clerk_customer_123",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
await expect(
|
||||
asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 0,
|
||||
title: "Bad",
|
||||
content: "Content.",
|
||||
}),
|
||||
).rejects.toThrow(/1 and 5/);
|
||||
|
||||
await expect(
|
||||
asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 6,
|
||||
title: "Bad",
|
||||
content: "Content.",
|
||||
}),
|
||||
).rejects.toThrow(/1 and 5/);
|
||||
});
|
||||
|
||||
it("listForAdmin and approve and deleteReview require admin", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "customer@example.com",
|
||||
subject: "clerk_customer_123",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
const reviewId = await asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 3,
|
||||
title: "OK",
|
||||
content: "Content.",
|
||||
});
|
||||
|
||||
await expect(
|
||||
asCustomer.query(api.reviews.listForAdmin, { limit: 10, offset: 0 }),
|
||||
).rejects.toThrow(/admin|Unauthorized/);
|
||||
|
||||
await expect(
|
||||
asCustomer.mutation(api.reviews.approve, { id: reviewId }),
|
||||
).rejects.toThrow(/admin|Unauthorized/);
|
||||
|
||||
await expect(
|
||||
asCustomer.mutation(api.reviews.deleteReview, { id: reviewId }),
|
||||
).rejects.toThrow(/admin|Unauthorized/);
|
||||
|
||||
await asAdmin.mutation(api.reviews.approve, { id: reviewId });
|
||||
const adminList = await asAdmin.query(api.reviews.listForAdmin, {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(adminList.page.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
it("listByProductSorted sorts correctly", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer1 = t.withIdentity({
|
||||
name: "Customer 1",
|
||||
email: "customer1@example.com",
|
||||
subject: "clerk_c1",
|
||||
});
|
||||
await asCustomer1.mutation(api.users.store, {});
|
||||
const r1 = await asCustomer1.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 3,
|
||||
title: "C1",
|
||||
content: "C1",
|
||||
});
|
||||
|
||||
const asCustomer2 = t.withIdentity({
|
||||
name: "Customer 2",
|
||||
email: "customer2@example.com",
|
||||
subject: "clerk_c2",
|
||||
});
|
||||
await asCustomer2.mutation(api.users.store, {});
|
||||
const r2 = await asCustomer2.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 5,
|
||||
title: "C2",
|
||||
content: "C2",
|
||||
});
|
||||
|
||||
await asAdmin.mutation(api.reviews.approve, { id: r1 });
|
||||
await asAdmin.mutation(api.reviews.approve, { id: r2 });
|
||||
|
||||
const highest = await t.query(api.reviews.listByProductSorted, {
|
||||
productId,
|
||||
sortBy: "highest",
|
||||
});
|
||||
expect(highest.page.length).toBe(2);
|
||||
expect(highest.page[0].rating).toBe(5);
|
||||
|
||||
const lowest = await t.query(api.reviews.listByProductSorted, {
|
||||
productId,
|
||||
sortBy: "lowest",
|
||||
});
|
||||
expect(lowest.page[0].rating).toBe(3);
|
||||
});
|
||||
|
||||
it("hasUserReviewed works", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "cust@example.com",
|
||||
subject: "clerk_c_has",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
const hasBefore = await asCustomer.query(api.reviews.hasUserReviewed, { productId });
|
||||
expect(hasBefore).toBe(false);
|
||||
|
||||
await asCustomer.mutation(api.reviews.create, {
|
||||
productId,
|
||||
rating: 4,
|
||||
title: "Has",
|
||||
content: "Has content",
|
||||
});
|
||||
|
||||
const hasAfter = await asCustomer.query(api.reviews.hasUserReviewed, { productId });
|
||||
expect(hasAfter).toBe(true);
|
||||
});
|
||||
|
||||
it("submitAndRecalculate works", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const asAdmin = await setupAdminUser(t);
|
||||
const productId = await setupProduct(t, asAdmin);
|
||||
|
||||
const asCustomer = t.withIdentity({
|
||||
name: "Customer",
|
||||
email: "cust_submit@example.com",
|
||||
subject: "clerk_c_submit",
|
||||
});
|
||||
await asCustomer.mutation(api.users.store, {});
|
||||
|
||||
const reviewId = await asCustomer.action(api.reviews.submitAndRecalculate, {
|
||||
productId,
|
||||
rating: 4,
|
||||
title: "Title",
|
||||
content: "Content",
|
||||
});
|
||||
|
||||
expect(reviewId).toBeTruthy();
|
||||
|
||||
const product = (await t.run(async (ctx) => ctx.db.get(productId))) as Doc<"products"> | null;
|
||||
// Since isApproved is false initially, the stats are actually undefined (or unchanged)
|
||||
expect(product?.averageRating).toBeUndefined();
|
||||
expect(product?.reviewCount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user