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>
292 lines
8.5 KiB
TypeScript
292 lines
8.5 KiB
TypeScript
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();
|
|
});
|
|
});
|