SEO - New Settings > SEO admin page (seo_settings in app_config): indexing switch, Google/Bing verification, X handle, JSON-LD identity (Person/Organization, sameAs), per-locale keywords, readiness checklist and open links for sitemap.xml / robots.txt / manifest. - robots.txt is now dynamic: disallows admin, api, success and coming-soon paths; blocks everything while indexing is off or maintenance is on. - sitemap.xml carries hreflang alternates per URL, lists only categories with published projects, and is empty while hidden. - Metadata: robots + verification meta, og:locale in de_DE/en_US/ar_AR form, alternateLocale, twitter site/creator, project cover as OG image with article type, noindex on /success and /coming-soon. - JSON-LD: WebSite + publisher graph on all public pages, CreativeWork per project (view-mode independent). Security - Maintenance bypass now requires a correctly signed admin cookie; the middleware previously only checked the cookie existed. Token helpers moved to lib/admin-session-token.ts (shared by proxy.ts and lib/admin-auth.ts). - Media uploads: magic-byte validation against the declared type, SVG sanitization (script/handlers/foreignObject/javascript: rejected), upload folder sanitized, kind inferred from the real file. - Media route: fixed prefix-based path check that accepted sibling directories, unknown extensions return 404, nosniff header, CSP sandbox on SVG, gif content type added. - External media URLs: protocol-relative (//host) URLs rejected. Portfolio - Project and category slugs share /portfolio/[slug]; saving now rejects a slug already used on the other side instead of silently shadowing it. Tooling/docs - Lint: ignore scripts/legacy-prisma-seed.cjs, drop unused import. - New docs/SEO.md; FEATURES, ARCHITECTURE (Drizzle instead of Prisma), admin spec and CLAUDE.md updated. - Tests for all of the above (unit + integration); suite green.
244 lines
10 KiB
TypeScript
244 lines
10 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 { and, eq } from "drizzle-orm";
|
|
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
category as categoryTable,
|
|
mediaUsage as mediaUsageTable,
|
|
portfolioAsset as portfolioAssetTable,
|
|
portfolioProject as portfolioProjectTable,
|
|
} from "@/lib/db/schema";
|
|
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 db.query.category.findFirst({ where: eq(categoryTable.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 db.query.category.findFirst({ where: eq(categoryTable.id, existing.id) });
|
|
expect(category?.nameEn).toBe("Renamed");
|
|
});
|
|
|
|
it("rejects a category slug that already belongs to a project", async () => {
|
|
const other = await createCategory({ slug: "other" });
|
|
await createProject({ categoryId: other.id, slug: "taken" });
|
|
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "taken" })));
|
|
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Projekt Slug vergeben");
|
|
expect(await db.query.category.findFirst({ where: eq(categoryTable.slug, "taken") })).toBeUndefined();
|
|
});
|
|
|
|
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 db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).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 db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("saveProjectAction", () => {
|
|
it("rejects a project slug that already belongs to a category", async () => {
|
|
const category = await createCategory({ slug: "branding" });
|
|
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { slug: "branding" })));
|
|
expect(new URL(url, "http://test").searchParams.get("error")).toContain("Kategorie Slug vergeben");
|
|
expect(await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "branding") })).toBeUndefined();
|
|
});
|
|
|
|
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 db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.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 db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
|
|
const usages = await db.select().from(mediaUsageTable).where(and(eq(mediaUsageTable.entityType, "portfolio-project"), eq(mediaUsageTable.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 db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
|
|
|
|
const url = await captureRedirect(() =>
|
|
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
|
|
);
|
|
expect(url).toContain("success=");
|
|
const updated = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project!.id) });
|
|
expect(updated?.titleEn).toBe("Updated Title");
|
|
// assets are replaced, not duplicated
|
|
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.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 db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
|
|
const originalPublishedAt = first!.publishedAt;
|
|
|
|
await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
|
|
const second = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.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 db.$count(portfolioProjectTable)).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 db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project.id) })) ?? null).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("/");
|
|
});
|
|
});
|