test: add comprehensive automated test coverage
- 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
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
describe("integration harness smoke test", () => {
|
||||
it("connects to the migrated test database and performs CRUD", async () => {
|
||||
const created = await prisma.category.create({
|
||||
data: {
|
||||
slug: "smoke",
|
||||
nameAr: "a",
|
||||
nameEn: "b",
|
||||
nameDe: "c",
|
||||
descriptionAr: "a",
|
||||
descriptionEn: "b",
|
||||
descriptionDe: "c",
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.isActive).toBe(true);
|
||||
|
||||
const found = await prisma.category.findUnique({ where: { slug: "smoke" } });
|
||||
expect(found?.nameEn).toBe("b");
|
||||
});
|
||||
|
||||
it("resets the database between tests", async () => {
|
||||
const count = await prisma.category.count();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("supports enums and appconfig upsert", async () => {
|
||||
await prisma.appConfig.upsert({
|
||||
where: { key: "k" },
|
||||
update: { value: "v2" },
|
||||
create: { key: "k", value: "v1" },
|
||||
});
|
||||
const row = await prisma.appConfig.findUnique({ where: { key: "k" } });
|
||||
expect(row?.value).toBe("v1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
const { sendContactMessage } = vi.hoisted(() => ({ sendContactMessage: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/mail", () => ({ sendContactMessage }));
|
||||
|
||||
import { submitContactFormAction } from "@/app/[locale]/(site)/contact/actions";
|
||||
import { captureRedirect, formDataFrom } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
sendContactMessage.mockClear();
|
||||
});
|
||||
|
||||
describe("submitContactFormAction", () => {
|
||||
it("sends a valid message and redirects to the localized success page", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
submitContactFormAction(
|
||||
formDataFrom({
|
||||
locale: "en",
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
message: "Hello, I would like to work together on a project.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(url).toBe("/en/success");
|
||||
expect(sendContactMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendContactMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ locale: "en", name: "Jane Doe", email: "jane@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the default locale (de) and its bare success path", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
submitContactFormAction(
|
||||
formDataFrom({
|
||||
locale: "de",
|
||||
name: "Max Mustermann",
|
||||
email: "max@example.com",
|
||||
message: "Ich interessiere mich fuer eine Zusammenarbeit.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(url).toBe("/success");
|
||||
});
|
||||
|
||||
it("redirects back with an error for an invalid email and does not send", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
submitContactFormAction(
|
||||
formDataFrom({
|
||||
locale: "en",
|
||||
name: "Jane",
|
||||
email: "not-an-email",
|
||||
message: "This is a long enough message body.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(url).toContain("/en/contact?error=");
|
||||
expect(sendContactMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a too-short message", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
submitContactFormAction(
|
||||
formDataFrom({ locale: "de", name: "Jane Doe", email: "jane@example.com", message: "short" }),
|
||||
),
|
||||
);
|
||||
expect(url).toContain("/contact?error=");
|
||||
expect(sendContactMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects with an error when delivery fails", async () => {
|
||||
sendContactMessage.mockRejectedValueOnce(new Error("smtp down"));
|
||||
const url = await captureRedirect(() =>
|
||||
submitContactFormAction(
|
||||
formDataFrom({
|
||||
locale: "en",
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
message: "A perfectly valid message body here.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(url).toContain("/en/contact?error=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
|
||||
import { getMaintenanceMode } from "@/lib/app-config";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
resetNextMocks();
|
||||
});
|
||||
|
||||
describe("updateMaintenanceModeAction", () => {
|
||||
it("enables maintenance mode and redirects with a success flash", async () => {
|
||||
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
|
||||
expect(url).toContain("success=");
|
||||
expect(await getMaintenanceMode()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables maintenance mode", async () => {
|
||||
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
|
||||
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "false" })));
|
||||
expect(await getMaintenanceMode()).toBe(false);
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
|
||||
expect(url).toBe("/");
|
||||
// state unchanged
|
||||
expect(await getMaintenanceMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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 { saveMarqueeSettingsAction } from "@/app/_admin/marquee/actions";
|
||||
import { getMarqueeSettings } from "@/lib/app-config";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
resetNextMocks();
|
||||
});
|
||||
|
||||
const validRows = {
|
||||
"row1-de": "A\nB",
|
||||
"row2-de": "C\nD",
|
||||
"row3-de": "E\nF",
|
||||
"row4-de": "G\nH",
|
||||
};
|
||||
|
||||
describe("saveMarqueeSettingsAction", () => {
|
||||
it("saves german rows and mirrors them across locales", async () => {
|
||||
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
|
||||
expect(url).toContain("success=");
|
||||
|
||||
const settings = await getMarqueeSettings();
|
||||
expect(settings.locales.de.row1).toBe("A\nB");
|
||||
expect(settings.locales.en.row1).toBe("A\nB");
|
||||
expect(settings.locales.ar.row4).toBe("G\nH");
|
||||
});
|
||||
|
||||
it("redirects with an error when a required row is empty", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveMarqueeSettingsAction(formDataFrom({ ...validRows, "row2-de": " " })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||
import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
|
||||
import { canManageUploads } from "@/tests/helpers/fs-capability";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
resetNextMocks();
|
||||
});
|
||||
|
||||
describe("createMediaAssetAction", () => {
|
||||
it("errors when no file is provided", async () => {
|
||||
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
|
||||
expect(url).toContain("error=");
|
||||
expect(await prisma.mediaAsset.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
|
||||
const url = await captureRedirect(() =>
|
||||
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
|
||||
);
|
||||
expect(url).toContain("success=");
|
||||
const assets = await prisma.mediaAsset.findMany();
|
||||
expect(assets.length).toBe(1);
|
||||
expect(assets[0].source).toBe("UPLOAD");
|
||||
await removeManagedMediaFile(assets[0].url);
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMediaAssetAction", () => {
|
||||
it("errors when the asset does not exist", async () => {
|
||||
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "missing" })));
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("refuses to delete an asset that is still in use", async () => {
|
||||
const asset = await createMediaAsset();
|
||||
await createMediaUsage(asset.id);
|
||||
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
|
||||
expect(url).toContain("error=");
|
||||
expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes an unused external asset", async () => {
|
||||
const asset = await createMediaAsset({ url: "https://cdn/external.png" });
|
||||
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
|
||||
expect(url).toContain("success=");
|
||||
expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "x" })));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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 {
|
||||
saveSiteBrandSettingsAction,
|
||||
saveSiteLocalizationSettingsAction,
|
||||
} from "@/app/_admin/site-settings/actions";
|
||||
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
resetNextMocks();
|
||||
});
|
||||
|
||||
describe("saveSiteBrandSettingsAction", () => {
|
||||
it("saves a normalized primary color", async () => {
|
||||
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
|
||||
expect(url).toContain("success=");
|
||||
expect((await getSiteSettings()).brand.primaryColor).toBe("#123456");
|
||||
});
|
||||
|
||||
it("falls back to the default color for invalid input", async () => {
|
||||
await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "not-a-color" })));
|
||||
expect((await getSiteSettings()).brand.primaryColor).toBe("#dc5a35");
|
||||
});
|
||||
|
||||
it("wires an external favicon into media bindings", async () => {
|
||||
const faviconMedia = JSON.stringify({ mode: "external", url: "https://cdn/f.svg", kind: "IMAGE", label: "F" });
|
||||
const url = await captureRedirect(() =>
|
||||
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia })),
|
||||
);
|
||||
expect(url).toContain("success=");
|
||||
const bindings = await getSiteSettingsMediaBindings();
|
||||
expect(bindings.favicon?.url).toBe("https://cdn/f.svg");
|
||||
});
|
||||
|
||||
it("errors on an invalid media json payload", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia: "{not json" })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
const localizationForm = {
|
||||
defaultLocale: "en",
|
||||
siteNameAr: "الموقع",
|
||||
siteNameEn: "The Site",
|
||||
siteNameDe: "Die Seite",
|
||||
titleTemplateAr: "{pageTitle} | {siteName}",
|
||||
titleTemplateEn: "{pageTitle} | {siteName}",
|
||||
titleTemplateDe: "{pageTitle} | {siteName}",
|
||||
siteDescriptionAr: "وصف",
|
||||
siteDescriptionEn: "Description",
|
||||
siteDescriptionDe: "Beschreibung",
|
||||
subheadAr: "",
|
||||
subheadEn: "",
|
||||
subheadDe: "",
|
||||
};
|
||||
|
||||
describe("saveSiteLocalizationSettingsAction", () => {
|
||||
it("saves valid localization settings and the default locale", async () => {
|
||||
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
|
||||
expect(url).toContain("success=");
|
||||
const settings = await getSiteSettings();
|
||||
expect(settings.defaultLocale).toBe("en");
|
||||
expect(settings.locales.en.siteName).toBe("The Site");
|
||||
});
|
||||
|
||||
it("requires a site name for every locale", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, siteNameEn: "" })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("requires the {pageTitle} token in every title template", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, titleTemplateDe: "{siteName} only" })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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 };
|
||||
});
|
||||
|
||||
const { sendTestEmail } = vi.hoisted(() => ({ sendTestEmail: vi.fn(async () => {}) }));
|
||||
vi.mock("@/lib/mail", () => ({ sendTestEmail }));
|
||||
|
||||
import { saveMailSettingsAction, sendTestEmailAction } from "@/app/_admin/smtp/actions";
|
||||
import { getMailSettings } from "@/lib/app-config";
|
||||
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
|
||||
|
||||
beforeEach(() => {
|
||||
resetNextMocks();
|
||||
sendTestEmail.mockClear();
|
||||
});
|
||||
|
||||
const validForm = {
|
||||
smtpHost: "smtp.example.com",
|
||||
smtpPort: "465",
|
||||
smtpUsername: "mailer",
|
||||
smtpPassword: "secret",
|
||||
smtpSecure: "on",
|
||||
mailFromEmail: "from@example.com",
|
||||
mailFromName: "Studio",
|
||||
mailContactRecipient: "contact@example.com",
|
||||
mailTestRecipient: "test@example.com",
|
||||
};
|
||||
|
||||
describe("saveMailSettingsAction", () => {
|
||||
it("persists valid settings and redirects with success", async () => {
|
||||
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
|
||||
expect(url).toContain("success=");
|
||||
|
||||
const settings = await getMailSettings();
|
||||
expect(settings.smtp.host).toBe("smtp.example.com");
|
||||
expect(settings.smtp.port).toBe(465);
|
||||
expect(settings.smtp.secure).toBe(true);
|
||||
expect(settings.recipients.contact).toBe("contact@example.com");
|
||||
});
|
||||
|
||||
it("rejects an invalid port with an error flash", async () => {
|
||||
const url = await captureRedirect(() =>
|
||||
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPort: "not-a-number" })),
|
||||
);
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("retains the existing password when the field is left blank", async () => {
|
||||
await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
|
||||
await captureRedirect(() =>
|
||||
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPassword: "" })),
|
||||
);
|
||||
const settings = await getMailSettings();
|
||||
expect(settings.smtp.password).toBe("secret");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
|
||||
expect(url).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTestEmailAction", () => {
|
||||
it("sends a test email and redirects with success", async () => {
|
||||
const url = await captureRedirect(() => sendTestEmailAction());
|
||||
expect(sendTestEmail).toHaveBeenCalledTimes(1);
|
||||
expect(url).toContain("success=");
|
||||
});
|
||||
|
||||
it("redirects with an error when sending fails", async () => {
|
||||
sendTestEmail.mockRejectedValueOnce(new Error("SMTP host is required."));
|
||||
const url = await captureRedirect(() => sendTestEmailAction());
|
||||
expect(url).toContain("error=");
|
||||
});
|
||||
|
||||
it("redirects unauthenticated callers to the admin root", async () => {
|
||||
adminAuth.authenticated = false;
|
||||
const url = await captureRedirect(() => sendTestEmailAction());
|
||||
expect(url).toBe("/");
|
||||
expect(sendTestEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => new Headers({ "x-forwarded-for": "203.0.113.7" }),
|
||||
cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getAdminLockState,
|
||||
isAdminAuthConfigured,
|
||||
isPasswordValid,
|
||||
registerFailedAdminAttempt,
|
||||
resetAdminFailedAttempts,
|
||||
} from "@/lib/admin-auth";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("admin auth configuration", () => {
|
||||
it("is configured only when both password and secret are set", () => {
|
||||
vi.stubEnv("ADMIN_PASSWORD", "");
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "");
|
||||
expect(isAdminAuthConfigured()).toBe(false);
|
||||
|
||||
vi.stubEnv("ADMIN_PASSWORD", "pw");
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "");
|
||||
expect(isAdminAuthConfigured()).toBe(false);
|
||||
|
||||
vi.stubEnv("ADMIN_PASSWORD", "pw");
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "secret");
|
||||
expect(isAdminAuthConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPasswordValid", () => {
|
||||
it("returns false when auth is not configured", () => {
|
||||
vi.stubEnv("ADMIN_PASSWORD", "");
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "");
|
||||
expect(isPasswordValid("anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the correct password and rejects wrong ones", () => {
|
||||
vi.stubEnv("ADMIN_PASSWORD", "s3cret-password");
|
||||
vi.stubEnv("ADMIN_AUTH_SECRET", "hmac-secret");
|
||||
expect(isPasswordValid("s3cret-password")).toBe(true);
|
||||
expect(isPasswordValid("wrong")).toBe(false);
|
||||
expect(isPasswordValid("s3cret-passwordX")).toBe(false); // length mismatch
|
||||
});
|
||||
});
|
||||
|
||||
describe("login lockout", () => {
|
||||
it("locks the IP after the failed-attempt threshold", async () => {
|
||||
expect((await getAdminLockState()).locked).toBe(false);
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const state = await registerFailedAdminAttempt();
|
||||
expect(state.locked).toBe(false);
|
||||
}
|
||||
expect((await getAdminLockState()).locked).toBe(false);
|
||||
|
||||
const fifth = await registerFailedAdminAttempt();
|
||||
expect(fifth.locked).toBe(true);
|
||||
expect(fifth.remainingSeconds).toBeGreaterThan(0);
|
||||
|
||||
const lockState = await getAdminLockState();
|
||||
expect(lockState.locked).toBe(true);
|
||||
expect(lockState.remainingSeconds).toBeGreaterThan(0);
|
||||
expect(lockState.remainingSeconds).toBeLessThanOrEqual(15 * 60);
|
||||
});
|
||||
|
||||
it("clears the lock on reset", async () => {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await registerFailedAdminAttempt();
|
||||
}
|
||||
expect((await getAdminLockState()).locked).toBe(true);
|
||||
|
||||
await resetAdminFailedAttempts();
|
||||
expect((await getAdminLockState()).locked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GET as healthGet } from "@/app/api/health/route";
|
||||
import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route";
|
||||
import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/health", () => {
|
||||
it("reports ok when the database responds", async () => {
|
||||
const response = await healthGet();
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.checks.database).toBe("up");
|
||||
expect(typeof body.timestamp).toBe("string");
|
||||
});
|
||||
|
||||
it("reports degraded (503) when the database query throws", async () => {
|
||||
vi.spyOn(prisma, "$queryRaw").mockRejectedValueOnce(new Error("db down"));
|
||||
const response = await healthGet();
|
||||
expect(response.status).toBe(503);
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("degraded");
|
||||
expect(body.checks.database).toBe("down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/site/default-locale", () => {
|
||||
it("returns the runtime default locale and maintenance flag with no-store", async () => {
|
||||
const response = await defaultLocaleGet();
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
|
||||
const body = await response.json();
|
||||
expect(body.defaultLocale).toBe("de");
|
||||
expect(body.maintenanceEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects updated settings and maintenance state", async () => {
|
||||
const settings = await getSiteSettings();
|
||||
settings.defaultLocale = "ar";
|
||||
await updateSiteSettings(settings);
|
||||
await setMaintenanceMode(true);
|
||||
|
||||
const response = await defaultLocaleGet();
|
||||
const body = await response.json();
|
||||
expect(body.defaultLocale).toBe("ar");
|
||||
expect(body.maintenanceEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_SITE_NAME,
|
||||
MAINTENANCE_MODE_KEY,
|
||||
SITE_NAME_KEY,
|
||||
SITE_SETTINGS_ENTITY_ID,
|
||||
SITE_SETTINGS_ENTITY_TYPE,
|
||||
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
||||
buildDefaultMailSettings,
|
||||
buildDefaultMarqueeSettings,
|
||||
getMailSettings,
|
||||
getMaintenanceMode,
|
||||
getMarqueeSettings,
|
||||
getSiteSettings,
|
||||
getSiteSettingsMediaBindings,
|
||||
setMaintenanceMode,
|
||||
updateMailSettings,
|
||||
updateMarqueeSettings,
|
||||
updateSiteSettings,
|
||||
} from "@/lib/app-config";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createMediaAsset } from "@/tests/helpers/factories";
|
||||
|
||||
describe("maintenance mode", () => {
|
||||
it("defaults to false when unset", async () => {
|
||||
expect(await getMaintenanceMode()).toBe(false);
|
||||
});
|
||||
|
||||
it("persists and reads back the enabled flag", async () => {
|
||||
await setMaintenanceMode(true);
|
||||
expect(await getMaintenanceMode()).toBe(true);
|
||||
const row = await prisma.appConfig.findUnique({ where: { key: MAINTENANCE_MODE_KEY } });
|
||||
expect(row?.value).toBe("true");
|
||||
await setMaintenanceMode(false);
|
||||
expect(await getMaintenanceMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("site settings", () => {
|
||||
it("returns defaults (with fallback name) when nothing stored", async () => {
|
||||
const settings = await getSiteSettings();
|
||||
expect(settings.defaultLocale).toBe("de");
|
||||
expect(settings.locales.en.siteName).toBe(DEFAULT_SITE_NAME);
|
||||
});
|
||||
|
||||
it("uses the stored siteName key as the fallback name", async () => {
|
||||
await prisma.appConfig.create({ data: { key: SITE_NAME_KEY, value: "My Studio" } });
|
||||
const settings = await getSiteSettings();
|
||||
expect(settings.locales.ar.siteName).toBe("My Studio");
|
||||
});
|
||||
|
||||
it("round-trips an updated settings object", async () => {
|
||||
const next = await getSiteSettings();
|
||||
next.defaultLocale = "ar";
|
||||
next.brand.primaryColor = "#123456";
|
||||
next.locales.en.siteName = "Updated EN";
|
||||
await updateSiteSettings(next);
|
||||
|
||||
const reloaded = await getSiteSettings();
|
||||
expect(reloaded.defaultLocale).toBe("ar");
|
||||
expect(reloaded.brand.primaryColor).toBe("#123456");
|
||||
expect(reloaded.locales.en.siteName).toBe("Updated EN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mail settings", () => {
|
||||
it("returns defaults when unset", async () => {
|
||||
expect(await getMailSettings()).toEqual(buildDefaultMailSettings());
|
||||
});
|
||||
|
||||
it("round-trips stored mail settings", async () => {
|
||||
const next = buildDefaultMailSettings();
|
||||
next.smtp.host = "smtp.test";
|
||||
next.smtp.port = 465;
|
||||
next.sender.email = "from@test";
|
||||
next.recipients.contact = "c@test";
|
||||
await updateMailSettings(next);
|
||||
|
||||
const reloaded = await getMailSettings();
|
||||
expect(reloaded.smtp.host).toBe("smtp.test");
|
||||
expect(reloaded.smtp.port).toBe(465);
|
||||
expect(reloaded.recipients.contact).toBe("c@test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("marquee settings", () => {
|
||||
it("returns defaults when unset", async () => {
|
||||
const settings = await getMarqueeSettings();
|
||||
expect(settings.locales.de.row1).toBe(buildDefaultMarqueeSettings().locales.de.row1);
|
||||
});
|
||||
|
||||
it("stores german-synced values", async () => {
|
||||
const next = buildDefaultMarqueeSettings();
|
||||
next.locales.de.row1 = "GERMAN ROW";
|
||||
next.locales.en.row1 = "will be overwritten";
|
||||
await updateMarqueeSettings(next);
|
||||
|
||||
const reloaded = await getMarqueeSettings();
|
||||
expect(reloaded.locales.de.row1).toBe("GERMAN ROW");
|
||||
expect(reloaded.locales.en.row1).toBe("GERMAN ROW");
|
||||
expect(reloaded.locales.ar.row1).toBe("GERMAN ROW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSiteSettingsMediaBindings", () => {
|
||||
it("returns nulls when there are no usages", async () => {
|
||||
const bindings = await getSiteSettingsMediaBindings();
|
||||
expect(bindings).toEqual({ siteLogoLight: null, siteLogoDark: null, favicon: null, defaultOgImage: null });
|
||||
});
|
||||
|
||||
it("maps media usages to their field bindings", async () => {
|
||||
const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
|
||||
const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
|
||||
await prisma.mediaUsage.create({
|
||||
data: {
|
||||
assetId: logo.id,
|
||||
usageType: "GENERIC",
|
||||
entityType: SITE_SETTINGS_ENTITY_TYPE,
|
||||
entityId: SITE_SETTINGS_ENTITY_ID,
|
||||
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
|
||||
},
|
||||
});
|
||||
await prisma.mediaUsage.create({
|
||||
data: {
|
||||
assetId: favicon.id,
|
||||
usageType: "GENERIC",
|
||||
entityType: SITE_SETTINGS_ENTITY_TYPE,
|
||||
entityId: SITE_SETTINGS_ENTITY_ID,
|
||||
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const bindings = await getSiteSettingsMediaBindings();
|
||||
expect(bindings.siteLogoLight?.assetId).toBe(logo.id);
|
||||
expect(bindings.siteLogoLight?.url).toBe("https://cdn/logo.png");
|
||||
expect(bindings.favicon?.assetId).toBe(favicon.id);
|
||||
expect(bindings.favicon?.version).toMatch(/\d{4}-\d{2}-\d{2}T/); // updatedAt ISO string
|
||||
expect(bindings.siteLogoDark).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMediaSelection } from "@/lib/media-service";
|
||||
import { resolveMediaUploadPath } from "@/lib/media-storage";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createMediaAsset } from "@/tests/helpers/factories";
|
||||
import { canManageUploads } from "@/tests/helpers/fs-capability";
|
||||
|
||||
describe("resolveMediaSelection — library mode", () => {
|
||||
it("returns the referenced asset", async () => {
|
||||
const asset = await createMediaAsset({ url: "https://cdn/lib.png" });
|
||||
const result = await resolveMediaSelection({
|
||||
media: { mode: "library", assetId: asset.id, url: "", label: "", kind: "IMAGE" },
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "Cover",
|
||||
required: false,
|
||||
});
|
||||
expect(result.assetId).toBe(asset.id);
|
||||
expect(result.url).toBe("https://cdn/lib.png");
|
||||
});
|
||||
|
||||
it("throws when the referenced asset is missing", async () => {
|
||||
await expect(
|
||||
resolveMediaSelection({
|
||||
media: { mode: "library", assetId: "nope", url: "", label: "", kind: "IMAGE" },
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "Cover",
|
||||
required: true,
|
||||
}),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMediaSelection — external mode", () => {
|
||||
it("creates a new external asset from the url", async () => {
|
||||
const result = await resolveMediaSelection({
|
||||
media: { mode: "external", assetId: "", url: "https://cdn/new/photo.png", label: "Photo", kind: "IMAGE" },
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "Cover",
|
||||
required: true,
|
||||
});
|
||||
expect(result.createdAssetId).toBeTruthy();
|
||||
expect(result.url).toBe("https://cdn/new/photo.png");
|
||||
|
||||
const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } });
|
||||
expect(stored?.source).toBe("EXTERNAL");
|
||||
expect(stored?.fileName).toBe("photo.png");
|
||||
expect(stored?.label).toBe("Photo");
|
||||
});
|
||||
|
||||
it("returns empty selection for a not-required empty url", async () => {
|
||||
const result = await resolveMediaSelection({
|
||||
media: { mode: "external", assetId: "", url: "", label: "", kind: "IMAGE" },
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "Cover",
|
||||
required: false,
|
||||
});
|
||||
expect(result.assetId).toBeNull();
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMediaSelection — missing configuration", () => {
|
||||
it("throws when required and no media object is present", async () => {
|
||||
await expect(
|
||||
resolveMediaSelection({ media: undefined, uploadFile: null, folder: "covers", fallbackLabel: "L", required: true }),
|
||||
).rejects.toThrow(/missing/i);
|
||||
});
|
||||
|
||||
it("returns empty selection when not required and no media object is present", async () => {
|
||||
const result = await resolveMediaSelection({
|
||||
media: undefined,
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "L",
|
||||
required: false,
|
||||
});
|
||||
expect(result.assetId).toBeNull();
|
||||
});
|
||||
|
||||
it("throws for a required upload with no file", async () => {
|
||||
await expect(
|
||||
resolveMediaSelection({
|
||||
media: { mode: "upload", assetId: "", url: "", label: "", kind: "IMAGE" },
|
||||
uploadFile: null,
|
||||
folder: "covers",
|
||||
fallbackLabel: "L",
|
||||
required: true,
|
||||
}),
|
||||
).rejects.toThrow(/required/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMediaSelection — upload mode (filesystem)", () => {
|
||||
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
|
||||
const result = await resolveMediaSelection({
|
||||
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
|
||||
uploadFile: file,
|
||||
folder: "tests",
|
||||
fallbackLabel: "L",
|
||||
required: true,
|
||||
});
|
||||
expect(result.uploadedUrl).toBeTruthy();
|
||||
const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } });
|
||||
expect(stored?.source).toBe("UPLOAD");
|
||||
// File actually written to disk
|
||||
const bytes = await readFile(resolveMediaUploadPath(result.url));
|
||||
expect(bytes.length).toBeGreaterThan(0);
|
||||
// cleanup
|
||||
const { removeManagedMediaFile } = await import("@/lib/media-storage");
|
||||
await removeManagedMediaFile(result.url);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
countMediaUsageReferences,
|
||||
createMediaAsset,
|
||||
deleteEntityMediaUsages,
|
||||
getAdminMediaAssets,
|
||||
getMediaAssetById,
|
||||
getMediaOptions,
|
||||
getPortfolioMediaBindings,
|
||||
replaceEntityMediaUsages,
|
||||
} from "@/lib/media";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createMediaAsset as seedAsset } from "@/tests/helpers/factories";
|
||||
|
||||
describe("createMediaAsset / getMediaAssetById", () => {
|
||||
it("creates and reads back an asset with usages", async () => {
|
||||
const created = await createMediaAsset({
|
||||
source: "EXTERNAL",
|
||||
kind: "IMAGE",
|
||||
url: "https://cdn/x.png",
|
||||
fileName: "x.png",
|
||||
label: "X",
|
||||
});
|
||||
const found = await getMediaAssetById(created.id);
|
||||
expect(found?.url).toBe("https://cdn/x.png");
|
||||
expect(found?.usages).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null for a missing asset", async () => {
|
||||
expect(await getMediaAssetById("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMediaOptions", () => {
|
||||
it("filters by kind", async () => {
|
||||
await seedAsset({ kind: "IMAGE" });
|
||||
await seedAsset({ kind: "DOCUMENT" });
|
||||
const images = await getMediaOptions({ kind: "IMAGE" });
|
||||
expect(images.every((a) => a.kind === "IMAGE")).toBe(true);
|
||||
const all = await getMediaOptions();
|
||||
expect(all.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdminMediaAssets", () => {
|
||||
it("returns newest first with usage details", async () => {
|
||||
const a = await seedAsset();
|
||||
await createMediaUsageFor(a.id);
|
||||
const list = await getAdminMediaAssets();
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0].usages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceEntityMediaUsages", () => {
|
||||
it("replaces existing usages transactionally", async () => {
|
||||
const a1 = await seedAsset();
|
||||
const a2 = await seedAsset();
|
||||
|
||||
await replaceEntityMediaUsages({
|
||||
entityType: "portfolio-project",
|
||||
entityId: "p1",
|
||||
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
|
||||
});
|
||||
expect(await countMediaUsageReferences(a1.id)).toBe(1);
|
||||
|
||||
await replaceEntityMediaUsages({
|
||||
entityType: "portfolio-project",
|
||||
entityId: "p1",
|
||||
usages: [{ assetId: a2.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
|
||||
});
|
||||
expect(await countMediaUsageReferences(a1.id)).toBe(0);
|
||||
expect(await countMediaUsageReferences(a2.id)).toBe(1);
|
||||
});
|
||||
|
||||
it("clears usages when given an empty list", async () => {
|
||||
const a1 = await seedAsset();
|
||||
await replaceEntityMediaUsages({
|
||||
entityType: "portfolio-project",
|
||||
entityId: "p2",
|
||||
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
|
||||
});
|
||||
await replaceEntityMediaUsages({ entityType: "portfolio-project", entityId: "p2", usages: [] });
|
||||
expect(await countMediaUsageReferences(a1.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteEntityMediaUsages", () => {
|
||||
it("removes only the target entity's usages", async () => {
|
||||
const a1 = await seedAsset();
|
||||
await replaceEntityMediaUsages({
|
||||
entityType: "portfolio-project",
|
||||
entityId: "keep",
|
||||
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
|
||||
});
|
||||
await replaceEntityMediaUsages({
|
||||
entityType: "portfolio-project",
|
||||
entityId: "drop",
|
||||
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "b" }],
|
||||
});
|
||||
await deleteEntityMediaUsages("portfolio-project", "drop");
|
||||
expect(await countMediaUsageReferences(a1.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPortfolioMediaBindings", () => {
|
||||
it("routes usages into cover / section / asset buckets", async () => {
|
||||
const cover = await seedAsset();
|
||||
const section = await seedAsset();
|
||||
const asset = await seedAsset();
|
||||
|
||||
await prisma.mediaUsage.createMany({
|
||||
data: [
|
||||
{ assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" },
|
||||
{ assetId: section.id, usageType: "PORTFOLIO_SECTION", entityType: "portfolio-project", entityId: "proj", fieldKey: "sec_1" },
|
||||
{ assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
|
||||
],
|
||||
});
|
||||
|
||||
const bindings = await getPortfolioMediaBindings("proj");
|
||||
expect(bindings.coverAssetId).toBe(cover.id);
|
||||
expect(bindings.sectionAssetIds.sec_1).toBe(section.id);
|
||||
expect(bindings.assetIds.ast_1).toBe(asset.id);
|
||||
});
|
||||
|
||||
it("returns empty bindings for an unknown project", async () => {
|
||||
const bindings = await getPortfolioMediaBindings("missing");
|
||||
expect(bindings).toEqual({ coverAssetId: null, sectionAssetIds: {}, assetIds: {} });
|
||||
});
|
||||
});
|
||||
|
||||
async function createMediaUsageFor(assetId: string) {
|
||||
await prisma.mediaUsage.create({
|
||||
data: { assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getActivePortfolioCategories,
|
||||
getActivePortfolioCategoryBySlug,
|
||||
getAdminPortfolioCategories,
|
||||
getAdminPortfolioProjectById,
|
||||
getAdminPortfolioProjects,
|
||||
getPublishedPortfolioProjectBySlug,
|
||||
getPublishedPortfolioProjects,
|
||||
} from "@/lib/portfolio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createAsset,
|
||||
createCategory,
|
||||
createMediaAsset,
|
||||
createProject,
|
||||
createSection,
|
||||
} from "@/tests/helpers/factories";
|
||||
|
||||
describe("categories", () => {
|
||||
it("lists admin categories with project counts, ordered", async () => {
|
||||
const a = await createCategory({ slug: "a", sortOrder: 2 });
|
||||
await createCategory({ slug: "b", sortOrder: 1 });
|
||||
await createProject({ categoryId: a.id });
|
||||
|
||||
const categories = await getAdminPortfolioCategories();
|
||||
expect(categories.map((c) => c.slug)).toEqual(["b", "a"]); // sortOrder asc
|
||||
expect(categories.find((c) => c.slug === "a")?.projectCount).toBe(1);
|
||||
expect(categories.find((c) => c.slug === "b")?.projectCount).toBe(0);
|
||||
});
|
||||
|
||||
it("returns only active categories publicly", async () => {
|
||||
await createCategory({ slug: "on", isActive: true });
|
||||
await createCategory({ slug: "off", isActive: false });
|
||||
const active = await getActivePortfolioCategories();
|
||||
expect(active.map((c) => c.slug)).toEqual(["on"]);
|
||||
});
|
||||
|
||||
it("finds an active category by slug and ignores inactive ones", async () => {
|
||||
await createCategory({ slug: "visible", isActive: true });
|
||||
await createCategory({ slug: "hidden", isActive: false });
|
||||
expect((await getActivePortfolioCategoryBySlug("visible"))?.slug).toBe("visible");
|
||||
expect(await getActivePortfolioCategoryBySlug("hidden")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin projects", () => {
|
||||
it("filters by status and category", async () => {
|
||||
const cat = await createCategory();
|
||||
await createProject({ categoryId: cat.id, slug: "pub", isPublished: true });
|
||||
await createProject({ categoryId: cat.id, slug: "draft", isPublished: false });
|
||||
|
||||
const published = await getAdminPortfolioProjects({ status: "published" });
|
||||
expect(published.map((p) => p.slug)).toEqual(["pub"]);
|
||||
|
||||
const drafts = await getAdminPortfolioProjects({ status: "draft" });
|
||||
expect(drafts.map((p) => p.slug)).toEqual(["draft"]);
|
||||
|
||||
const byCategory = await getAdminPortfolioProjects({ categoryId: cat.id });
|
||||
expect(byCategory.length).toBe(2);
|
||||
});
|
||||
|
||||
it("maps localized content and nested sections/assets", async () => {
|
||||
const project = await createProject({ slug: "mapped" });
|
||||
await createSection(project.id, { titleEn: "Intro" });
|
||||
await createAsset(project.id, { altEn: "Cover" });
|
||||
|
||||
const detail = await getAdminPortfolioProjectById(project.id);
|
||||
expect(detail?.title.en).toBe("Title");
|
||||
expect(detail?.sections[0].title.en).toBe("Intro");
|
||||
expect(detail?.assets[0].alt.en).toBe("Cover");
|
||||
});
|
||||
|
||||
it("attaches media bindings to a project fetched by id", async () => {
|
||||
const project = await createProject();
|
||||
const cover = await createMediaAsset();
|
||||
await prisma.mediaUsage.create({
|
||||
data: {
|
||||
assetId: cover.id,
|
||||
usageType: "PORTFOLIO_COVER",
|
||||
entityType: "portfolio-project",
|
||||
entityId: project.id,
|
||||
fieldKey: "cover",
|
||||
},
|
||||
});
|
||||
const detail = await getAdminPortfolioProjectById(project.id);
|
||||
expect(detail?.coverMediaAssetId).toBe(cover.id);
|
||||
});
|
||||
|
||||
it("returns null for a missing project id", async () => {
|
||||
expect(await getAdminPortfolioProjectById("missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("published projects", () => {
|
||||
it("returns only published projects in active categories", async () => {
|
||||
const activeCat = await createCategory({ isActive: true });
|
||||
const inactiveCat = await createCategory({ isActive: false });
|
||||
await createProject({ categoryId: activeCat.id, slug: "shown", isPublished: true });
|
||||
await createProject({ categoryId: activeCat.id, slug: "hidden-draft", isPublished: false });
|
||||
await createProject({ categoryId: inactiveCat.id, slug: "hidden-cat", isPublished: true });
|
||||
|
||||
const projects = await getPublishedPortfolioProjects();
|
||||
expect(projects.map((p) => p.slug)).toEqual(["shown"]);
|
||||
});
|
||||
|
||||
it("filters published projects by category slug", async () => {
|
||||
const catA = await createCategory({ slug: "cat-a", isActive: true });
|
||||
const catB = await createCategory({ slug: "cat-b", isActive: true });
|
||||
await createProject({ categoryId: catA.id, slug: "in-a", isPublished: true });
|
||||
await createProject({ categoryId: catB.id, slug: "in-b", isPublished: true });
|
||||
|
||||
const projects = await getPublishedPortfolioProjects({ categorySlug: "cat-a" });
|
||||
expect(projects.map((p) => p.slug)).toEqual(["in-a"]);
|
||||
});
|
||||
|
||||
it("finds a published project by slug and hides drafts", async () => {
|
||||
await createProject({ slug: "live", isPublished: true });
|
||||
await createProject({ slug: "wip", isPublished: false });
|
||||
expect((await getPublishedPortfolioProjectBySlug("live"))?.slug).toBe("live");
|
||||
expect(await getPublishedPortfolioProjectBySlug("wip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("referential integrity", () => {
|
||||
it("restricts deleting a category that still has projects", async () => {
|
||||
const cat = await createCategory();
|
||||
await createProject({ categoryId: cat.id });
|
||||
await expect(prisma.category.delete({ where: { id: cat.id } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("cascades section and asset deletion when a project is removed", async () => {
|
||||
const project = await createProject();
|
||||
await createSection(project.id);
|
||||
await createAsset(project.id);
|
||||
await prisma.portfolioProject.delete({ where: { id: project.id } });
|
||||
expect(await prisma.portfolioSection.count()).toBe(0);
|
||||
expect(await prisma.portfolioAsset.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user