- Vitest multi-project setup (unit / integration / component) - Real-Postgres integration harness via in-process PGlite (TEST_DATABASE_URL override), migrations applied per worker; production code untouched - Unit: routing, locale, validation/Zod schemas, metadata, mail, site-theme, marquee, media, portfolio helpers, plus architecture-rule tests - Integration: Prisma data layer, API routes, and all server actions - Component: UI primitives and form components (jsdom + Testing Library) - 371 tests passing
223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
|
|
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
|
|
vi.mock("next/dist/client/components/redirect-error", async () => ({
|
|
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
|
|
}));
|
|
vi.mock("@/lib/admin-auth", async () => {
|
|
const m = await import("@/tests/helpers/next-mocks");
|
|
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
|
|
});
|
|
|
|
import {
|
|
deleteCategoryAction,
|
|
deleteProjectAction,
|
|
saveProjectAction,
|
|
upsertCategoryAction,
|
|
} from "@/app/_admin/portfolio/actions";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { createCategory, createProject } from "@/tests/helpers/factories";
|
|
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
|
|
|
beforeEach(() => {
|
|
resetNextMocks();
|
|
});
|
|
|
|
function categoryForm(overrides: Record<string, string> = {}) {
|
|
return formDataFrom({
|
|
slug: "branding",
|
|
nameAr: "الهوية",
|
|
nameEn: "Branding",
|
|
nameDe: "Branding",
|
|
descriptionAr: "وصف",
|
|
descriptionEn: "Description",
|
|
descriptionDe: "Beschreibung",
|
|
sortOrder: "1",
|
|
isActive: "on",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function projectForm(categoryId: string, overrides: Record<string, string> = {}) {
|
|
const assets = JSON.stringify([
|
|
{
|
|
kind: "IMAGE",
|
|
altAr: "ع",
|
|
altEn: "Alt",
|
|
altDe: "Alt",
|
|
sortOrder: 0,
|
|
media: { mode: "external", url: "https://cdn/asset.png", kind: "IMAGE", label: "Asset" },
|
|
},
|
|
]);
|
|
const coverMedia = JSON.stringify({ mode: "external", url: "https://cdn/cover.png", kind: "IMAGE", label: "Cover" });
|
|
return formDataFrom({
|
|
categoryId,
|
|
slug: "case-study",
|
|
viewMode: "GRID",
|
|
titleAr: "عنوان",
|
|
titleEn: "Title",
|
|
titleDe: "Titel",
|
|
summaryAr: "ملخص",
|
|
summaryEn: "Summary",
|
|
summaryDe: "Zusammenfassung",
|
|
clientName: "Client",
|
|
projectYear: "2025",
|
|
serviceLabelAr: "خدمة",
|
|
serviceLabelEn: "Service",
|
|
serviceLabelDe: "Service",
|
|
previewUrl: "https://example.com",
|
|
sortOrder: "0",
|
|
isFeatured: "",
|
|
isPublished: "on",
|
|
sections: "[]",
|
|
assets,
|
|
coverMedia,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe("upsertCategoryAction", () => {
|
|
it("creates a category", async () => {
|
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
|
|
expect(url).toContain("success=");
|
|
const category = await prisma.category.findUnique({ where: { slug: "branding" } });
|
|
expect(category?.nameEn).toBe("Branding");
|
|
expect(category?.isActive).toBe(true);
|
|
});
|
|
|
|
it("updates an existing category", async () => {
|
|
const existing = await createCategory({ slug: "old", nameEn: "Old" });
|
|
const url = await captureRedirect(() =>
|
|
upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
|
|
);
|
|
expect(url).toContain("success=");
|
|
const category = await prisma.category.findUnique({ where: { id: existing.id } });
|
|
expect(category?.nameEn).toBe("Renamed");
|
|
});
|
|
|
|
it("reports a unique-constraint violation on duplicate slugs", async () => {
|
|
await createCategory({ slug: "branding" });
|
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
|
|
expect(url).toContain("error=");
|
|
expect(decodeURIComponent(url)).toContain("eindeutig");
|
|
});
|
|
|
|
it("reports validation errors for an invalid slug", async () => {
|
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "Not Valid" })));
|
|
expect(url).toContain("error=");
|
|
});
|
|
|
|
it("redirects unauthenticated callers to the admin root", async () => {
|
|
adminAuth.authenticated = false;
|
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
|
|
expect(url).toBe("/");
|
|
});
|
|
});
|
|
|
|
describe("deleteCategoryAction", () => {
|
|
it("refuses to delete a category that has projects", async () => {
|
|
const category = await createCategory();
|
|
await createProject({ categoryId: category.id });
|
|
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
|
|
expect(url).toContain("error=");
|
|
expect(await prisma.category.findUnique({ where: { id: category.id } })).not.toBeNull();
|
|
});
|
|
|
|
it("deletes an empty category", async () => {
|
|
const category = await createCategory();
|
|
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
|
|
expect(url).toContain("success=");
|
|
expect(await prisma.category.findUnique({ where: { id: category.id } })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("saveProjectAction", () => {
|
|
it("creates a published project with cover and asset media usages", async () => {
|
|
const category = await createCategory();
|
|
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
|
expect(url).toContain("success=");
|
|
|
|
const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
|
|
expect(project).not.toBeNull();
|
|
expect(project?.isPublished).toBe(true);
|
|
expect(project?.publishedAt).not.toBeNull();
|
|
expect(project?.coverImagePath).toBe("https://cdn/cover.png");
|
|
|
|
expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1);
|
|
const usages = await prisma.mediaUsage.findMany({
|
|
where: { entityType: "portfolio-project", entityId: project!.id },
|
|
});
|
|
const usageTypes = usages.map((u) => u.usageType).sort();
|
|
expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
|
|
});
|
|
|
|
it("updates an existing project and replaces its assets", async () => {
|
|
const category = await createCategory();
|
|
const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
|
void created;
|
|
const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
|
|
|
|
const url = await captureRedirect(() =>
|
|
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
|
|
);
|
|
expect(url).toContain("success=");
|
|
const updated = await prisma.portfolioProject.findUnique({ where: { id: project!.id } });
|
|
expect(updated?.titleEn).toBe("Updated Title");
|
|
// assets are replaced, not duplicated
|
|
expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1);
|
|
});
|
|
|
|
it("keeps the original publishedAt when re-saving an already published project", async () => {
|
|
const category = await createCategory();
|
|
await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
|
const first = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } });
|
|
const originalPublishedAt = first!.publishedAt;
|
|
|
|
await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
|
|
const second = await prisma.portfolioProject.findUnique({ where: { id: first!.id } });
|
|
expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString());
|
|
});
|
|
|
|
it("reports validation errors and creates nothing", async () => {
|
|
const category = await createCategory();
|
|
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
|
|
expect(url).toContain("error=");
|
|
expect(await prisma.portfolioProject.count()).toBe(0);
|
|
});
|
|
|
|
it("reports a unique-constraint violation on duplicate slugs", async () => {
|
|
const category = await createCategory();
|
|
await createProject({ categoryId: category.id, slug: "case-study" });
|
|
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
|
|
expect(url).toContain("error=");
|
|
expect(decodeURIComponent(url)).toContain("eindeutig");
|
|
});
|
|
|
|
it("redirects unauthenticated callers to the admin root", async () => {
|
|
adminAuth.authenticated = false;
|
|
const url = await captureRedirect(() => saveProjectAction(projectForm("cat")));
|
|
expect(url).toBe("/");
|
|
});
|
|
});
|
|
|
|
describe("deleteProjectAction", () => {
|
|
it("deletes a project and its media usages", async () => {
|
|
const project = await createProject();
|
|
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
|
|
expect(url).toContain("success=");
|
|
expect(await prisma.portfolioProject.findUnique({ where: { id: project.id } })).toBeNull();
|
|
});
|
|
|
|
it("errors when the project does not exist", async () => {
|
|
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "missing" })));
|
|
expect(url).toContain("error=");
|
|
});
|
|
|
|
it("redirects unauthenticated callers to the admin root", async () => {
|
|
adminAuth.authenticated = false;
|
|
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "x" })));
|
|
expect(url).toBe("/");
|
|
});
|
|
});
|