test: 添加完整测试套件(52 个文件,326 个用例)
基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、 前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user