test: 添加完整测试套件(52 个文件,326 个用例)

基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、
前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
2026-02-28 20:19:14 +08:00
parent 11eeec868e
commit 3ccd1262f9
59 changed files with 8131 additions and 3 deletions
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { ApiError, requireUserId, apiHandler } from "@/lib/api";
vi.mock("@/lib/prisma", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
describe("ApiError", () => {
it("creates with default 400 status", () => {
const err = new ApiError("bad request");
expect(err.message).toBe("bad request");
expect(err.status).toBe(400);
expect(err.name).toBe("ApiError");
});
it("creates with custom status", () => {
const err = new ApiError("not found", 404);
expect(err.status).toBe(404);
});
it("is instance of Error", () => {
expect(new ApiError("test")).toBeInstanceOf(Error);
});
});
describe("requireUserId", () => {
it("returns userId when valid string", () => {
expect(requireUserId("user-123")).toBe("user-123");
});
it("throws 401 for empty string", () => {
expect(() => requireUserId("")).toThrow(ApiError);
try {
requireUserId("");
} catch (e) {
expect((e as ApiError).status).toBe(401);
}
});
it("throws 401 for null/undefined", () => {
expect(() => requireUserId(null)).toThrow(ApiError);
expect(() => requireUserId(undefined)).toThrow(ApiError);
});
it("throws 401 for non-string", () => {
expect(() => requireUserId(123)).toThrow(ApiError);
});
});
describe("apiHandler", () => {
const mockCtx = { params: Promise.resolve({}) };
it("passes through successful responses", async () => {
const handler = apiHandler(async () =>
NextResponse.json({ ok: true }),
);
const req = new NextRequest("http://localhost/api/test");
const res = await handler(req, mockCtx);
const data = await res.json();
expect(res.status).toBe(200);
expect(data.ok).toBe(true);
});
it("converts ApiError to JSON response", async () => {
const handler = apiHandler(async () => {
throw new ApiError("not found", 404);
});
const req = new NextRequest("http://localhost/api/test");
const res = await handler(req, mockCtx);
const data = await res.json();
expect(res.status).toBe(404);
expect(data.error).toBe("not found");
});
it("handles Prisma P2002 unique constraint error", async () => {
const handler = apiHandler(async () => {
const err = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
});
throw err;
});
const req = new NextRequest("http://localhost/api/test");
const res = await handler(req, mockCtx);
const data = await res.json();
expect(res.status).toBe(409);
expect(data.error).toBe("该记录已存在或值已被占用");
});
it("handles unknown errors as 500", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const handler = apiHandler(async () => {
throw new Error("unexpected");
});
const req = new NextRequest("http://localhost/api/test");
const res = await handler(req, mockCtx);
const data = await res.json();
expect(res.status).toBe(500);
expect(data.error).toBe("操作失败");
consoleSpy.mockRestore();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
TEST_USER,
TEST_USER_2,
TEST_RESTAURANT,
TEST_RESTAURANT_2,
TEST_RESTAURANT_3,
TEST_ROOM_DATA,
} from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/store", () => ({
getRoomData: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
user: {
findMany: vi.fn(),
},
},
}));
import { buildRoomStatus } from "@/lib/buildRoomStatus";
import { getRoomData } from "@/lib/store";
import { prisma } from "@/lib/prisma";
const mockGetRoomData = vi.mocked(getRoomData);
const mockFindMany = vi.mocked(prisma.user.findMany);
beforeEach(() => {
vi.clearAllMocks();
mockFindMany.mockResolvedValue([
{ id: TEST_USER.id, username: TEST_USER.username, avatar: TEST_USER.avatar } as never,
{ id: TEST_USER_2.id, username: TEST_USER_2.username, avatar: TEST_USER_2.avatar } as never,
]);
});
describe("buildRoomStatus", () => {
it("returns null when room not found", async () => {
mockGetRoomData.mockResolvedValue(null);
const result = await buildRoomStatus("nonexistent");
expect(result).toBeNull();
});
it("returns base status for room with no match", async () => {
mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA });
const result = await buildRoomStatus("room-1");
expect(result).not.toBeNull();
expect(result!.roomId).toBe("room-1");
expect(result!.userCount).toBe(2);
expect(result!.match).toBeNull();
expect(result!.matchType).toBeNull();
expect(result!.restaurants).toHaveLength(3);
expect(result!.creatorId).toBe(TEST_USER.id);
});
it("returns unanimous match when data has match", async () => {
mockGetRoomData.mockResolvedValue({
...TEST_ROOM_DATA,
match: TEST_RESTAURANT.id,
});
const result = await buildRoomStatus("room-1");
expect(result!.match).toBe(TEST_RESTAURANT.id);
expect(result!.matchType).toBe("unanimous");
expect(result!.matchLikes).toBe(2);
});
it("returns best match when all finished and top has likes", async () => {
const data = {
...TEST_ROOM_DATA,
swipeCounts: {
[TEST_USER.id]: 3,
[TEST_USER_2.id]: 3,
},
likes: {
[TEST_RESTAURANT.id]: [TEST_USER.id],
[TEST_RESTAURANT_2.id]: [TEST_USER.id, TEST_USER_2.id],
},
};
mockGetRoomData.mockResolvedValue(data);
const result = await buildRoomStatus("room-1");
expect(result!.matchType).toBe("best");
expect(result!.match).toBe(TEST_RESTAURANT_2.id);
expect(result!.matchLikes).toBe(2);
expect(result!.runnerUps).toEqual([{ id: TEST_RESTAURANT.id, likes: 1 }]);
});
it("returns no_match when all finished but no likes", async () => {
const data = {
...TEST_ROOM_DATA,
swipeCounts: {
[TEST_USER.id]: 3,
[TEST_USER_2.id]: 3,
},
likes: {},
};
mockGetRoomData.mockResolvedValue(data);
const result = await buildRoomStatus("room-1");
expect(result!.matchType).toBe("no_match");
expect(result!.matchLikes).toBe(0);
});
it("ranks by likes descending then by rating", async () => {
const data = {
...TEST_ROOM_DATA,
swipeCounts: {
[TEST_USER.id]: 3,
[TEST_USER_2.id]: 3,
},
likes: {
[TEST_RESTAURANT.id]: [TEST_USER.id],
[TEST_RESTAURANT_2.id]: [TEST_USER.id],
},
};
mockGetRoomData.mockResolvedValue(data);
const result = await buildRoomStatus("room-1");
// Both have 1 like, TEST_RESTAURANT has higher rating (4.5 vs 4.0)
expect(result!.match).toBe(TEST_RESTAURANT.id);
expect(result!.runnerUps[0].id).toBe(TEST_RESTAURANT_2.id);
});
it("does not consider allFinished when no users", async () => {
const data = {
...TEST_ROOM_DATA,
users: [],
swipeCounts: {},
};
mockGetRoomData.mockResolvedValue(data);
const result = await buildRoomStatus("room-1");
expect(result!.matchType).toBeNull();
});
it("populates user profiles", async () => {
mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA });
const result = await buildRoomStatus("room-1");
expect(result!.userProfiles[TEST_USER.id]).toEqual({
id: TEST_USER.id,
username: TEST_USER.username,
avatar: TEST_USER.avatar,
});
});
it("includes likeCounts only for restaurants with likes", async () => {
const data = {
...TEST_ROOM_DATA,
likes: {
[TEST_RESTAURANT.id]: [TEST_USER.id, TEST_USER_2.id],
[TEST_RESTAURANT_2.id]: [],
},
};
mockGetRoomData.mockResolvedValue(data);
const result = await buildRoomStatus("room-1");
expect(result!.likeCounts[TEST_RESTAURANT.id]).toBe(2);
expect(result!.likeCounts[TEST_RESTAURANT_2.id]).toBeUndefined();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api";
import {
validateUsername,
validatePassword,
validateEmail,
validateIdeaContent,
validateRoomName,
requireString,
} from "@/lib/validation";
describe("validateUsername", () => {
it("accepts 2-16 character names", () => {
expect(validateUsername("ab")).toBe("ab");
expect(validateUsername("a".repeat(16))).toBe("a".repeat(16));
expect(validateUsername("用户名")).toBe("用户名");
});
it("trims whitespace", () => {
expect(validateUsername(" hello ")).toBe("hello");
});
it("rejects names shorter than 2 chars", () => {
expect(() => validateUsername("a")).toThrow(ApiError);
expect(() => validateUsername("")).toThrow(ApiError);
expect(() => validateUsername(" ")).toThrow(ApiError);
});
it("rejects names longer than 16 chars", () => {
expect(() => validateUsername("a".repeat(17))).toThrow(ApiError);
});
});
describe("validatePassword", () => {
it("accepts 6-128 character passwords", () => {
expect(() => validatePassword("123456")).not.toThrow();
expect(() => validatePassword("a".repeat(128))).not.toThrow();
});
it("rejects passwords shorter than 6 chars", () => {
expect(() => validatePassword("12345")).toThrow(ApiError);
expect(() => validatePassword("")).toThrow(ApiError);
});
it("rejects passwords longer than 128 chars", () => {
expect(() => validatePassword("a".repeat(129))).toThrow(ApiError);
});
it("uses custom label in error message", () => {
expect(() => validatePassword("12345", "新密码")).toThrow("新密码至少 6 个字符");
});
});
describe("validateEmail", () => {
it("accepts valid emails", () => {
expect(() => validateEmail("test@example.com")).not.toThrow();
expect(() => validateEmail("user.name@domain.co")).not.toThrow();
});
it("rejects invalid emails", () => {
expect(() => validateEmail("notanemail")).toThrow(ApiError);
expect(() => validateEmail("@domain.com")).toThrow(ApiError);
expect(() => validateEmail("user@")).toThrow(ApiError);
expect(() => validateEmail("user @domain.com")).toThrow(ApiError);
});
});
describe("validateIdeaContent", () => {
it("accepts valid content and trims", () => {
expect(validateIdeaContent(" 去公园 ")).toBe("去公园");
expect(validateIdeaContent("a")).toBe("a");
});
it("rejects empty/falsy content", () => {
expect(() => validateIdeaContent("")).toThrow(ApiError);
expect(() => validateIdeaContent(null)).toThrow(ApiError);
expect(() => validateIdeaContent(undefined)).toThrow(ApiError);
expect(() => validateIdeaContent(" ")).toThrow(ApiError);
});
it("rejects non-string content", () => {
expect(() => validateIdeaContent(123)).toThrow(ApiError);
});
it("rejects content over 200 chars", () => {
expect(() => validateIdeaContent("a".repeat(201))).toThrow(ApiError);
expect(validateIdeaContent("a".repeat(200))).toBe("a".repeat(200));
});
});
describe("validateRoomName", () => {
it("accepts valid room name", () => {
expect(validateRoomName("我的房间")).toBe("我的房间");
});
it("uses fallback for empty name", () => {
expect(validateRoomName("")).toBe("我们的周末");
expect(validateRoomName(null)).toBe("我们的周末");
expect(validateRoomName(undefined)).toBe("我们的周末");
});
it("uses custom fallback", () => {
expect(validateRoomName("", "默认名")).toBe("默认名");
});
it("rejects names over 30 chars", () => {
expect(() => validateRoomName("a".repeat(31))).toThrow(ApiError);
expect(validateRoomName("a".repeat(30))).toBe("a".repeat(30));
});
});
describe("requireString", () => {
it("returns string value when valid", () => {
expect(requireString("hello", "field")).toBe("hello");
});
it("throws for empty/falsy values", () => {
expect(() => requireString("", "字段")).toThrow("字段不能为空");
expect(() => requireString(null, "字段")).toThrow(ApiError);
expect(() => requireString(undefined, "字段")).toThrow(ApiError);
expect(() => requireString(" ", "字段")).toThrow(ApiError);
});
it("throws for non-string values", () => {
expect(() => requireString(123, "字段")).toThrow(ApiError);
});
});