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,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readFlash, withFlash } from "@/lib/admin-feedback";
|
||||
|
||||
describe("withFlash", () => {
|
||||
it("returns the plain path when there are no messages", () => {
|
||||
expect(withFlash("/admin/smtp", {})).toBe("/admin/smtp");
|
||||
});
|
||||
|
||||
it("appends a success message", () => {
|
||||
expect(withFlash("/admin/smtp", { success: "Saved." })).toBe("/admin/smtp?success=Saved.");
|
||||
});
|
||||
|
||||
it("appends an error message", () => {
|
||||
expect(withFlash("/admin/smtp", { error: "Nope." })).toBe("/admin/smtp?error=Nope.");
|
||||
});
|
||||
|
||||
it("appends both and url-encodes values", () => {
|
||||
const result = withFlash("/admin/smtp", { success: "a b", error: "x&y" });
|
||||
const params = new URL(result, "http://local").searchParams;
|
||||
expect(params.get("success")).toBe("a b");
|
||||
expect(params.get("error")).toBe("x&y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFlash", () => {
|
||||
it("reads success and error from resolved search params", () => {
|
||||
expect(readFlash({ success: "ok", error: "bad" })).toEqual({ success: "ok", error: "bad" });
|
||||
});
|
||||
|
||||
it("returns undefined fields when params are missing", () => {
|
||||
expect(readFlash(undefined)).toEqual({ success: undefined, error: undefined });
|
||||
expect(readFlash(null)).toEqual({ success: undefined, error: undefined });
|
||||
expect(readFlash({})).toEqual({ success: undefined, error: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdminNavigation } from "@/lib/admin-navigation";
|
||||
|
||||
const copy = {
|
||||
overview: "Overview",
|
||||
maintenance: "Maintenance",
|
||||
uiKit: "UI Kit",
|
||||
portfolio: "Portfolio",
|
||||
media: "Media",
|
||||
siteSettings: "Site Settings",
|
||||
brandSettings: "Brand",
|
||||
localizationSettings: "Localization",
|
||||
marquee: "Marquee",
|
||||
smtp: "SMTP",
|
||||
};
|
||||
|
||||
describe("getAdminNavigation", () => {
|
||||
it("returns the full set of top-level sections", () => {
|
||||
const nav = getAdminNavigation(copy, "overview");
|
||||
const labels = nav.map((item) => item.label);
|
||||
expect(labels).toEqual([
|
||||
"Overview",
|
||||
"Maintenance",
|
||||
"UI Kit",
|
||||
"Media",
|
||||
"Site Settings",
|
||||
"Marquee",
|
||||
"SMTP",
|
||||
"Portfolio",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the active top-level section", () => {
|
||||
const nav = getAdminNavigation(copy, "smtp");
|
||||
expect(nav.find((item) => item.label === "SMTP")?.active).toBe(true);
|
||||
expect(nav.find((item) => item.label === "Overview")?.active).toBe(false);
|
||||
});
|
||||
|
||||
it("expands site-settings and marks the active child", () => {
|
||||
const nav = getAdminNavigation(copy, "site-settings", undefined, "localization");
|
||||
const siteSettings = nav.find((item) => item.label === "Site Settings");
|
||||
expect(siteSettings?.expanded).toBe(true);
|
||||
expect(siteSettings?.active).toBe(false); // has a child selected
|
||||
const localization = siteSettings?.children?.find((c) => c.label === "Localization");
|
||||
expect(localization?.active).toBe(true);
|
||||
});
|
||||
|
||||
it("marks the parent active when no child is selected", () => {
|
||||
const nav = getAdminNavigation(copy, "site-settings");
|
||||
const siteSettings = nav.find((item) => item.label === "Site Settings");
|
||||
expect(siteSettings?.active).toBe(true);
|
||||
expect(siteSettings?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("maps portfolio children and de-duplicates hrefs", () => {
|
||||
const nav = getAdminNavigation(copy, "portfolio", "projects");
|
||||
const portfolio = nav.find((item) => item.label === "Portfolio");
|
||||
expect(portfolio?.expanded).toBe(true);
|
||||
const hrefs = portfolio?.children?.map((c) => c.href) ?? [];
|
||||
expect(new Set(hrefs).size).toBe(hrefs.length); // unique
|
||||
const projects = portfolio?.children?.find((c) => c.label === "Projects");
|
||||
expect(projects?.active).toBe(true);
|
||||
});
|
||||
|
||||
it("activates the new-project child", () => {
|
||||
const nav = getAdminNavigation(copy, "portfolio", "new-project");
|
||||
const portfolio = nav.find((item) => item.label === "Portfolio");
|
||||
expect(portfolio?.children?.find((c) => c.label === "Add Project")?.active).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to default child labels when copy omits them", () => {
|
||||
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
|
||||
const nav = getAdminNavigation(minimal, "overview");
|
||||
const siteSettings = nav.find((item) => item.label === "Site Settings");
|
||||
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
|
||||
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
|
||||
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
|
||||
});
|
||||
|
||||
it("gives every item an icon and href", () => {
|
||||
const nav = getAdminNavigation(copy, "overview");
|
||||
for (const item of nav) {
|
||||
expect(item.icon).toBeTruthy();
|
||||
expect(typeof item.href).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildAdminUrl,
|
||||
buildSiteUrl,
|
||||
fromDevelopmentAdminPath,
|
||||
getAdminAppPath,
|
||||
getAdminBaseUrl,
|
||||
getAdminHost,
|
||||
getRequestHostname,
|
||||
getSiteBaseUrl,
|
||||
getSiteHost,
|
||||
hasDedicatedAdminHost,
|
||||
INTERNAL_ADMIN_PREFIX,
|
||||
isAdminHost,
|
||||
isDevelopmentAdminPath,
|
||||
isInternalAdminPath,
|
||||
isLegacyAdminPath,
|
||||
toInternalAdminPath,
|
||||
} from "@/lib/admin-routing";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("getRequestHostname", () => {
|
||||
it("prefers the first non-empty candidate", () => {
|
||||
expect(getRequestHostname("root.mohfarawati.de", "internal")).toBe("root.mohfarawati.de");
|
||||
});
|
||||
|
||||
it("strips ports and takes the first comma-separated proxy value", () => {
|
||||
expect(getRequestHostname(undefined, "root.mohfarawati.de:443, proxy")).toBe("root.mohfarawati.de");
|
||||
});
|
||||
|
||||
it("lowercases the hostname", () => {
|
||||
expect(getRequestHostname("ROOT.MohFarawati.de")).toBe("root.mohfarawati.de");
|
||||
});
|
||||
|
||||
it("falls back to empty string when nothing matches", () => {
|
||||
expect(getRequestHostname(undefined, null, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host configuration", () => {
|
||||
it("defaults admin and site hosts", () => {
|
||||
expect(getAdminHost()).toBe("root.mohfarawati.de");
|
||||
expect(getSiteHost()).toBe("mohfarawati.de");
|
||||
expect(hasDedicatedAdminHost()).toBe(true);
|
||||
});
|
||||
|
||||
it("reads ADMIN_HOST override and normalizes case/whitespace", () => {
|
||||
vi.stubEnv("ADMIN_HOST", " Admin.Example.COM ");
|
||||
expect(getAdminHost()).toBe("admin.example.com");
|
||||
});
|
||||
|
||||
it("derives the site host from NEXT_PUBLIC_SITE_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.org/some/path");
|
||||
expect(getSiteHost()).toBe("example.org");
|
||||
});
|
||||
|
||||
it("hasDedicatedAdminHost is false when admin and site hosts match", () => {
|
||||
vi.stubEnv("ADMIN_HOST", "example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
|
||||
expect(hasDedicatedAdminHost()).toBe(false);
|
||||
});
|
||||
|
||||
it("isAdminHost compares against the configured admin host", () => {
|
||||
expect(isAdminHost("root.mohfarawati.de")).toBe(true);
|
||||
expect(isAdminHost("mohfarawati.de")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path predicates", () => {
|
||||
it("recognizes legacy /root paths", () => {
|
||||
expect(isLegacyAdminPath("/root")).toBe(true);
|
||||
expect(isLegacyAdminPath("/root/portfolio")).toBe(true);
|
||||
expect(isLegacyAdminPath("/rooting")).toBe(false);
|
||||
expect(isLegacyAdminPath("/")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes development /root paths", () => {
|
||||
expect(isDevelopmentAdminPath("/root")).toBe(true);
|
||||
expect(isDevelopmentAdminPath("/root/media")).toBe(true);
|
||||
expect(isDevelopmentAdminPath("/rootx")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes internal admin paths", () => {
|
||||
expect(isInternalAdminPath(INTERNAL_ADMIN_PREFIX)).toBe(true);
|
||||
expect(isInternalAdminPath(`${INTERNAL_ADMIN_PREFIX}/smtp`)).toBe(true);
|
||||
expect(isInternalAdminPath("/admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path translation", () => {
|
||||
it("maps public paths to internal admin paths", () => {
|
||||
expect(toInternalAdminPath("/")).toBe(INTERNAL_ADMIN_PREFIX);
|
||||
expect(toInternalAdminPath("/portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
|
||||
expect(toInternalAdminPath("portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
|
||||
});
|
||||
|
||||
it("strips the dev /root prefix", () => {
|
||||
expect(fromDevelopmentAdminPath("/root")).toBe("/");
|
||||
expect(fromDevelopmentAdminPath("/root/portfolio")).toBe("/portfolio");
|
||||
expect(fromDevelopmentAdminPath("/portfolio")).toBe("/portfolio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdminAppPath", () => {
|
||||
it("returns bare paths when a dedicated admin host exists", () => {
|
||||
// default env: admin host != site host -> dedicated host branch
|
||||
expect(getAdminAppPath("/")).toBe("/");
|
||||
expect(getAdminAppPath("/smtp")).toBe("/smtp");
|
||||
});
|
||||
|
||||
it("uses the /root dev prefix when no dedicated host and not production", () => {
|
||||
vi.stubEnv("ADMIN_HOST", "example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
expect(getAdminAppPath("/")).toBe("/root");
|
||||
expect(getAdminAppPath("/portfolio")).toBe("/root/portfolio");
|
||||
});
|
||||
|
||||
it("returns bare paths in production even without a dedicated host", () => {
|
||||
vi.stubEnv("ADMIN_HOST", "example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(getAdminAppPath("/portfolio")).toBe("/portfolio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("url builders", () => {
|
||||
it("builds admin urls from NEXT_PUBLIC_ADMIN_URL", () => {
|
||||
expect(getAdminBaseUrl()).toBe("https://root.mohfarawati.de");
|
||||
expect(buildAdminUrl("/smtp")).toBe("https://root.mohfarawati.de/smtp");
|
||||
});
|
||||
|
||||
it("trims trailing slashes from configured base urls", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_ADMIN_URL", "https://admin.example.com/");
|
||||
expect(getAdminBaseUrl()).toBe("https://admin.example.com");
|
||||
});
|
||||
|
||||
it("builds site urls from NEXT_PUBLIC_SITE_URL", () => {
|
||||
expect(getSiteBaseUrl()).toBe("https://mohfarawati.de");
|
||||
expect(buildSiteUrl("/about")).toBe("https://mohfarawati.de/about");
|
||||
expect(buildSiteUrl("/")).toBe("https://mohfarawati.de/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { readFileSync, readdirSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function walk(dir: string, filter: (file: string) => boolean): string[] {
|
||||
const absolute = path.join(ROOT, dir);
|
||||
const results: string[] = [];
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(absolute);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry === "node_modules" || entry === ".next") continue;
|
||||
const full = path.join(absolute, entry);
|
||||
const rel = path.relative(ROOT, full);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...walk(rel, filter));
|
||||
} else if (filter(full)) {
|
||||
results.push(rel);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8");
|
||||
const isSource = (file: string) => /\.(ts|tsx)$/.test(file) && !file.endsWith(".d.ts");
|
||||
|
||||
const componentFiles = walk("components", isSource);
|
||||
const appFiles = walk("app", isSource);
|
||||
const libFiles = walk("lib", isSource);
|
||||
const actionFiles = [...appFiles].filter((file) => /(^|\/)actions\.tsx?$/.test(file));
|
||||
|
||||
describe("architecture: data access boundaries", () => {
|
||||
it("no component imports the Prisma client", () => {
|
||||
const offenders = componentFiles.filter((file) => /["']@\/lib\/prisma["']/.test(read(file)));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no client component imports the Prisma client (defense in depth)", () => {
|
||||
const offenders = [...componentFiles, ...appFiles].filter((file) => {
|
||||
const source = read(file);
|
||||
return /["']use client["']/.test(source) && /lib\/prisma/.test(source);
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("code imports admin server actions from the canonical _admin source, never the mirrors", () => {
|
||||
const offenders = [...componentFiles, ...appFiles, ...libFiles].filter((file) =>
|
||||
/from\s+["']@\/app\/(root|admin-internal)\//.test(read(file)),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("lib modules never import from the app layer", () => {
|
||||
const offenders = libFiles.filter((file) => {
|
||||
const source = read(file);
|
||||
return /from\s+["']@\/app\//.test(source) || /from\s+["']\.\.\/app\//.test(source);
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("architecture: server actions", () => {
|
||||
it("finds the expected server action files", () => {
|
||||
expect(actionFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every actions file starts with the "use server" directive', () => {
|
||||
for (const file of actionFiles) {
|
||||
const firstMeaningfulLine = read(file)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
expect(firstMeaningfulLine, file).toMatch(/^["']use server["'];?$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("every admin action file enforces authentication", () => {
|
||||
const adminActionFiles = actionFiles.filter((file) => file.includes(`${path.sep}_admin${path.sep}`));
|
||||
expect(adminActionFiles.length).toBeGreaterThan(0);
|
||||
for (const file of adminActionFiles) {
|
||||
const source = read(file);
|
||||
expect(
|
||||
/ensureAdmin\s*\(/.test(source) ||
|
||||
/requireAdminAuth\s*\(/.test(source) ||
|
||||
/isAdminAuthenticated\s*\(/.test(source),
|
||||
file,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("architecture: admin mirror parity", () => {
|
||||
const canonicalPages = walk("app/_admin", (file) => /page\.tsx$/.test(file));
|
||||
|
||||
it("has admin pages to mirror", () => {
|
||||
expect(canonicalPages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const mirror of ["root", "admin-internal"]) {
|
||||
it(`mirrors every _admin page under app/${mirror} via a re-export`, () => {
|
||||
const missing: string[] = [];
|
||||
for (const page of canonicalPages) {
|
||||
const mirrored = page.replace(`app${path.sep}_admin${path.sep}`, `app${path.sep}${mirror}${path.sep}`);
|
||||
try {
|
||||
const source = read(mirrored);
|
||||
if (!source.includes("_admin")) {
|
||||
missing.push(`${mirrored} (does not re-export _admin)`);
|
||||
}
|
||||
} catch {
|
||||
missing.push(`${mirrored} (missing)`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isCheckedFormValue } from "@/lib/form-data";
|
||||
|
||||
describe("isCheckedFormValue", () => {
|
||||
it("treats standard checkbox values as checked", () => {
|
||||
expect(isCheckedFormValue("on")).toBe(true);
|
||||
expect(isCheckedFormValue("true")).toBe(true);
|
||||
expect(isCheckedFormValue("1")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats other values as unchecked", () => {
|
||||
expect(isCheckedFormValue("off")).toBe(false);
|
||||
expect(isCheckedFormValue("false")).toBe(false);
|
||||
expect(isCheckedFormValue("0")).toBe(false);
|
||||
expect(isCheckedFormValue("")).toBe(false);
|
||||
expect(isCheckedFormValue(null)).toBe(false);
|
||||
expect(isCheckedFormValue("yes")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FALLBACK_LOCALE,
|
||||
getDirection,
|
||||
getLocalizedPath,
|
||||
getLocalizedPathWithDefault,
|
||||
isSupportedLocale,
|
||||
resolveLocale,
|
||||
stripLocalePrefix,
|
||||
} from "@/lib/locale";
|
||||
|
||||
describe("isSupportedLocale", () => {
|
||||
it("accepts the three app locales", () => {
|
||||
expect(isSupportedLocale("de")).toBe(true);
|
||||
expect(isSupportedLocale("en")).toBe(true);
|
||||
expect(isSupportedLocale("ar")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects everything else", () => {
|
||||
expect(isSupportedLocale("fr")).toBe(false);
|
||||
expect(isSupportedLocale("")).toBe(false);
|
||||
expect(isSupportedLocale(undefined)).toBe(false);
|
||||
expect(isSupportedLocale(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLocale", () => {
|
||||
it("keeps supported locales", () => {
|
||||
expect(resolveLocale("ar", "de")).toBe("ar");
|
||||
});
|
||||
|
||||
it("falls back for unsupported locales", () => {
|
||||
expect(resolveLocale("fr", "en")).toBe("en");
|
||||
expect(resolveLocale(undefined, FALLBACK_LOCALE)).toBe("de");
|
||||
expect(resolveLocale(null, "ar")).toBe("ar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDirection", () => {
|
||||
it("is rtl for arabic only", () => {
|
||||
expect(getDirection("ar")).toBe("rtl");
|
||||
expect(getDirection("de")).toBe("ltr");
|
||||
expect(getDirection("en")).toBe("ltr");
|
||||
expect(getDirection("fr")).toBe("ltr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLocalePrefix", () => {
|
||||
it("removes a bare locale prefix", () => {
|
||||
expect(stripLocalePrefix("/de")).toBe("/");
|
||||
expect(stripLocalePrefix("/ar")).toBe("/");
|
||||
});
|
||||
|
||||
it("removes a nested locale prefix", () => {
|
||||
expect(stripLocalePrefix("/en/about")).toBe("/about");
|
||||
expect(stripLocalePrefix("/ar/portfolio/x")).toBe("/portfolio/x");
|
||||
});
|
||||
|
||||
it("returns unprefixed paths unchanged", () => {
|
||||
expect(stripLocalePrefix("/about")).toBe("/about");
|
||||
expect(stripLocalePrefix("/")).toBe("/");
|
||||
expect(stripLocalePrefix("")).toBe("/");
|
||||
});
|
||||
|
||||
it("does not strip lookalike segments", () => {
|
||||
expect(stripLocalePrefix("/design")).toBe("/design");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalizedPathWithDefault", () => {
|
||||
it("keeps the default locale on the bare domain", () => {
|
||||
expect(getLocalizedPathWithDefault("ar", "/", "ar")).toBe("/");
|
||||
expect(getLocalizedPathWithDefault("de", "/", "ar")).toBe("/de");
|
||||
expect(getLocalizedPathWithDefault("en", "/", "ar")).toBe("/en");
|
||||
});
|
||||
|
||||
it("builds nested paths against the configured default", () => {
|
||||
expect(getLocalizedPathWithDefault("ar", "/coming-soon", "ar")).toBe("/coming-soon");
|
||||
expect(getLocalizedPathWithDefault("de", "/coming-soon", "ar")).toBe("/de/coming-soon");
|
||||
expect(getLocalizedPathWithDefault("en", "/portfolio", "de")).toBe("/en/portfolio");
|
||||
});
|
||||
|
||||
it("re-bases an already-prefixed path onto the requested locale", () => {
|
||||
expect(getLocalizedPathWithDefault("en", "/ar/contact", "de")).toBe("/en/contact");
|
||||
expect(getLocalizedPathWithDefault("ar", "/de/about", "ar")).toBe("/about");
|
||||
});
|
||||
|
||||
it("normalizes an empty path to root", () => {
|
||||
expect(getLocalizedPathWithDefault("de", "", "de")).toBe("/");
|
||||
expect(getLocalizedPathWithDefault("en", "", "de")).toBe("/en");
|
||||
});
|
||||
|
||||
it("falls back to the default locale for unsupported input", () => {
|
||||
expect(getLocalizedPathWithDefault("fr", "/about", "de")).toBe("/about");
|
||||
});
|
||||
|
||||
it("getLocalizedPath is an alias of getLocalizedPathWithDefault", () => {
|
||||
expect(getLocalizedPath("en", "/about", "de")).toBe(
|
||||
getLocalizedPathWithDefault("en", "/about", "de"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MailSettings } from "@/lib/mail-settings";
|
||||
import { createSmtpTransport, sendContactMessage, sendMail, sendTestEmail } from "@/lib/mail";
|
||||
|
||||
function settings(overrides: Partial<MailSettings> = {}): MailSettings {
|
||||
return {
|
||||
smtp: { host: "smtp.example.com", port: 587, secure: false, username: "mailer", password: "secret", ...overrides.smtp },
|
||||
sender: { email: "hello@example.com", name: "Studio", ...overrides.sender },
|
||||
recipients: { contact: "contact@example.com", test: "test@example.com", ...overrides.recipients },
|
||||
};
|
||||
}
|
||||
|
||||
function mockTransport() {
|
||||
const sendMailMock = vi.fn().mockResolvedValue({});
|
||||
const createTransport = vi.fn().mockReturnValue({ sendMail: sendMailMock });
|
||||
return { sendMailMock, createTransport };
|
||||
}
|
||||
|
||||
describe("createSmtpTransport", () => {
|
||||
it("builds the transport with host, port, secure, and auth", () => {
|
||||
const { createTransport } = mockTransport();
|
||||
createSmtpTransport(settings({ smtp: { host: "h", port: 465, secure: true, username: "u", password: "p" } }), createTransport);
|
||||
expect(createTransport).toHaveBeenCalledWith({
|
||||
host: "h",
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: { user: "u", pass: "p" },
|
||||
});
|
||||
});
|
||||
|
||||
it("requires host, username and password", () => {
|
||||
const { createTransport } = mockTransport();
|
||||
expect(() => createSmtpTransport(settings({ smtp: { host: "", port: 587, secure: false, username: "u", password: "p" } }), createTransport)).toThrow(/host/i);
|
||||
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "", password: "p" } }), createTransport)).toThrow(/username/i);
|
||||
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "u", password: "" } }), createTransport)).toThrow(/password/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMail", () => {
|
||||
it("formats the from header with the sender name", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings(), createTransport });
|
||||
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "Studio <hello@example.com>", to: "x@y.z" }));
|
||||
});
|
||||
|
||||
it("omits the display name when sender name is blank", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "hello@example.com", name: "" } }), createTransport });
|
||||
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "hello@example.com" }));
|
||||
});
|
||||
|
||||
it("requires a from email", async () => {
|
||||
const { createTransport } = mockTransport();
|
||||
await expect(
|
||||
sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "", name: "" } }), createTransport }),
|
||||
).rejects.toThrow(/from email/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendContactMessage", () => {
|
||||
it("targets the contact recipient with reply-to and full body", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendContactMessage(
|
||||
{ locale: "en", name: "Jane", email: "jane@x.z", phone: "123", company: "Acme", message: "Hi there team." },
|
||||
{ settings: settings(), createTransport },
|
||||
);
|
||||
const call = sendMailMock.mock.calls[0][0];
|
||||
expect(call).toMatchObject({ to: "contact@example.com", subject: "New contact message", replyTo: "jane@x.z" });
|
||||
expect(call.text).toContain("Name: Jane");
|
||||
expect(call.text).toContain("Phone: 123");
|
||||
expect(call.text).toContain("Company: Acme");
|
||||
expect(call.text).toContain("Hi there team.");
|
||||
});
|
||||
|
||||
it("falls back to the test recipient when contact is empty", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendContactMessage(
|
||||
{ locale: "de", name: "Jane", email: "jane@x.z", message: "Fallback works fine." },
|
||||
{ settings: settings({ recipients: { contact: "", test: "fallback@x.z" } }), createTransport },
|
||||
);
|
||||
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "fallback@x.z" }));
|
||||
});
|
||||
|
||||
it("renders dashes for missing optional fields", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendContactMessage(
|
||||
{ locale: "en", name: "Jane", email: "jane@x.z", message: "No phone or company." },
|
||||
{ settings: settings(), createTransport },
|
||||
);
|
||||
const call = sendMailMock.mock.calls[0][0];
|
||||
expect(call.text).toContain("Phone: -");
|
||||
expect(call.text).toContain("Company: -");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTestEmail", () => {
|
||||
it("sends to the test recipient", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendTestEmail({ settings: settings(), createTransport });
|
||||
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "test@example.com", subject: "SMTP test email" }));
|
||||
});
|
||||
|
||||
it("falls back to the contact recipient when test is empty", async () => {
|
||||
const { sendMailMock, createTransport } = mockTransport();
|
||||
await sendTestEmail({ settings: settings({ recipients: { contact: "c@x.z", test: "" } }), createTransport });
|
||||
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "c@x.z" }));
|
||||
});
|
||||
|
||||
it("propagates transport failures", async () => {
|
||||
const createTransport = vi.fn().mockReturnValue({ sendMail: vi.fn().mockRejectedValue(new Error("Auth failed.")) });
|
||||
await expect(sendTestEmail({ settings: settings(), createTransport })).rejects.toThrow("Auth failed.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildDefaultMarqueeSettings,
|
||||
parseMarqueeSettingsValue,
|
||||
splitMarqueeRowItems,
|
||||
syncMarqueeSettingsToGermanSource,
|
||||
} from "@/lib/marquee-settings";
|
||||
|
||||
describe("buildDefaultMarqueeSettings", () => {
|
||||
it("provides all four rows for every locale", () => {
|
||||
const settings = buildDefaultMarqueeSettings();
|
||||
for (const locale of ["ar", "en", "de"] as const) {
|
||||
expect(settings.locales[locale].row1).toContain("Next.js");
|
||||
expect(settings.locales[locale].row2).toContain("TypeScript");
|
||||
expect(settings.locales[locale].row3).toContain("JavaScript");
|
||||
expect(settings.locales[locale].row4).toContain("Frontend Strategy");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncMarqueeSettingsToGermanSource", () => {
|
||||
it("copies the german rows over english and arabic", () => {
|
||||
const base = buildDefaultMarqueeSettings();
|
||||
base.locales.de.row1 = "GERMAN";
|
||||
base.locales.en.row1 = "english";
|
||||
base.locales.ar.row1 = "arabic";
|
||||
const synced = syncMarqueeSettingsToGermanSource(base);
|
||||
expect(synced.locales.de.row1).toBe("GERMAN");
|
||||
expect(synced.locales.en.row1).toBe("GERMAN");
|
||||
expect(synced.locales.ar.row1).toBe("GERMAN");
|
||||
});
|
||||
|
||||
it("returns independent copies (no shared references)", () => {
|
||||
const synced = syncMarqueeSettingsToGermanSource(buildDefaultMarqueeSettings());
|
||||
synced.locales.en.row1 = "changed";
|
||||
expect(synced.locales.de.row1).not.toBe("changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMarqueeSettingsValue", () => {
|
||||
it("returns german-synced defaults for empty input", () => {
|
||||
const settings = parseMarqueeSettingsValue(null);
|
||||
expect(settings.locales.en.row1).toBe(settings.locales.de.row1);
|
||||
expect(settings.locales.de.row1).toContain("Next.js");
|
||||
});
|
||||
|
||||
it("returns defaults for invalid json", () => {
|
||||
const settings = parseMarqueeSettingsValue("{not json");
|
||||
expect(settings.locales.de.row1).toContain("Next.js");
|
||||
});
|
||||
|
||||
it("normalizes stored values, trims, and syncs to german", () => {
|
||||
const settings = parseMarqueeSettingsValue(
|
||||
JSON.stringify({
|
||||
locales: {
|
||||
de: { row1: " Custom Row 1 ", row2: "R2", row3: "R3", row4: "R4" },
|
||||
en: { row1: "IGNORED" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(settings.locales.de.row1).toBe("Custom Row 1");
|
||||
// english is overwritten by the german source
|
||||
expect(settings.locales.en.row1).toBe("Custom Row 1");
|
||||
expect(settings.locales.ar.row2).toBe("R2");
|
||||
});
|
||||
|
||||
it("falls back to per-row defaults when a row is blank", () => {
|
||||
const settings = parseMarqueeSettingsValue(
|
||||
JSON.stringify({ locales: { de: { row1: " ", row2: "", row3: "R3", row4: "R4" } } }),
|
||||
);
|
||||
expect(settings.locales.de.row1).toContain("Next.js");
|
||||
expect(settings.locales.de.row3).toBe("R3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitMarqueeRowItems", () => {
|
||||
it("splits on newlines and commas and trims blanks", () => {
|
||||
expect(splitMarqueeRowItems("A\nB, C\n\n , D")).toEqual(["A", "B", "C", "D"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for whitespace-only input", () => {
|
||||
expect(splitMarqueeRowItems(" \n ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getKindFromUploadFile,
|
||||
inferMediaKindFromFileName,
|
||||
inferMediaKindFromMimeType,
|
||||
} from "@/lib/media-service";
|
||||
|
||||
describe("inferMediaKindFromMimeType", () => {
|
||||
it("classifies image mime types as IMAGE", () => {
|
||||
expect(inferMediaKindFromMimeType("image/png")).toBe("IMAGE");
|
||||
expect(inferMediaKindFromMimeType("image/svg+xml")).toBe("IMAGE");
|
||||
expect(inferMediaKindFromMimeType("image/gif")).toBe("IMAGE");
|
||||
});
|
||||
|
||||
it("classifies everything else as DOCUMENT", () => {
|
||||
expect(inferMediaKindFromMimeType("application/pdf")).toBe("DOCUMENT");
|
||||
expect(inferMediaKindFromMimeType(null)).toBe("DOCUMENT");
|
||||
expect(inferMediaKindFromMimeType(undefined)).toBe("DOCUMENT");
|
||||
expect(inferMediaKindFromMimeType("")).toBe("DOCUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferMediaKindFromFileName", () => {
|
||||
it("treats known image extensions as IMAGE (case-insensitive)", () => {
|
||||
for (const name of ["a.gif", "a.jpg", "a.jpeg", "a.PNG", "a.webp", "a.SVG"]) {
|
||||
expect(inferMediaKindFromFileName(name)).toBe("IMAGE");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats other extensions as DOCUMENT", () => {
|
||||
expect(inferMediaKindFromFileName("report.pdf")).toBe("DOCUMENT");
|
||||
expect(inferMediaKindFromFileName("noext")).toBe("DOCUMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKindFromUploadFile", () => {
|
||||
it("derives IMAGE from an image mime type", () => {
|
||||
const file = new File([new Uint8Array([1])], "logo.png", { type: "image/png" });
|
||||
expect(getKindFromUploadFile(file)).toBe("IMAGE");
|
||||
});
|
||||
|
||||
it("derives DOCUMENT from a pdf mime type", () => {
|
||||
const file = new File([new Uint8Array([1])], "doc.pdf", { type: "application/pdf" });
|
||||
expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
|
||||
});
|
||||
|
||||
it("falls back to DOCUMENT for unknown mime types", () => {
|
||||
const file = new File([new Uint8Array([1])], "thing.bin", { type: "application/octet-stream" });
|
||||
expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from "path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_MEDIA_FILE_SIZE,
|
||||
MEDIA_UPLOAD_ROOT,
|
||||
getExtensionForMimeType,
|
||||
isManagedMediaFilePath,
|
||||
removeManagedMediaFile,
|
||||
resolveMediaUploadPath,
|
||||
sanitizeBaseName,
|
||||
} from "@/lib/media-storage";
|
||||
|
||||
describe("sanitizeBaseName", () => {
|
||||
it("lowercases, hyphenates, and strips symbols", () => {
|
||||
expect(sanitizeBaseName("Brand Redesign 2026!.svg")).toBe("brand-redesign-2026-svg");
|
||||
});
|
||||
|
||||
it("collapses repeated separators and trims edges", () => {
|
||||
expect(sanitizeBaseName("--Hello___World--")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("truncates to 60 characters", () => {
|
||||
expect(sanitizeBaseName("a".repeat(100)).length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExtensionForMimeType", () => {
|
||||
it("maps known image and document mime types", () => {
|
||||
expect(getExtensionForMimeType("image/gif")).toBe(".gif");
|
||||
expect(getExtensionForMimeType("image/jpeg")).toBe(".jpg");
|
||||
expect(getExtensionForMimeType("image/png")).toBe(".png");
|
||||
expect(getExtensionForMimeType("image/webp")).toBe(".webp");
|
||||
expect(getExtensionForMimeType("image/svg+xml")).toBe(".svg");
|
||||
expect(getExtensionForMimeType("image/x-icon")).toBe(".ico");
|
||||
expect(getExtensionForMimeType("image/vnd.microsoft.icon")).toBe(".ico");
|
||||
expect(getExtensionForMimeType("application/pdf")).toBe(".pdf");
|
||||
});
|
||||
|
||||
it("returns null for unknown mime types", () => {
|
||||
expect(getExtensionForMimeType("application/zip")).toBeNull();
|
||||
expect(getExtensionForMimeType("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isManagedMediaFilePath", () => {
|
||||
it("accepts managed upload paths only", () => {
|
||||
expect(isManagedMediaFilePath("/uploads/media/covers/x.svg")).toBe(true);
|
||||
expect(isManagedMediaFilePath("https://example.com/x.svg")).toBe(false);
|
||||
expect(isManagedMediaFilePath("../x.svg")).toBe(false);
|
||||
expect(isManagedMediaFilePath("/uploads/other/x.svg")).toBe(false);
|
||||
expect(isManagedMediaFilePath(null)).toBe(false);
|
||||
expect(isManagedMediaFilePath(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMediaUploadPath", () => {
|
||||
it("resolves managed paths inside the upload root", () => {
|
||||
const resolved = resolveMediaUploadPath("/uploads/media/assets/test.svg");
|
||||
expect(resolved.startsWith(MEDIA_UPLOAD_ROOT)).toBe(true);
|
||||
expect(resolved.endsWith(path.join("assets", "test.svg"))).toBe(true);
|
||||
});
|
||||
|
||||
it("throws for unmanaged paths", () => {
|
||||
expect(() => resolveMediaUploadPath("https://example.com/x.svg")).toThrow(/managed/i);
|
||||
});
|
||||
|
||||
it("throws when a traversal attempt escapes the root", () => {
|
||||
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeManagedMediaFile", () => {
|
||||
it("returns false without touching disk for unmanaged paths", async () => {
|
||||
await expect(removeManagedMediaFile("https://example.com/x.svg")).resolves.toBe(false);
|
||||
await expect(removeManagedMediaFile(null)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constants", () => {
|
||||
it("caps media uploads at 5 MB", () => {
|
||||
expect(MAX_MEDIA_FILE_SIZE).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||
|
||||
const base = { assetId: "", url: "", label: "", kind: "IMAGE" as const };
|
||||
|
||||
describe("mediaFieldInputSchema", () => {
|
||||
it("accepts a valid library selection", () => {
|
||||
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "library", assetId: "asset_1" });
|
||||
expect(parsed.mode).toBe("library");
|
||||
expect(parsed.assetId).toBe("asset_1");
|
||||
});
|
||||
|
||||
it("requires an assetId in library mode", () => {
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "library" })).toThrow(/media asset/i);
|
||||
});
|
||||
|
||||
it("accepts a valid external url", () => {
|
||||
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "https://cdn/x.png" });
|
||||
expect(parsed.url).toBe("https://cdn/x.png");
|
||||
});
|
||||
|
||||
it("accepts a root-relative external url", () => {
|
||||
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "/uploads/media/x.png" });
|
||||
expect(parsed.url).toBe("/uploads/media/x.png");
|
||||
});
|
||||
|
||||
it("requires a url in external mode", () => {
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
|
||||
});
|
||||
|
||||
it("rejects malformed urls", () => {
|
||||
expect(() =>
|
||||
mediaFieldInputSchema.parse({ ...base, mode: "external", url: "not-a-url" }),
|
||||
).toThrow(/absolute URL or start with/i);
|
||||
});
|
||||
|
||||
it("accepts upload mode without asset or url", () => {
|
||||
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "upload" });
|
||||
expect(parsed.mode).toBe("upload");
|
||||
});
|
||||
|
||||
it("rejects an unknown mode", () => {
|
||||
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "sideload" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an invalid media kind", () => {
|
||||
expect(() =>
|
||||
mediaFieldInputSchema.parse({ ...base, mode: "upload", kind: "VIDEO" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("trims text fields and defaults optionals to empty strings", () => {
|
||||
const parsed = mediaFieldInputSchema.parse({
|
||||
mode: "library",
|
||||
assetId: " asset_9 ",
|
||||
label: " Logo ",
|
||||
kind: "IMAGE",
|
||||
});
|
||||
expect(parsed.assetId).toBe("asset_9");
|
||||
expect(parsed.label).toBe("Logo");
|
||||
expect(parsed.url).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDefaultSiteSettings } from "@/lib/site-settings";
|
||||
import {
|
||||
applyTitleTemplateFn,
|
||||
buildAppMetadataFromConfig,
|
||||
buildLocaleAlternates,
|
||||
buildLocalizedMetadataFromConfig,
|
||||
} from "@/lib/metadata";
|
||||
|
||||
const noBindings = {
|
||||
siteLogoLight: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
};
|
||||
|
||||
describe("applyTitleTemplateFn", () => {
|
||||
it("substitutes page title and site name", () => {
|
||||
expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio")).toBe("About | Studio");
|
||||
});
|
||||
|
||||
it("replaces every site-name token but only the first page-title token", () => {
|
||||
expect(applyTitleTemplateFn("P", "{siteName} {pageTitle} {siteName}", "S")).toBe("S P S");
|
||||
});
|
||||
|
||||
it("falls back to a default template when the token is missing", () => {
|
||||
expect(applyTitleTemplateFn("About", "Just Site", "Studio")).toBe("About | Studio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLocaleAlternates", () => {
|
||||
it("builds canonical, hreflang, and x-default against the default locale", () => {
|
||||
const alt = buildLocaleAlternates("/about", "ar");
|
||||
expect(alt.canonical).toBe("https://mohfarawati.de/about");
|
||||
expect(alt.languages.ar).toBe("https://mohfarawati.de/about");
|
||||
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
|
||||
expect(alt.languages.en).toBe("https://mohfarawati.de/en/about");
|
||||
expect(alt.languages["x-default"]).toBe("https://mohfarawati.de/about");
|
||||
});
|
||||
|
||||
it("shifts prefixes when the default locale changes", () => {
|
||||
const alt = buildLocaleAlternates("/about", "en");
|
||||
expect(alt.canonical).toBe("https://mohfarawati.de/about");
|
||||
expect(alt.languages.en).toBe("https://mohfarawati.de/about");
|
||||
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppMetadataFromConfig", () => {
|
||||
it("uses summary twitter card and omits og images when unset", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
const metadata = buildAppMetadataFromConfig(settings, noBindings);
|
||||
expect(metadata.title).toBe("Studio");
|
||||
expect(metadata.twitter).toMatchObject({ card: "summary" });
|
||||
expect(metadata.openGraph?.images).toBeUndefined();
|
||||
expect(metadata.metadataBase?.toString()).toBe("https://mohfarawati.de/");
|
||||
});
|
||||
|
||||
it("uses summary_large_image and a versioned favicon when bindings exist", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
const metadata = buildAppMetadataFromConfig(settings, {
|
||||
...noBindings,
|
||||
favicon: { assetId: "f", url: "/uploads/media/site-settings/favicon.svg", version: "v9" },
|
||||
defaultOgImage: { assetId: "og", url: "/uploads/media/site-settings/og.png", version: "v9" },
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
|
||||
expect(metadata.icons).toMatchObject({ icon: [{ url: "/favicon.ico?v=v9" }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLocalizedMetadataFromConfig", () => {
|
||||
it("applies the title template and localized description by default", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
settings.locales.en.siteDescription = "English description";
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: noBindings,
|
||||
locale: "en",
|
||||
pathname: "/about",
|
||||
title: "About",
|
||||
});
|
||||
expect(metadata.title).toBe("About | Studio");
|
||||
expect(metadata.description).toBe("English description");
|
||||
expect(metadata.openGraph?.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("can skip the title template (homepage)", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: noBindings,
|
||||
locale: "ar",
|
||||
pathname: "/",
|
||||
title: "الرئيسية",
|
||||
applyTitleTemplate: false,
|
||||
});
|
||||
expect(metadata.title).toBe("الرئيسية");
|
||||
});
|
||||
|
||||
it("prefers an explicit description over the locale default", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio");
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: noBindings,
|
||||
locale: "de",
|
||||
pathname: "/x",
|
||||
title: "T",
|
||||
description: " Custom desc ",
|
||||
});
|
||||
expect(metadata.description).toBe("Custom desc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getFirstIncompleteWizardStep,
|
||||
getPortfolioWizardProgress,
|
||||
isPortfolioAssetReady,
|
||||
isPortfolioSectionReady,
|
||||
} from "@/lib/portfolio-form-progress";
|
||||
|
||||
const titles = { titleAr: "ع", titleEn: "en", titleDe: "de" };
|
||||
|
||||
function section(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "RICH_TEXT" as const,
|
||||
...titles,
|
||||
bodyAr: "ب",
|
||||
bodyEn: "body",
|
||||
bodyDe: "koerper",
|
||||
linkUrl: "",
|
||||
mediaAssetId: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isPortfolioSectionReady", () => {
|
||||
it("requires titles in all languages", () => {
|
||||
expect(isPortfolioSectionReady(section({ titleEn: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("RICH_TEXT/STATS/DELIVERABLES require body in all languages", () => {
|
||||
for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"] as const) {
|
||||
expect(isPortfolioSectionReady(section({ type }))).toBe(true);
|
||||
expect(isPortfolioSectionReady(section({ type, bodyDe: "" }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("GALLERY requires a media asset id", () => {
|
||||
expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "asset_1" }))).toBe(true);
|
||||
expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("LINK requires a link url", () => {
|
||||
expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "https://x" }))).toBe(true);
|
||||
expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPortfolioAssetReady", () => {
|
||||
it("requires media asset id and alt text in all languages", () => {
|
||||
expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "e", altDe: "d" })).toBe(true);
|
||||
expect(isPortfolioAssetReady({ mediaAssetId: "", altAr: "ع", altEn: "e", altDe: "d" })).toBe(false);
|
||||
expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "", altDe: "d" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPortfolioWizardProgress", () => {
|
||||
const completeInput = {
|
||||
basics: {
|
||||
categoryId: "cat_1",
|
||||
slug: "case-study",
|
||||
clientName: "Client",
|
||||
projectYear: "2025",
|
||||
sortOrder: "1",
|
||||
viewMode: "GRID" as const,
|
||||
},
|
||||
content: {
|
||||
titleAr: "ع", titleEn: "t", titleDe: "t",
|
||||
serviceLabelAr: "خ", serviceLabelEn: "s", serviceLabelDe: "s",
|
||||
summaryAr: "م", summaryEn: "sum", summaryDe: "zus",
|
||||
},
|
||||
sections: [section()],
|
||||
assets: [{ mediaAssetId: "asset_1", altAr: "ع", altEn: "e", altDe: "d" }],
|
||||
};
|
||||
|
||||
it("marks all steps complete for a fully filled project", () => {
|
||||
const progress = getPortfolioWizardProgress(completeInput);
|
||||
expect(progress.every((step) => step.complete)).toBe(true);
|
||||
expect(getFirstIncompleteWizardStep(progress)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an invalid slug", () => {
|
||||
const progress = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
basics: { ...completeInput.basics, slug: "Invalid Slug" },
|
||||
});
|
||||
expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
|
||||
expect(getFirstIncompleteWizardStep(progress)).toBe("basics");
|
||||
});
|
||||
|
||||
it("enforces year bounds 2000..2100", () => {
|
||||
const before = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
basics: { ...completeInput.basics, projectYear: "1999" },
|
||||
});
|
||||
expect(before.find((s) => s.key === "basics")?.complete).toBe(false);
|
||||
const after = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
basics: { ...completeInput.basics, projectYear: "2101" },
|
||||
});
|
||||
expect(after.find((s) => s.key === "basics")?.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces sortOrder bounds 0..9999", () => {
|
||||
const progress = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
basics: { ...completeInput.basics, sortOrder: "-1" },
|
||||
});
|
||||
expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("content is incomplete when a summary locale is missing", () => {
|
||||
const progress = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
content: { ...completeInput.content, summaryEn: "" },
|
||||
});
|
||||
expect(progress.find((s) => s.key === "content")?.complete).toBe(false);
|
||||
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
|
||||
});
|
||||
|
||||
it("sections/assets steps require at least one ready entry", () => {
|
||||
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
||||
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
|
||||
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("summarizes section/asset readiness counts", () => {
|
||||
const progress = getPortfolioWizardProgress({
|
||||
...completeInput,
|
||||
sections: [section(), section({ titleEn: "" })],
|
||||
});
|
||||
expect(progress.find((s) => s.key === "sections")?.summary).toBe("1/2 sections ready.");
|
||||
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assetInputSchema,
|
||||
categoryInputSchema,
|
||||
projectInputSchema,
|
||||
sectionInputSchema,
|
||||
} from "@/lib/portfolio-validation";
|
||||
|
||||
function category(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
slug: "branding",
|
||||
nameAr: "الهوية", nameEn: "Branding", nameDe: "Branding",
|
||||
descriptionAr: "وصف", descriptionEn: "Description", descriptionDe: "Beschreibung",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseMedia(overrides: Record<string, unknown> = {}) {
|
||||
return { mode: "external", assetId: "", url: "https://x/y.png", label: "L", kind: "IMAGE", ...overrides };
|
||||
}
|
||||
|
||||
function section(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "RICH_TEXT",
|
||||
titleAr: "ع", titleEn: "t", titleDe: "t",
|
||||
bodyAr: "ب", bodyEn: "b", bodyDe: "b",
|
||||
imagePath: "",
|
||||
linkUrl: "",
|
||||
sortOrder: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
categoryId: "cat_1",
|
||||
slug: "case-study",
|
||||
viewMode: "GRID",
|
||||
titleAr: "ع", titleEn: "T", titleDe: "T",
|
||||
summaryAr: "م", summaryEn: "S", summaryDe: "S",
|
||||
clientName: "Client",
|
||||
projectYear: 2025,
|
||||
serviceLabelAr: "خ", serviceLabelEn: "Svc", serviceLabelDe: "Svc",
|
||||
previewUrl: "https://example.com",
|
||||
currentCoverImagePath: "",
|
||||
sortOrder: 1,
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
sections: [],
|
||||
assets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("categoryInputSchema", () => {
|
||||
it("accepts a valid payload", () => {
|
||||
expect(categoryInputSchema.parse(category()).slug).toBe("branding");
|
||||
});
|
||||
|
||||
it("requires lowercase hyphenated slugs", () => {
|
||||
expect(() => categoryInputSchema.parse(category({ slug: "Not Valid" }))).toThrow(/slug/i);
|
||||
expect(() => categoryInputSchema.parse(category({ slug: "-leading" }))).toThrow(/slug/i);
|
||||
expect(categoryInputSchema.parse(category({ slug: "multi-word-slug" })).slug).toBe("multi-word-slug");
|
||||
});
|
||||
|
||||
it("requires all name and description locales", () => {
|
||||
expect(() => categoryInputSchema.parse(category({ nameEn: "" }))).toThrow(/nameEn/i);
|
||||
expect(() => categoryInputSchema.parse(category({ descriptionDe: " " }))).toThrow(/descriptionDe/i);
|
||||
});
|
||||
|
||||
it("coerces sortOrder and enforces its range", () => {
|
||||
expect(categoryInputSchema.parse(category({ sortOrder: "5" })).sortOrder).toBe(5);
|
||||
expect(() => categoryInputSchema.parse(category({ sortOrder: 10000 }))).toThrow();
|
||||
expect(() => categoryInputSchema.parse(category({ sortOrder: -1 }))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionInputSchema", () => {
|
||||
it("accepts a RICH_TEXT section with body", () => {
|
||||
expect(sectionInputSchema.parse(section()).type).toBe("RICH_TEXT");
|
||||
});
|
||||
|
||||
it("requires body for RICH_TEXT, STATS and DELIVERABLES", () => {
|
||||
for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"]) {
|
||||
expect(() => sectionInputSchema.parse(section({ type, bodyEn: "" }))).toThrow(/body/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an image for GALLERY sections", () => {
|
||||
expect(() =>
|
||||
sectionInputSchema.parse(section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "" })),
|
||||
).toThrow(/image/i);
|
||||
expect(
|
||||
sectionInputSchema.parse(
|
||||
section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "/uploads/media/x.svg" }),
|
||||
).type,
|
||||
).toBe("GALLERY");
|
||||
expect(
|
||||
sectionInputSchema.parse(
|
||||
section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", media: baseMedia({ mode: "library", assetId: "a" }) }),
|
||||
).type,
|
||||
).toBe("GALLERY");
|
||||
});
|
||||
|
||||
it("requires a link url for LINK sections", () => {
|
||||
expect(() =>
|
||||
sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "" })),
|
||||
).toThrow(/link/i);
|
||||
expect(
|
||||
sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "https://x" })).linkUrl,
|
||||
).toBe("https://x");
|
||||
});
|
||||
|
||||
it("rejects malformed link urls", () => {
|
||||
expect(() => sectionInputSchema.parse(section({ type: "LINK", linkUrl: "javascript:alert(1)" }))).toThrow(/absolute URL/i);
|
||||
});
|
||||
|
||||
it("requires titles in all languages", () => {
|
||||
expect(() => sectionInputSchema.parse(section({ titleAr: "" }))).toThrow(/titleAr/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetInputSchema", () => {
|
||||
it("accepts a valid image asset", () => {
|
||||
const parsed = assetInputSchema.parse({
|
||||
kind: "IMAGE",
|
||||
filePath: "/uploads/media/assets/x.svg",
|
||||
fileFieldName: "",
|
||||
media: baseMedia(),
|
||||
altAr: "ع", altEn: "a", altDe: "a",
|
||||
sortOrder: 0,
|
||||
});
|
||||
expect(parsed.kind).toBe("IMAGE");
|
||||
});
|
||||
|
||||
it("only allows the IMAGE kind", () => {
|
||||
expect(() =>
|
||||
assetInputSchema.parse({ kind: "DOCUMENT", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("requires alt text in all languages", () => {
|
||||
expect(() =>
|
||||
assetInputSchema.parse({ kind: "IMAGE", altAr: "ع", altEn: "", altDe: "a", sortOrder: 0 }),
|
||||
).toThrow(/altEn/i);
|
||||
});
|
||||
|
||||
it("rejects invalid embedded media urls", () => {
|
||||
expect(() =>
|
||||
assetInputSchema.parse({
|
||||
kind: "IMAGE",
|
||||
media: baseMedia({ url: "not-a-url" }),
|
||||
altAr: "ع", altEn: "a", altDe: "a",
|
||||
sortOrder: 0,
|
||||
}),
|
||||
).toThrow(/url/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectInputSchema", () => {
|
||||
it("accepts a fully valid project", () => {
|
||||
expect(projectInputSchema.parse(project()).slug).toBe("case-study");
|
||||
});
|
||||
|
||||
it("defaults viewMode to GRID and accepts known modes", () => {
|
||||
expect(projectInputSchema.parse(project({ viewMode: undefined })).viewMode).toBe("GRID");
|
||||
expect(projectInputSchema.parse(project({ viewMode: "CASE_STUDY" })).viewMode).toBe("CASE_STUDY");
|
||||
expect(() => projectInputSchema.parse(project({ viewMode: "WILD" }))).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid slugs", () => {
|
||||
expect(() => projectInputSchema.parse(project({ slug: "Bad Slug" }))).toThrow(/slug/i);
|
||||
});
|
||||
|
||||
it("coerces and bounds projectYear", () => {
|
||||
expect(projectInputSchema.parse(project({ projectYear: "2025" })).projectYear).toBe(2025);
|
||||
expect(() => projectInputSchema.parse(project({ projectYear: 1999 }))).toThrow();
|
||||
expect(() => projectInputSchema.parse(project({ projectYear: 2101 }))).toThrow();
|
||||
});
|
||||
|
||||
it("requires all localized content fields", () => {
|
||||
expect(() => projectInputSchema.parse(project({ summaryDe: "" }))).toThrow(/summaryDe/i);
|
||||
expect(() => projectInputSchema.parse(project({ serviceLabelAr: "" }))).toThrow(/serviceLabelAr/i);
|
||||
expect(() => projectInputSchema.parse(project({ clientName: "" }))).toThrow(/clientName/i);
|
||||
});
|
||||
|
||||
it("allows an empty preview url but rejects a relative one", () => {
|
||||
expect(projectInputSchema.parse(project({ previewUrl: "" })).previewUrl).toBe("");
|
||||
expect(() => projectInputSchema.parse(project({ previewUrl: "/relative" }))).toThrow(/absolute URL/i);
|
||||
});
|
||||
|
||||
it("validates nested sections and assets", () => {
|
||||
expect(() =>
|
||||
projectInputSchema.parse(project({ sections: [section({ titleEn: "" })] })),
|
||||
).toThrow(/titleEn/i);
|
||||
expect(
|
||||
projectInputSchema.parse(
|
||||
project({ assets: [{ kind: "IMAGE", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0, media: baseMedia() }] }),
|
||||
).assets.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getLocalizedValue, resolvePortfolioProjectViewMode } from "@/lib/portfolio";
|
||||
|
||||
describe("resolvePortfolioProjectViewMode", () => {
|
||||
it("keeps supported view modes", () => {
|
||||
expect(resolvePortfolioProjectViewMode("GRID")).toBe("GRID");
|
||||
expect(resolvePortfolioProjectViewMode("STORY")).toBe("STORY");
|
||||
expect(resolvePortfolioProjectViewMode("CASE_STUDY")).toBe("CASE_STUDY");
|
||||
});
|
||||
|
||||
it("falls back to GRID for unknown or missing values", () => {
|
||||
expect(resolvePortfolioProjectViewMode(undefined)).toBe("GRID");
|
||||
expect(resolvePortfolioProjectViewMode(null)).toBe("GRID");
|
||||
expect(resolvePortfolioProjectViewMode("unexpected")).toBe("GRID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalizedValue", () => {
|
||||
const content = { ar: "عربي", en: "English", de: "Deutsch" };
|
||||
|
||||
it("returns the direct locale value when present", () => {
|
||||
expect(getLocalizedValue(content, "en")).toBe("English");
|
||||
expect(getLocalizedValue(content, "ar")).toBe("عربي");
|
||||
});
|
||||
|
||||
it("falls back to the provided fallback locale", () => {
|
||||
expect(getLocalizedValue({ ar: "", en: "", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
|
||||
});
|
||||
|
||||
it("falls back to any available value when neither locale is filled", () => {
|
||||
expect(getLocalizedValue({ ar: "عربي", en: "", de: "" }, "en", "de")).toBe("عربي");
|
||||
});
|
||||
|
||||
it("trims whitespace-only values before considering them empty", () => {
|
||||
expect(getLocalizedValue({ ar: " ", en: " ", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
|
||||
});
|
||||
|
||||
it("returns an empty string when everything is blank", () => {
|
||||
expect(getLocalizedValue({ ar: "", en: "", de: "" }, "en")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSiteIconResponse, buildSiteIconUrls } from "@/lib/site-icons";
|
||||
|
||||
describe("buildSiteIconUrls", () => {
|
||||
it("uses the default version when none is provided", () => {
|
||||
const urls = buildSiteIconUrls({ siteName: "Studio" });
|
||||
expect(urls.version).toBe("default");
|
||||
expect(urls.faviconHref).toBe("/favicon.ico?v=default");
|
||||
expect(urls.appleIconHref).toBe("/apple-icon.png?v=default");
|
||||
expect(urls.manifestHref).toBe("/manifest.webmanifest?v=default");
|
||||
expect(urls.faviconAssetUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("applies a provided favicon version to internal icon hrefs", () => {
|
||||
const urls = buildSiteIconUrls({ siteName: "Studio", faviconVersion: "v1" });
|
||||
expect(urls.faviconHref).toBe("/favicon.ico?v=v1");
|
||||
expect(urls.appleIconHref).toBe("/apple-icon.png?v=v1");
|
||||
});
|
||||
|
||||
it("versions a relative favicon asset url", () => {
|
||||
const urls = buildSiteIconUrls({
|
||||
siteName: "Studio",
|
||||
faviconVersion: "v2",
|
||||
faviconUrl: "/uploads/media/site-settings/favicon.svg",
|
||||
});
|
||||
expect(urls.faviconAssetUrl).toBe("/uploads/media/site-settings/favicon.svg?v=v2");
|
||||
});
|
||||
|
||||
it("versions an absolute favicon asset url", () => {
|
||||
const urls = buildSiteIconUrls({
|
||||
siteName: "Studio",
|
||||
faviconVersion: "v3",
|
||||
faviconUrl: "https://cdn.example.com/favicon.png",
|
||||
});
|
||||
expect(urls.faviconAssetUrl).toBe("https://cdn.example.com/favicon.png?v=v3");
|
||||
});
|
||||
|
||||
it("falls back to a default site name when empty", () => {
|
||||
expect(buildSiteIconUrls({ siteName: " " }).siteName).toBe("Moh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSiteIconResponse", () => {
|
||||
it("returns a transparent png for a null icon url", async () => {
|
||||
const response = await buildSiteIconResponse(null);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
// PNG magic number
|
||||
expect(Array.from(bytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
it("returns the transparent fallback for unmanaged paths", async () => {
|
||||
const response = await buildSiteIconResponse("https://example.com/external.png");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_SITE_PRIMARY_COLOR,
|
||||
buildDefaultSiteSettings,
|
||||
normalizeSiteDefaultLocale,
|
||||
normalizeSitePrimaryColor,
|
||||
parseSiteSettingsValue,
|
||||
} from "@/lib/site-settings";
|
||||
|
||||
describe("normalizeSiteDefaultLocale", () => {
|
||||
it("keeps the three supported locales", () => {
|
||||
expect(normalizeSiteDefaultLocale("ar")).toBe("ar");
|
||||
expect(normalizeSiteDefaultLocale("en")).toBe("en");
|
||||
expect(normalizeSiteDefaultLocale("de")).toBe("de");
|
||||
});
|
||||
|
||||
it("defaults to de for anything else", () => {
|
||||
expect(normalizeSiteDefaultLocale("fr")).toBe("de");
|
||||
expect(normalizeSiteDefaultLocale(undefined)).toBe("de");
|
||||
expect(normalizeSiteDefaultLocale(123)).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSitePrimaryColor", () => {
|
||||
it("accepts and lowercases 6-digit hex", () => {
|
||||
expect(normalizeSitePrimaryColor("#AABBCC")).toBe("#aabbcc");
|
||||
expect(normalizeSitePrimaryColor(" #112233 ")).toBe("#112233");
|
||||
});
|
||||
|
||||
it("rejects invalid colors", () => {
|
||||
expect(normalizeSitePrimaryColor("red")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
expect(normalizeSitePrimaryColor("#abc")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
expect(normalizeSitePrimaryColor("#12345g")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
expect(normalizeSitePrimaryColor(42)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
expect(normalizeSitePrimaryColor(null)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefaultSiteSettings", () => {
|
||||
it("uses the fallback name across all locales", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
expect(settings.defaultLocale).toBe("de");
|
||||
expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
expect(settings.locales.ar.siteName).toBe("Studio Moh");
|
||||
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
|
||||
expect(settings.locales.de.subhead).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSiteSettingsValue", () => {
|
||||
it("returns defaults for empty input", () => {
|
||||
expect(parseSiteSettingsValue(null, "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
|
||||
});
|
||||
|
||||
it("returns defaults for invalid json", () => {
|
||||
expect(parseSiteSettingsValue("{bad", "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
|
||||
});
|
||||
|
||||
it("merges stored values with safe defaults", () => {
|
||||
const settings = parseSiteSettingsValue(
|
||||
JSON.stringify({
|
||||
defaultLocale: "ar",
|
||||
brand: { primaryColor: "#112233" },
|
||||
locales: {
|
||||
en: { siteName: "Brand EN", titleTemplate: "{pageTitle} - {siteName}", siteDescription: "English" },
|
||||
de: { siteName: "Brand DE", subhead: "Sub" },
|
||||
},
|
||||
}),
|
||||
"Fallback",
|
||||
);
|
||||
expect(settings.defaultLocale).toBe("ar");
|
||||
expect(settings.brand.primaryColor).toBe("#112233");
|
||||
expect(settings.locales.en.siteName).toBe("Brand EN");
|
||||
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}");
|
||||
expect(settings.locales.en.subhead).toBe("");
|
||||
expect(settings.locales.ar.siteName).toBe("Fallback");
|
||||
expect(settings.locales.de.subhead).toBe("Sub");
|
||||
});
|
||||
|
||||
it("ignores title templates that lack the {pageTitle} token", () => {
|
||||
const settings = parseSiteSettingsValue(
|
||||
JSON.stringify({ locales: { en: { siteName: "X", titleTemplate: "no token here" } } }),
|
||||
"Fallback",
|
||||
);
|
||||
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
|
||||
});
|
||||
|
||||
it("falls back for invalid default locale and primary color", () => {
|
||||
const settings = parseSiteSettingsValue(
|
||||
JSON.stringify({ defaultLocale: "fr", brand: { primaryColor: "nope" } }),
|
||||
"Fallback",
|
||||
);
|
||||
expect(settings.defaultLocale).toBe("de");
|
||||
expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
|
||||
});
|
||||
|
||||
it("trims string fields", () => {
|
||||
const settings = parseSiteSettingsValue(
|
||||
JSON.stringify({ locales: { de: { siteName: " Trimmed ", siteDescription: " d " } } }),
|
||||
"Fallback",
|
||||
);
|
||||
expect(settings.locales.de.siteName).toBe("Trimmed");
|
||||
expect(settings.locales.de.siteDescription).toBe("d");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSiteThemeStyleText, buildSiteThemeTokens } from "@/lib/site-theme";
|
||||
|
||||
const CHANNEL = /^\d+ \d+% \d+%$/;
|
||||
|
||||
describe("buildSiteThemeTokens", () => {
|
||||
it("converts pure red to the expected HSL channels", () => {
|
||||
const tokens = buildSiteThemeTokens("#ff0000");
|
||||
expect(tokens.light.primary).toBe("0 100% 50%");
|
||||
// dark primary lightens by 6 and clamps saturation into [40,95]
|
||||
expect(tokens.dark.primary).toBe("0 95% 56%");
|
||||
});
|
||||
|
||||
it("produces zero saturation for a neutral gray", () => {
|
||||
const tokens = buildSiteThemeTokens("#808080");
|
||||
expect(tokens.light.primary.startsWith("0 0%")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits well-formed channel strings for every token", () => {
|
||||
const tokens = buildSiteThemeTokens("#dc5a35");
|
||||
for (const value of [
|
||||
tokens.light.primary,
|
||||
tokens.light.secondary,
|
||||
tokens.dark.primary,
|
||||
tokens.dark.secondary,
|
||||
]) {
|
||||
expect(value).toMatch(CHANNEL);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives distinct dark and secondary variants", () => {
|
||||
const tokens = buildSiteThemeTokens("#dc5a35");
|
||||
expect(tokens.dark.primary).not.toBe(tokens.light.primary);
|
||||
expect(tokens.light.secondary).not.toBe(tokens.light.primary);
|
||||
});
|
||||
|
||||
it("falls back to the default brand color for invalid input", () => {
|
||||
expect(buildSiteThemeTokens("not-a-color")).toEqual(buildSiteThemeTokens("#dc5a35"));
|
||||
expect(buildSiteThemeTokens("")).toEqual(buildSiteThemeTokens("#dc5a35"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSiteThemeStyleText", () => {
|
||||
it("emits :root and .dark blocks with the derived channels", () => {
|
||||
const css = buildSiteThemeStyleText("#ff0000");
|
||||
expect(css).toContain(":root {");
|
||||
expect(css).toContain(".dark {");
|
||||
expect(css).toContain("--primary: 0 100% 50%;");
|
||||
expect(css).toContain("--brand-secondary:");
|
||||
expect(css).toContain("--sidebar-ring:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
describe("cn", () => {
|
||||
it("joins truthy class values", () => {
|
||||
expect(cn("a", "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("ignores falsey values", () => {
|
||||
expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("supports conditional object syntax", () => {
|
||||
expect(cn("base", { active: true, hidden: false })).toBe("base active");
|
||||
});
|
||||
|
||||
it("merges conflicting tailwind classes, last wins", () => {
|
||||
expect(cn("px-2", "px-4")).toBe("px-4");
|
||||
expect(cn("text-sm text-red-500", "text-lg")).toBe("text-red-500 text-lg");
|
||||
});
|
||||
|
||||
it("flattens arrays", () => {
|
||||
expect(cn(["a", "b"], "c")).toBe("a b c");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user