Files
S. EsmaeiliandCursor 49f30a96b1 feat: polish admin UI, phone login, and product details
Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 17:18:17 +03:30

170 lines
7.7 KiB
TypeScript

import { randomBytes } from "node:crypto";
import type { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { SECTION_KEYS } from "@imen/shared";
import cookieParser from "cookie-parser";
import request from "supertest";
import { AppModule } from "../src/app.module.js";
import { hashPassword } from "../src/auth/auth.service.js";
import { PrismaService } from "../src/prisma/prisma.service.js";
/**
* Runs against the development database (seeded). Every record it creates is removed afterwards;
* it uses its own temporary users so real sessions are never touched.
*/
describe("API (e2e)", () => {
let app: INestApplication;
let prisma: PrismaService;
const suffix = String(randomBytes(4).readUInt32BE(0) % 10_000_000).padStart(7, "0");
const admin = { phone: `0912${suffix}`, password: "e2e-Password-1" };
const editor = { phone: `0935${suffix}`, password: "e2e-Password-2" };
const http = () => request(app.getHttpServer());
const login = async (u: { phone: string; password: string }) => {
const res = await http().post("/api/admin/auth/login").send(u).expect(200);
return { token: res.body.accessToken as string, cookie: res.headers["set-cookie"] as unknown as string[] };
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix("api");
app.use(cookieParser());
await app.init();
prisma = app.get(PrismaService);
await prisma.adminUser.createMany({
data: [
{ phone: admin.phone, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) },
{ phone: editor.phone, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) },
],
});
});
afterAll(async () => {
await prisma.adminUser.deleteMany({ where: { phone: { in: [admin.phone, editor.phone] } } });
await app.close();
});
describe("public", () => {
it("reports health", () => http().get("/api/health").expect(200, { ok: true }));
it("returns every site section and the published categories", async () => {
const res = await http().get("/api/public/site").expect(200);
expect(Object.keys(res.body.sections).sort()).toEqual([...SECTION_KEYS].sort());
expect(res.body.categories.length).toBeGreaterThan(0);
});
it("returns a category with its products, 404 for unknown slugs", async () => {
const { body: site } = await http().get("/api/public/site");
const slug = site.categories[0].slug;
const res = await http().get(`/api/public/categories/${slug}`).expect(200);
expect(res.body.products.length).toBe(site.categories[0].productCount);
await http().get("/api/public/categories/no-such-category").expect(404);
});
it("returns a product page with related products, 404 for unknown slugs", async () => {
const { body: refs } = await http().get("/api/public/products").expect(200);
expect(refs.length).toBeGreaterThan(0);
const res = await http().get(`/api/public/products/${refs[0].slug}`).expect(200);
expect(res.body.category.slug).toBeTruthy();
expect(Array.isArray(res.body.attributes)).toBe(true);
expect(res.body.related.length).toBeLessThanOrEqual(4);
expect(res.body.related.some((r: { slug: string }) => r.slug === refs[0].slug)).toBe(false);
await http().get("/api/public/products/no-such-product").expect(404);
});
it("validates the contact form", async () => {
const res = await http().post("/api/public/contact").send({ name: "", org: "x", phone: "abc" }).expect(400);
expect(res.body.issues.map((i: { path: string }) => i.path).sort()).toEqual(["name", "phone"]);
});
it("silently drops honeypot submissions", async () => {
const before = await prisma.contactMessage.count();
await http()
.post("/api/public/contact")
.send({ name: "bot", org: "bot", phone: "0912 000 0000", website: "http://spam" })
.expect(201);
expect(await prisma.contactMessage.count()).toBe(before);
});
});
describe("auth", () => {
it("rejects admin routes without a token and wrong passwords", async () => {
await http().get("/api/admin/dashboard").expect(401);
await http().post("/api/admin/auth/login").send({ phone: admin.phone, password: "wrong-password" }).expect(401);
});
it("sets an httpOnly refresh cookie and rotates it", async () => {
const { token, cookie } = await login(admin);
expect(cookie.join(";")).toMatch(/imen_rt=.*HttpOnly/i);
await http().get("/api/admin/auth/me").set("authorization", `Bearer ${token}`).expect(200);
const refreshed = await http().post("/api/admin/auth/refresh").set("Cookie", cookie).expect(200);
expect(refreshed.body.accessToken).toBeTruthy();
// replaying the old (rotated) cookie is treated as theft: all sessions end
await http().post("/api/admin/auth/refresh").set("Cookie", cookie).expect(401);
const newCookie = refreshed.headers["set-cookie"] as unknown as string[];
await http().post("/api/admin/auth/refresh").set("Cookie", newCookie).expect(401);
});
});
describe("roles", () => {
it("limits users and site settings to admins", async () => {
const { token } = await login(editor);
const auth = { authorization: `Bearer ${token}` };
await http().get("/api/admin/users").set(auth).expect(403);
const settings = await http().get("/api/admin/sections/settings").set(auth).expect(200);
await http().put("/api/admin/sections/settings").set(auth).send(settings.body.data).expect(403);
await http().get("/api/admin/dashboard").set(auth).expect(200);
});
});
describe("catalog", () => {
it("creates, validates, hides and deletes a product", async () => {
const { token } = await login(admin);
const auth = { authorization: `Bearer ${token}` };
const [category] = (await http().get("/api/admin/categories").set(auth)).body;
const code = `E2E-${suffix}`;
const invalid = await http()
.post("/api/admin/products")
.set(auth)
.send({ categoryId: category.id, code: "", slug: "Not A Slug", name: "" })
.expect(400);
expect(invalid.body.issues.length).toBeGreaterThanOrEqual(3);
const created = await http()
.post("/api/admin/products")
.set(auth)
.send({ categoryId: category.id, code, slug: code.toLowerCase(), name: "محصول آزمایشی" })
.expect(201);
const duplicate = await http()
.post("/api/admin/products")
.set(auth)
.send({ categoryId: category.id, code, slug: `${code.toLowerCase()}-b`, name: "x" })
.expect(409);
expect(duplicate.body.message).toContain("کد");
const published = await http().get(`/api/public/categories/${category.slug}`);
expect(published.body.products.some((p: { code: string }) => p.code === code)).toBe(true);
await http().patch(`/api/admin/products/${created.body.id}/publish`).set(auth).send({ isPublished: false }).expect(200);
const hidden = await http().get(`/api/public/categories/${category.slug}`);
expect(hidden.body.products.some((p: { code: string }) => p.code === code)).toBe(false);
await http().delete(`/api/admin/products/${created.body.id}`).set(auth).expect(204);
});
it("refuses to delete a category that still has products", async () => {
const { token } = await login(admin);
const auth = { authorization: `Bearer ${token}` };
const [category] = (await http().get("/api/admin/categories").set(auth)).body;
await http().delete(`/api/admin/categories/${category.id}`).set(auth).expect(409);
});
});
});