test: add frontend test suite
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 43s

This commit is contained in:
TerryM
2026-05-16 18:21:37 +08:00
parent f59d1e8e2a
commit a29ec8ed92
17 changed files with 1624 additions and 12 deletions

71
src/api.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
async function loadApi(apiUrl = "") {
vi.resetModules();
vi.stubEnv("VITE_API_URL", apiUrl);
return import("./api");
}
function jsonResponse(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
describe("api helpers", () => {
it("normalizes nullish API arrays", async () => {
const { itemsOrEmpty } = await loadApi();
expect(itemsOrEmpty(null)).toEqual([]);
expect(itemsOrEmpty(undefined)).toEqual([]);
expect(itemsOrEmpty(["a"])).toEqual(["a"]);
});
it("builds asset URLs from API base while preserving absolute URLs", async () => {
const { assetUrl } = await loadApi("https://api.example.com");
expect(assetUrl("/uploads/file.png")).toBe(
"https://api.example.com/uploads/file.png",
);
expect(assetUrl("https://cdn.example.com/file.png")).toBe(
"https://cdn.example.com/file.png",
);
expect(assetUrl(undefined)).toBe("");
});
it("fetches JSON with API base and throws response text on failure", async () => {
const { getJSON } = await loadApi("https://api.example.com");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true }))
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
await expect(getJSON("/api/resources")).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/resources",
);
await expect(getJSON("/api/fail")).rejects.toThrow("boom");
});
it("posts JSON with optional bearer token", async () => {
const { postJSON } = await loadApi();
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
vi.stubGlobal("fetch", fetchMock);
await expect(
postJSON("/api/admin/resources", { title: "Demo" }, "token-123"),
).resolves.toEqual({ id: 1 });
expect(fetchMock).toHaveBeenCalledWith("/api/admin/resources", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token-123",
},
body: JSON.stringify({ title: "Demo" }),
});
});
});

View File

@@ -0,0 +1,100 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import type { Resource } from "../api";
import { I18nProvider } from "../i18n";
import { ResourceCard } from "./ResourceCard";
const resource: Resource = {
id: "resource-1",
title: "Demo Resource",
description: "Short description",
type: "pdf",
language: "zh-TW",
categoryId: 1,
categorySlug: "docs",
categoryName: "文件",
coverImage: "/uploads/cover.png",
fileUrl: "/uploads/demo.pdf",
previewUrl: "/uploads/preview.pdf",
badgeLabel: "官方",
isDownloadable: true,
isRecommended: true,
updatedAt: "2026-05-16T00:00:00Z",
tags: ["ark"],
};
function renderCard(onFavoriteToggle = vi.fn()) {
return {
user: userEvent.setup(),
onFavoriteToggle,
...render(
<MemoryRouter>
<I18nProvider>
<ResourceCard r={resource} onFavoriteToggle={onFavoriteToggle} />
</I18nProvider>
</MemoryRouter>,
),
};
}
function okResponse() {
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("ResourceCard", () => {
it("renders resource summary and detail link", () => {
renderCard();
expect(screen.getByText("Demo Resource")).toBeInTheDocument();
expect(screen.getByText("文件")).toBeInTheDocument();
expect(screen.getByText("官方")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /預覽/ })).toHaveAttribute(
"href",
"/resource/resource-1",
);
});
it("toggles local favorite state and syncs best-effort API delta", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const { user, onFavoriteToggle } = renderCard();
await user.click(screen.getByRole("button", { name: /收藏/ }));
expect(JSON.parse(localStorage.getItem("ark_favorites") || "[]")).toEqual([
"resource-1",
]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/favorite",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ add: true }),
}),
);
expect(onFavoriteToggle).toHaveBeenCalledTimes(1);
});
it("tracks downloads then opens the downloadable file", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
const { user } = renderCard();
await user.click(screen.getByRole("button", { name: /下載/ }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/download",
expect.objectContaining({ method: "POST" }),
);
expect(openMock).toHaveBeenCalledWith(
"/uploads/demo.pdf",
"_blank",
"noopener,noreferrer",
);
});
});

View File

@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ResourceListFooter } from "./ResourceListFooter";
const t = (key: string) =>
({
listRange: "顯示 {{from}}{{to}},共 {{total}} 筆",
paginationPrev: "上一頁",
paginationNext: "下一頁",
pageIndicator: "{{c}} / {{p}} 頁",
})[key] ?? key;
describe("ResourceListFooter", () => {
it("renders range, page count, and disabled prev on first page", () => {
render(
<ResourceListFooter
page={1}
limit={24}
total={50}
t={t}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(screen.getByText("顯示 124共 50 筆")).toBeInTheDocument();
expect(screen.getByText("1 / 3 頁")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "上一頁" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一頁" })).toBeEnabled();
});
it("calls navigation handlers when buttons are enabled", () => {
const onPrev = vi.fn();
const onNext = vi.fn();
render(
<ResourceListFooter
page={2}
limit={24}
total={50}
t={t}
onPrev={onPrev}
onNext={onNext}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一頁" }));
fireEvent.click(screen.getByRole("button", { name: "下一頁" }));
expect(onPrev).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});

27
src/favorites.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isFavorite, readFavorites, toggleFavorite } from "./favorites";
describe("favorites localStorage helpers", () => {
it("returns an empty list for missing, invalid, or non-array values", () => {
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", "not-json");
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", JSON.stringify({ id: "r1" }));
expect(readFavorites()).toEqual([]);
});
it("filters non-string entries and toggles favorite ids", () => {
localStorage.setItem("ark_favorites", JSON.stringify(["r1", 123, "r2"]));
expect(readFavorites()).toEqual(["r1", "r2"]);
expect(toggleFavorite("r3")).toBe(true);
expect(isFavorite("r3")).toBe(true);
expect(readFavorites()).toEqual(["r1", "r2", "r3"]);
expect(toggleFavorite("r1")).toBe(false);
expect(isFavorite("r1")).toBe(false);
expect(readFavorites()).toEqual(["r2", "r3"]);
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
resourceLanguageLabel,
resourceTypeDisplay,
resourceTypeLabel,
typeFilterLabel,
} from "./resourceTypeLabels";
const t = (key: string) =>
({
filterAll: "全部",
type_image: "圖片",
type_video: "影片",
lang_zh_TW: "繁中",
lang_zh_CN: "簡中",
lang_en: "英文",
})[key] ?? key;
describe("resource labels", () => {
it("localizes known resource types and falls back to raw type", () => {
expect(typeFilterLabel(t, "all")).toBe("全部");
expect(resourceTypeLabel(t, "image")).toBe("圖片");
expect(resourceTypeDisplay(t, "video")).toBe("影片");
expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
});
it("normalizes resource language codes", () => {
expect(resourceLanguageLabel(t, "zh-TW")).toBe("繁中");
expect(resourceLanguageLabel(t, "zh-hans")).toBe("簡中");
expect(resourceLanguageLabel(t, "EN")).toBe("英文");
expect(resourceLanguageLabel(t, "ja")).toBe("ja");
});
});

49
src/test/setup.ts Normal file
View File

@@ -0,0 +1,49 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
function createLocalStorageMock(): Storage {
let store: Record<string, string> = {};
return {
get length() {
return Object.keys(store).length;
},
clear() {
store = {};
},
getItem(key: string) {
return Object.prototype.hasOwnProperty.call(store, key)
? store[key]
: null;
},
key(index: number) {
return Object.keys(store)[index] ?? null;
},
removeItem(key: string) {
delete store[key];
},
setItem(key: string, value: string) {
store[key] = String(value);
},
};
}
beforeEach(() => {
const storage = createLocalStorageMock();
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
});
afterEach(() => {
cleanup();
globalThis.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { categoryCardLines } from "./categoryDisplay";
describe("categoryCardLines", () => {
it("splits Chinese and ASCII parenthetical subtitles", () => {
expect(categoryCardLines("官方公告(繁中)")).toEqual({
line1: "官方公告",
line2: "(繁中)",
});
expect(categoryCardLines("Tutorials (EN)")).toEqual({
line1: "Tutorials",
line2: "(EN)",
});
});
it("uses trimmed description as subtitle when name has no parentheses", () => {
expect(categoryCardLines("影片", " 官方教學 ")).toEqual({
line1: "影片",
line2: "官方教學",
});
expect(categoryCardLines("文件")).toEqual({ line1: "文件" });
});
});

11
src/video.test.ts Normal file
View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { isLikelyVideoPath } from "./video";
describe("isLikelyVideoPath", () => {
it("detects common video extensions and ignores query strings", () => {
expect(isLikelyVideoPath("/uploads/intro.MP4?token=1")).toBe(true);
expect(isLikelyVideoPath("https://cdn.example.com/demo.webm")).toBe(true);
expect(isLikelyVideoPath("/uploads/file.pdf")).toBe(false);
expect(isLikelyVideoPath(undefined)).toBe(false);
});
});