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
+2822 -1
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@prisma/client": "^6.19.2",
@@ -25,14 +28,22 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18",
"vitest-mock-extended": "^3.1.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest } from "next/server";
export function createRequest(
url: string,
options: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
} = {},
): NextRequest {
const { method = "GET", body, headers = {} } = options;
const init: RequestInit = { method, headers };
if (body !== undefined) {
init.body = JSON.stringify(body);
(init.headers as Record<string, string>)["content-type"] = "application/json";
}
return new NextRequest(new URL(url, "http://localhost:3000"), init);
}
export function createRouteContext(
params: Record<string, string>,
): { params: Promise<Record<string, string>> } {
return { params: Promise.resolve(params) };
}
export async function parseJsonResponse(response: Response) {
const data = await response.json();
return { status: response.status, data };
}
+123
View File
@@ -0,0 +1,123 @@
import type { Restaurant } from "@/types";
export const TEST_USER = {
id: "user-1",
username: "testuser",
avatar: "🐱",
email: null,
passwordHash: "$2a$10$hashedpassword",
preferences: "{}",
createdAt: new Date("2025-01-01"),
};
export const TEST_USER_2 = {
id: "user-2",
username: "testuser2",
avatar: "🐶",
email: null,
passwordHash: "$2a$10$hashedpassword2",
preferences: "{}",
createdAt: new Date("2025-01-02"),
};
export const TEST_RESTAURANT: Restaurant = {
id: "rest-1",
name: "测试餐厅",
rating: 4.5,
price: "¥80",
distance: "500m",
images: ["https://example.com/img.jpg"],
category: "中餐",
address: "测试地址",
openTime: "09:00-22:00",
tel: "021-12345678",
tag: "川菜",
location: "121.4,31.2",
};
export const TEST_RESTAURANT_2: Restaurant = {
id: "rest-2",
name: "测试餐厅2",
rating: 4.0,
price: "¥60",
distance: "800m",
images: ["https://example.com/img2.jpg"],
category: "日料",
address: "测试地址2",
openTime: "10:00-21:00",
tel: "021-87654321",
tag: "寿司",
location: "121.5,31.3",
};
export const TEST_RESTAURANT_3: Restaurant = {
id: "rest-3",
name: "测试餐厅3",
rating: 3.5,
price: "¥120",
distance: "1200m",
images: ["https://example.com/img3.jpg"],
category: "西餐",
address: "测试地址3",
openTime: "11:00-23:00",
tel: "021-11111111",
tag: "牛排",
location: "121.6,31.4",
};
export const TEST_ROOM_DATA = {
users: [TEST_USER.id, TEST_USER_2.id],
restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3],
likes: {} as Record<string, string[]>,
swipeCounts: {} as Record<string, number>,
match: null as string | null,
creatorId: TEST_USER.id,
locked: false,
kickedUsers: [] as string[],
scene: "eat" as const,
};
export const TEST_BLINDBOX_ROOM = {
id: "bb-room-1",
code: "ABC123",
name: "我们的周末",
creatorId: TEST_USER.id,
city: null,
lat: null,
lng: null,
createdAt: new Date("2025-01-01"),
};
export const TEST_BLINDBOX_IDEA = {
id: "idea-1",
roomId: "bb-room-1",
userId: TEST_USER.id,
content: "去公园野餐",
status: "in_pool",
category: "outdoor",
timeSlot: "morning",
estimatedMinutes: 120,
outdoor: true,
searchQuery: "公园",
searchType: "category",
drawnById: null,
createdAt: new Date("2025-01-01"),
};
export const TEST_WEEKEND_PLAN = {
id: "plan-1",
roomId: "bb-room-1",
userId: TEST_USER.id,
status: "active",
planData: JSON.stringify({
days: [{
date: "周六",
items: [
{ time: "10:00", activity: "去公园", poi: "某公园", address: "xx路", lat: 31.2, lng: 121.4, duration: 120, reason: "天气好" },
],
}],
summary: "愉快的一天",
}),
endTime: null,
createdAt: new Date("2025-01-01"),
};
+13
View File
@@ -0,0 +1,13 @@
import { vi } from "vitest";
import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
import type { PrismaClient } from "@prisma/client";
export const prismaMock = mockDeep<PrismaClient>();
vi.mock("@/lib/prisma", () => ({
prisma: prismaMock,
}));
export function resetPrismaMock() {
mockReset(prismaMock);
}
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
afterEach(() => {
vi.restoreAllMocks();
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("bcryptjs", () => ({
default: {
compare: vi.fn(),
},
}));
import bcrypt from "bcryptjs";
import { POST } from "./route";
const mockCompare = vi.mocked(bcrypt.compare);
beforeEach(() => {
resetPrismaMock();
mockCompare.mockReset();
});
describe("POST /api/auth/login", () => {
it("logs in successfully with correct credentials", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
mockCompare.mockResolvedValue(true as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "testuser", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe(TEST_USER.id);
expect(data.username).toBe("testuser");
expect(data.avatar).toBe("🐱");
});
it("returns 401 when user not found", async () => {
prismaMock.user.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "nonexistent", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(401);
});
it("returns 401 when password is wrong", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
mockCompare.mockResolvedValue(false as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "testuser", password: "wrongpassword" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(401);
});
it("returns 400 when fields are missing", async () => {
const req = createRequest("/api/auth/login", {
method: "POST",
body: {},
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("bcryptjs", () => ({
default: { hash: vi.fn().mockResolvedValue("$2a$10$hashed") },
}));
import { POST } from "./route";
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/auth/register", () => {
it("registers a new user successfully", async () => {
prismaMock.user.create.mockResolvedValue({
...TEST_USER,
id: "new-user",
username: "newuser",
avatar: "🐱",
} as never);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "newuser", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.username).toBe("newuser");
expect(data.id).toBe("new-user");
expect(data.avatar).toBe("🐱");
});
it("uses custom avatar if provided", async () => {
prismaMock.user.create.mockResolvedValue({
...TEST_USER,
avatar: "🦊",
} as never);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "newuser", password: "password123", avatar: "🦊" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { data } = await parseJsonResponse(res);
expect(data.avatar).toBe("🦊");
});
it("returns 400 when username is missing", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when password is missing", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "testuser" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when username too short", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "a", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when password too short", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "testuser", password: "12345" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 409 when username already exists", async () => {
const { Prisma } = await import("@prisma/client");
prismaMock.user.create.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
}),
);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "existing", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(409);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/draw", () => {
it("draws a random idea", async () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
findUnique: vi.fn().mockResolvedValue({
id: "idea-1",
content: "去公园",
createdAt: new Date(),
user: { id: "user-2", username: "submitter", avatar: "🐶" },
drawnBy: { id: "user-1", username: "drawer", avatar: "🐱" },
}),
},
};
return fn(tx);
});
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("idea-1");
expect(data.content).toBe("去公园");
expect(data.submitter).toBeDefined();
expect(data.drawnBy).toBeDefined();
});
it("returns 404 when pool is empty", async () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn(),
findUnique: vi.fn(),
},
};
return fn(tx);
});
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 409 on race condition (count=0)", async () => {
prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findUnique: vi.fn(),
},
};
return fn(tx);
});
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(409);
});
it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+191
View File
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/blindboxPlanGen", () => ({
runPlanGeneration: vi.fn().mockResolvedValue({
id: "plan-1",
days: [{ date: "周六", items: [] }],
createdAt: new Date(),
}),
}));
import { POST, PATCH, GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/plan", () => {
it("generates a weekend plan", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("plan-1");
});
it("returns 400 for invalid available time", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("PATCH /api/blindbox/plan", () => {
it("accepts a plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "active",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("completes an accepted plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "complete" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
});
it("expires an accepted plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "expire" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
});
it("returns 400 when accepting non-active plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 403 when not plan owner", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
userId: "other-user",
} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(403);
});
it("returns 400 for invalid action", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue(TEST_WEEKEND_PLAN as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "invalid" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("GET /api/blindbox/plan", () => {
it("returns latest accepted plan", async () => {
prismaMock.weekendPlan.findFirst.mockResolvedValue({
id: "plan-1",
planData: JSON.stringify({ days: [{ date: "周六", items: [] }] }),
endTime: null,
createdAt: new Date(),
} as never);
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.plan).toBeDefined();
expect(data.plan.id).toBe("plan-1");
});
it("returns null when no plan found", async () => {
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.plan).toBeNull();
});
it("returns 400 for invalid mode", async () => {
const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/api", () => ({
requireUserId: vi.fn((v) => {
if (!v || typeof v !== "string") throw new Error("请先登录");
return v;
}),
}));
vi.mock("@/lib/blindboxPlanGen", () => ({
runPlanGeneration: vi.fn(),
}));
import { POST } from "./route";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
const mockRunPlan = vi.mocked(runPlanGeneration);
beforeEach(() => {
vi.clearAllMocks();
});
async function readStream(response: Response): Promise<string> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
return text;
}
describe("POST /api/blindbox/plan/stream", () => {
it("streams plan generation with SSE events", async () => {
mockRunPlan.mockImplementation(async (_roomId, _userId, _at, onProgress) => {
onProgress?.("正在搜索周边...");
onProgress?.("正在生成行程...");
return {
id: "plan-1",
days: [{ date: "周六", items: [] }],
createdAt: new Date(),
};
});
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
const text = await readStream(res);
expect(text).toContain("event: status");
expect(text).toContain("正在搜索周边...");
expect(text).toContain("event: plan");
expect(text).toContain("plan-1");
});
it("streams error event on generation failure", async () => {
mockRunPlan.mockRejectedValue(new Error("AI 服务不可用"));
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req);
const text = await readStream(res);
expect(text).toContain("event: error");
expect(text).toContain("AI 服务不可用");
});
it("returns 400 for missing roomId", async () => {
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 for invalid available time", async () => {
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
}),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("returns 400 when userId is missing", async () => {
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/blindbox", () => ({
getRoomByCode: vi.fn(),
requireMembership: vi.fn().mockResolvedValue({}),
}));
import { GET, PATCH, DELETE } from "./route";
import { getRoomByCode } from "@/lib/blindbox";
const mockGetRoomByCode = vi.mocked(getRoomByCode);
beforeEach(() => {
resetPrismaMock();
vi.clearAllMocks();
});
describe("GET /api/blindbox/room/[code]", () => {
it("returns room data", async () => {
mockGetRoomByCode.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
_count: { ideas: 3 },
members: [
{ user: { id: "user-1", username: "test", avatar: "🐱" }, joinedAt: new Date() },
],
} as never);
const req = createRequest("/api/blindbox/room/ABC123");
const ctx = createRouteContext({ code: "ABC123" });
const res = await GET(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.code).toBe("ABC123");
expect(data.poolCount).toBe(3);
expect(data.members).toHaveLength(1);
});
it("returns 404 for nonexistent room", async () => {
mockGetRoomByCode.mockResolvedValue(null);
const req = createRequest("/api/blindbox/room/BADCODE");
const ctx = createRouteContext({ code: "BADCODE" });
const res = await GET(req, ctx);
expect(res.status).toBe(404);
});
});
describe("PATCH /api/blindbox/room/[code]", () => {
it("updates room location", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxRoom.update.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
city: "上海",
lat: 31.2,
lng: 121.4,
} as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.city).toBe("上海");
});
it("returns 400 for invalid coordinates", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { userId: "user-1", lat: 999, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
expect(res.status).toBe(400);
});
});
describe("DELETE /api/blindbox/room/[code]", () => {
it("deletes room when creator", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "DELETE",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.action).toBe("deleted");
});
it("leaves room when not creator", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "DELETE",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.action).toBe("left");
});
it("returns 403 when not a member and not creator", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "DELETE",
body: { userId: "stranger" },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
expect(res.status).toBe(403);
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/room/join", () => {
it("joins a room by code", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
prismaMock.blindBoxMember.create.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-2", code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.code).toBe("ABC123");
});
it("returns alreadyMember if already joined", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-1" } as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1", code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyMember).toBe(true);
});
it("returns 404 when room code not found", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1", code: "BADCODE" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 400 when code is missing", async () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/blindbox", () => ({
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
}));
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/room", () => {
it("creates a blindbox room", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.blindBoxRoom.create.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
code: "XYZ789",
} as never);
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { userId: "user-1", name: "周末计划" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.code).toBe("XYZ789");
});
it("uses default room name when not provided", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.blindBoxRoom.create.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(201);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { name: "test" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when room name too long", async () => {
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { userId: "user-1", name: "a".repeat(31) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/blindbox/rooms", () => {
it("returns user rooms list", async () => {
prismaMock.blindBoxMember.findMany.mockResolvedValue([
{
room: {
id: "bb-room-1",
code: "ABC123",
name: "周末",
creatorId: "user-1",
_count: { members: 2, ideas: 5 },
members: [
{ user: { id: "user-1", username: "test", avatar: "🐱" } },
],
ideas: [{ content: "去公园", createdAt: new Date() }],
},
joinedAt: new Date(),
},
] as never);
prismaMock.blindBoxIdea.groupBy.mockResolvedValue([
{ roomId: "bb-room-1", _count: 3 },
] as never);
const req = createRequest("/api/blindbox/rooms?userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.rooms).toHaveLength(1);
expect(data.rooms[0].code).toBe("ABC123");
expect(data.rooms[0].poolCount).toBe(3);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai", () => ({
tagIdea: vi.fn().mockResolvedValue({
category: "outdoor",
timeSlot: "morning",
estimatedMinutes: 120,
outdoor: true,
searchQuery: "公园",
searchType: "category",
}),
}));
import { POST, GET, PUT, DELETE } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox (create idea)", () => {
it("creates an idea successfully", async () => {
prismaMock.blindBoxIdea.create.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
prismaMock.blindBoxIdea.update.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.id).toBe("idea-1");
expect(data.tags).toBeDefined();
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "test" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when content is empty", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 when content over 200 chars", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "a".repeat(201) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("GET /api/blindbox (get pool data)", () => {
it("returns pool data for valid member", async () => {
prismaMock.blindBoxIdea.count.mockResolvedValue(5 as never);
prismaMock.blindBoxIdea.findMany
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
.mockResolvedValueOnce([] as never);
const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.poolCount).toBe(5);
expect(data.myIdeas).toHaveLength(1);
expect(data.drawn).toHaveLength(0);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
});
describe("PUT /api/blindbox (edit idea)", () => {
it("edits an idea successfully", async () => {
prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" },
});
const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.content).toBe("去公园散步");
});
it("returns 404 when idea not found or already drawn", async () => {
prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 0 } as never);
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "nonexistent", userId: "user-1", content: "test" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/blindbox (delete idea)", () => {
it("deletes an idea successfully", async () => {
prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "idea-1", userId: "user-1" },
});
const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.deleted).toBe(true);
});
it("returns 404 when idea not found or not owned", async () => {
prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 0 } as never);
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "nonexistent", userId: "user-1" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai", () => ({
suggestIdeas: vi.fn().mockResolvedValue(["去爬山", "骑自行车", "看日出", "野餐"]),
}));
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/blindbox/suggest", () => {
it("returns AI suggestions when enough ideas exist", async () => {
prismaMock.blindBoxIdea.findMany.mockResolvedValue([
{ content: "去公园" },
{ content: "看电影" },
{ content: "吃火锅" },
] as never);
const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.suggestions).toHaveLength(4);
expect(data.source).toBe("ai");
});
it("returns empty when less than 2 ideas", async () => {
prismaMock.blindBoxIdea.findMany.mockResolvedValue([
{ content: "去公园" },
] as never);
const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.suggestions).toHaveLength(0);
expect(data.source).toBe("none");
});
it("returns 400 when roomId missing", async () => {
const req = createRequest("/api/blindbox/suggest?userId=user-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/regeo", () => {
it("returns reverse geocoded location", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
regeocode: {
formatted_address: "上海市黄浦区人民大道",
addressComponent: {
district: "黄浦区",
township: "南京东路街道",
neighborhood: { name: "人民广场" },
},
},
}),
});
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.name).toContain("黄浦区");
expect(data.formatted).toBe("上海市黄浦区人民大道");
});
it("returns null name when API returns no result", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "0" }),
});
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.name).toBeNull();
});
it("returns 400 when coordinates missing", async () => {
const req = createRequest("/api/location/regeo");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network"));
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/search", () => {
it("returns search results", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "星巴克",
address: "南京路1号",
location: "121.4,31.2",
business: { rating: "4.5", cost: "40" },
},
],
}),
});
const req = createRequest("/api/location/search?keywords=星巴克");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("星巴克");
expect(data[0].lat).toBe(31.2);
expect(data[0].lng).toBe(121.4);
});
it("returns empty when no results", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "1", pois: [] }),
});
const req = createRequest("/api/location/search?keywords=不存在的地方");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns 400 when keywords missing", async () => {
const req = createRequest("/api/location/search");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 503 when API unavailable", async () => {
mockFetch.mockRejectedValue(new Error("network error"));
const req = createRequest("/api/location/search?keywords=test");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/suggest", () => {
it("returns suggestions", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
tips: [
{
id: "tip-1",
name: "人民广场",
district: "黄浦区",
address: "人民大道",
location: "121.4737,31.2304",
},
],
}),
});
const req = createRequest("/api/location/suggest?keywords=人民广场");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("人民广场");
});
it("returns empty for no keywords", async () => {
const req = createRequest("/api/location/suggest");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("filters tips without location", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
tips: [
{ id: "tip-1", name: "有位置", location: "121.4,31.2" },
{ id: "tip-2", name: "无位置", location: "" },
],
}),
});
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("有位置");
});
it("returns 503 when API fails", async () => {
mockFetch.mockRejectedValue(new Error("network"));
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/join", () => {
it("joins a room successfully", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, users: ["user-1"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
expect(data.userCount).toBe(2);
});
it("returns 403 when kicked", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, kickedUsers: ["user-2"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 403 when room is locked", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, locked: true, users: ["user-1"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/join", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: {},
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(401);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/manage", () => {
it("locks the room", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.locked).toBe(true);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "lock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("unlocks the room", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.locked = true;
const result = updater(data);
expect(result.locked).toBe(false);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "unlock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("kicks a user", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.users).not.toContain("user-2");
expect(result.kickedUsers).toContain("user-2");
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "kick", targetUserId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("prevents kicking yourself", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "kick", targetUserId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("ends voting by setting all swipeCounts to total", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.swipeCounts["user-1"]).toBe(3);
expect(result.swipeCounts["user-2"]).toBe(3);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "end_voting" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("returns 403 when not the creator", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.creatorId = "other-user";
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "lock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 400 for unknown action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "unknown" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/reset", () => {
it("resets the room (clears likes/swipeCounts/match)", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes = { "rest-1": ["user-1"] };
data.swipeCounts = { "user-1": 3 };
data.match = "rest-1";
const result = updater(data);
expect(result.likes).toEqual({});
expect(result.swipeCounts).toEqual({});
expect(result.match).toBeNull();
return result;
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("filters restaurants when restaurantIds provided", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.restaurants).toHaveLength(1);
expect(result.restaurants[0].id).toBe(TEST_RESTAURANT.id);
return result;
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1", restaurantIds: [TEST_RESTAURANT.id] },
});
const ctx = createRouteContext({ id: "ROOM01" });
await POST(req, ctx);
});
it("returns 403 when not a member or creator", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
data.creatorId = "other-user";
return updater(data);
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({
prisma: { user: { findMany: vi.fn().mockResolvedValue([]) } },
}));
vi.mock("@/lib/buildRoomStatus", () => ({
buildRoomStatus: vi.fn(),
}));
import { GET } from "./route";
import { buildRoomStatus } from "@/lib/buildRoomStatus";
const mockBuildRoomStatus = vi.mocked(buildRoomStatus);
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/room/[id]", () => {
it("returns room status", async () => {
mockBuildRoomStatus.mockResolvedValue({
roomId: "ROOM01",
userCount: 2,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [],
creatorId: "user-1",
locked: false,
users: ["user-1"],
userProfiles: {},
scene: "eat",
});
const req = createRequest("/api/room/ROOM01");
const ctx = createRouteContext({ id: "ROOM01" });
const res = await GET(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
});
it("returns 404 for nonexistent room", async () => {
mockBuildRoomStatus.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST");
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await GET(req, ctx);
expect(res.status).toBe(404);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/swipe", () => {
it("records a like action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.likeCount).toBe(1);
});
it("records a pass action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "pass" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.match).toBeNull();
});
it("sets match when all users like same restaurant", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes[TEST_RESTAURANT.id] = ["user-2"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.match).toBe(TEST_RESTAURANT.id);
expect(data.likeCount).toBe(2);
});
it("returns 403 when user is not a member", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 400 for invalid action", async () => {
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "invalid" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("returns 400 when missing restaurantId", async () => {
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/undo", () => {
it("undoes a like and decrements swipe count", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes[TEST_RESTAURANT.id] = ["user-1"];
data.swipeCounts["user-1"] = 1;
return updater(data);
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("clears match when undoing the matched restaurant", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.match = TEST_RESTAURANT.id;
data.likes[TEST_RESTAURANT.id] = ["user-1", "user-2"];
data.swipeCounts["user-1"] = 1;
const result = updater(data);
expect(result.match).toBeNull();
return result;
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
await POST(req, ctx);
});
it("returns 403 when user is not a member", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({
prisma: {},
}));
vi.mock("@/lib/store", () => ({
createRoom: vi.fn().mockResolvedValue("ROOM01"),
}));
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { POST } from "./route";
import { createRoom } from "@/lib/store";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/create", () => {
it("creates a room with restaurants from Amap", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "好吃餐厅",
distance: "300",
type: "餐饮服务;中餐厅;川菜",
address: "测试路1号",
location: "121.4,31.2",
business: { rating: "4.5", cost: "80" },
photos: [{ url: "https://img.example.com/1.jpg" }],
},
],
}),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
expect(data.restaurants).toHaveLength(1);
expect(data.restaurants[0].name).toBe("好吃餐厅");
expect(createRoom).toHaveBeenCalled();
});
it("returns 400 for invalid coordinates", async () => {
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: "invalid", lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 for out-of-range coordinates", async () => {
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 100, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 404 when no restaurants found", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "1", pois: [] }),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 503 when Amap API fails", async () => {
mockFetch.mockRejectedValue(new Error("network error"));
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(503);
});
it("filters restaurants by price range", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "便宜店",
location: "121.4,31.2",
business: { cost: "30" },
},
{
id: "poi-2",
name: "贵店",
location: "121.4,31.2",
business: { cost: "150" },
},
],
}),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1", priceRange: "under50" },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.restaurants).toHaveLength(1);
expect(data.restaurants[0].name).toBe("便宜店");
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/user/achievements", () => {
it("returns 401 when no userId", async () => {
const req = createRequest("/api/user/achievements");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns stats and records", async () => {
prismaMock.decision.findMany.mockResolvedValue([
{
id: "dec-1",
userId: "user-1",
roomId: "room-1",
restaurantName: "测试餐厅",
restaurantData: JSON.stringify(TEST_RESTAURANT),
matchType: "unanimous",
participants: 2,
createdAt: new Date("2025-01-01"),
},
] as never);
prismaMock.weekendPlan.findMany.mockResolvedValue([
{
id: "plan-1",
planData: JSON.stringify({
days: [{ date: "周六", items: [{ activity: "逛公园" }] }],
}),
status: "completed",
roomId: "bb-room-1",
createdAt: new Date("2025-01-01"),
},
] as never);
prismaMock.blindBoxRoom.findMany.mockResolvedValue([
{ id: "bb-room-1", name: "周末", code: "ABC123" },
] as never);
const req = createRequest("/api/user/achievements?userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.stats.totalDecisions).toBe(1);
expect(data.stats.totalContracts).toBe(1);
expect(data.stats.completedContracts).toBe(1);
expect(data.stats.completionRate).toBe(100);
expect(data.decisions).toHaveLength(1);
expect(data.contracts).toHaveLength(1);
expect(data.contracts[0].roomName).toBe("周末");
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { GET, POST, DELETE } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/user/favorite", () => {
it("returns empty array when no userId", async () => {
const req = createRequest("/api/user/favorite");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns favorites list", async () => {
prismaMock.favorite.findMany.mockResolvedValue([
{
id: "fav-1",
userId: "user-1",
restaurantId: "rest-1",
restaurantData: JSON.stringify(TEST_RESTAURANT),
createdAt: new Date("2025-01-01"),
},
] as never);
const req = createRequest("/api/user/favorite?userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].id).toBe("fav-1");
expect(data[0].restaurantData.name).toBe("测试餐厅");
});
});
describe("POST /api/user/favorite", () => {
it("adds a favorite", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.favorite.create.mockResolvedValue({ id: "fav-new" } as never);
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("fav-new");
});
it("returns alreadyExists on duplicate", async () => {
const { Prisma } = await import("@prisma/client");
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.favorite.create.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique", {
code: "P2002",
clientVersion: "5.0.0",
}),
);
prismaMock.favorite.findFirst.mockResolvedValue({ id: "fav-existing" } as never);
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { userId: "user-1", restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyExists).toBe(true);
expect(data.id).toBe("fav-existing");
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when no restaurant", async () => {
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("DELETE /api/user/favorite", () => {
it("deletes a favorite", async () => {
prismaMock.favorite.findUnique.mockResolvedValue({
id: "fav-1",
userId: "user-1",
} as never);
prismaMock.favorite.delete.mockResolvedValue({} as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { userId: "user-1", favoriteId: "fav-1" },
});
const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.ok).toBe(true);
});
it("returns 404 when favorite not found", async () => {
prismaMock.favorite.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { userId: "user-1", favoriteId: "nonexistent" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 404 when favorite belongs to another user", async () => {
prismaMock.favorite.findUnique.mockResolvedValue({
id: "fav-1",
userId: "other-user",
} as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { userId: "user-1", favoriteId: "fav-1" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { GET, POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/user/history", () => {
it("returns empty array when no userId", async () => {
const req = createRequest("/api/user/history");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns decision history", async () => {
prismaMock.decision.findMany.mockResolvedValue([
{
id: "dec-1",
userId: "user-1",
roomId: "room-1",
restaurantName: "测试餐厅",
restaurantData: JSON.stringify(TEST_RESTAURANT),
matchType: "unanimous",
participants: 2,
createdAt: new Date("2025-01-01"),
},
] as never);
const req = createRequest("/api/user/history?userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].restaurantName).toBe("测试餐厅");
});
});
describe("POST /api/user/history", () => {
it("saves a decision", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue(null as never);
prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never);
prismaMock.decision.count.mockResolvedValue(1 as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
userId: "user-1",
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "unanimous",
participants: 2,
},
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("dec-new");
});
it("returns alreadyExists for duplicate room", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue({ id: "existing" } as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
userId: "user-1",
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "unanimous",
},
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyExists).toBe(true);
});
it("trims history to 50 records", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue(null as never);
prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never);
prismaMock.decision.count.mockResolvedValue(51 as never);
prismaMock.decision.findMany.mockResolvedValue([{ id: "old-1" }] as never);
prismaMock.decision.deleteMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
userId: "user-1",
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "best",
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(200);
expect(prismaMock.decision.deleteMany).toHaveBeenCalled();
});
it("returns 400 when missing required fields", async () => {
const req = createRequest("/api/user/history", {
method: "POST",
body: { userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/user/history", {
method: "POST",
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("bcryptjs", () => ({
default: {
compare: vi.fn(),
hash: vi.fn().mockResolvedValue("$2a$10$newhash"),
},
}));
import bcrypt from "bcryptjs";
import { GET, PUT } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(bcrypt.compare).mockReset();
});
describe("GET /api/user", () => {
it("returns null when no userId provided", async () => {
const req = createRequest("/api/user");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toBeNull();
});
it("returns null when user not found", async () => {
prismaMock.user.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/user?id=nonexistent");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toBeNull();
});
it("returns user info with decision count", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.count.mockResolvedValue(5 as never);
const req = createRequest("/api/user?id=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("user-1");
expect(data.username).toBe("testuser");
expect(data.decisionCount).toBe(5);
});
});
describe("PUT /api/user", () => {
it("updates username", async () => {
prismaMock.user.findUnique
.mockResolvedValueOnce(TEST_USER as never)
.mockResolvedValueOnce(null as never);
prismaMock.user.update.mockResolvedValue({
...TEST_USER,
username: "newname",
preferences: "{}",
} as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", username: "newname" },
});
const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.username).toBe("newname");
});
it("returns 409 when new username is taken", async () => {
prismaMock.user.findUnique
.mockResolvedValueOnce(TEST_USER as never)
.mockResolvedValueOnce({ id: "other" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", username: "takenname" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(409);
});
it("updates password with correct current password", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
prismaMock.user.update.mockResolvedValue({ ...TEST_USER, preferences: "{}" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(200);
});
it("returns 403 when current password is wrong", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(403);
});
it("returns 400 when no current password for password change", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/user", {
method: "PUT",
body: { username: "test" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(401);
});
it("updates avatar", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.user.update.mockResolvedValue({ ...TEST_USER, avatar: "🦊", preferences: "{}" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", avatar: "🦊" },
});
const res = await PUT(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.avatar).toBe("🦊");
});
it("updates email with validation", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.user.update.mockResolvedValue({
...TEST_USER,
email: "new@example.com",
preferences: "{}",
} as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", email: "new@example.com" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(200);
});
it("rejects invalid email", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { userId: "user-1", email: "notanemail" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(400);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
useParams: () => ({ code: "ABC123" }),
}));
vi.mock("@/lib/userId", () => ({
getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }),
isRegistered: vi.fn().mockReturnValue(true),
}));
vi.mock("@/hooks/useShare", () => ({
useShare: () => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
share: vi.fn(),
}),
}));
vi.mock("canvas-confetti", () => ({
default: vi.fn(),
}));
vi.mock("@/components/ShareCardModal", () => ({
default: () => null,
}));
vi.mock("@/components/ContractCompletionModal", () => ({
default: () => null,
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import BlindboxCodePage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(BlindboxCodePage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
room: {
id: "bb-1",
code: "ABC123",
name: "周末房间",
creatorId: "u1",
city: null,
lat: null,
lng: null,
ideaCount: 3,
memberCount: 2,
drawnCount: 0,
},
ideas: [],
members: [
{ id: "u1", username: "test", avatar: "🐱" },
],
myIdeas: [],
drawnHistory: [],
pendingContracts: [],
}),
});
});
describe("BlindboxCodePage", () => {
it("loads room data", async () => {
renderPage();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const fetchCall = mockFetch.mock.calls.find(
(c: string[]) => typeof c[0] === "string" && c[0].includes("/api/blindbox/room/ABC123"),
);
expect(fetchCall).toBeDefined();
});
it("fetches room data and loads user ideas", async () => {
renderPage();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const urls = mockFetch.mock.calls.map((c: string[]) => c[0]);
expect(urls.some((u: string) => u.includes("blindbox/room/ABC123"))).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/lib/userId", () => ({
getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }),
isRegistered: vi.fn().mockReturnValue(true),
}));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ rooms: [] }),
});
vi.stubGlobal("fetch", mockFetch);
import BlindboxPage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(BlindboxPage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("BlindboxPage", () => {
it("renders page heading", () => {
renderPage();
expect(screen.getByText("周末契约")).toBeInTheDocument();
});
it("renders back button", () => {
renderPage();
const backBtns = screen.getAllByRole("button");
expect(backBtns.length).toBeGreaterThan(0);
});
it("renders subtitle text", () => {
renderPage();
expect(screen.getByText("ADVENTURE ROULETTE")).toBeInTheDocument();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/lib/userId", () => ({
getUserId: vi.fn().mockReturnValue("user-1"),
getCachedPreferences: vi.fn().mockReturnValue({}),
}));
vi.mock("@/lib/sceneConfig", () => ({
SCENES: ["eat", "drinks"],
getSceneConfig: vi.fn().mockReturnValue({
key: "eat",
label: "餐厅",
emoji: "🍜",
verb: "吃",
poiTypes: "050000",
defaultImage: "",
hotTags: ["火锅", "日料", "烧烤"],
priceOptions: [{ label: "不限", value: "any" }],
tagLabel: "口味",
tagPlaceholder: "想吃什么?",
loadingText: "正在搜索...",
emptyError: "没找到",
subtitle: "一起选餐厅",
inviteText: "邀请你",
shareTitle: "分享",
shareText: "一起来",
qrSubtitle: "扫码加入",
}),
}));
vi.mock("@/hooks/useGeolocation", () => ({
useGeolocation: vi.fn().mockReturnValue({
status: "success",
coords: { lat: 31.2, lng: 121.4 },
locationName: "上海",
retry: vi.fn(),
}),
}));
vi.mock("@/lib/room", () => ({
joinRoom: vi.fn().mockResolvedValue(undefined),
}));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ suggestions: [] }),
});
vi.stubGlobal("fetch", mockFetch);
import PanicPage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(PanicPage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PanicPage", () => {
it("renders page title", () => {
renderPage();
expect(screen.getByText("极速救场")).toBeInTheDocument();
});
it("renders location search input", () => {
renderPage();
expect(screen.getByPlaceholderText(/搜索位置/)).toBeInTheDocument();
});
it("renders distance options", () => {
renderPage();
expect(screen.getByText("1km")).toBeInTheDocument();
expect(screen.getByText("3km")).toBeInTheDocument();
expect(screen.getByText("5km")).toBeInTheDocument();
});
it("renders scene selector with labels", () => {
renderPage();
expect(screen.getAllByText("餐厅").length).toBeGreaterThanOrEqual(1);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/lib/userId", () => ({
getUserId: vi.fn().mockReturnValue("user-1"),
getCachedProfile: vi.fn().mockReturnValue({ id: "user-1", username: "testuser", avatar: "🐱" }),
setCachedProfile: vi.fn(),
setCachedPreferences: vi.fn(),
logout: vi.fn(),
}));
vi.mock("@/lib/avatars", () => ({
getAvatarBg: vi.fn().mockReturnValue("bg-amber-100"),
AVATARS: [
{ emoji: "🐱", bg: "bg-amber-100" },
{ emoji: "🐶", bg: "bg-orange-100" },
],
}));
vi.mock("@/components/ProfileFavoritesCard", () => ({
default: () => <div data-testid="favorites-card" />,
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import ProfilePage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(ProfilePage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
id: "user-1",
username: "testuser",
avatar: "🐱",
email: null,
achievements: {
totalDecisions: 5,
unanimousCount: 2,
roomsCreated: 3,
streak: 1,
},
records: [],
favorites: [],
}),
});
});
describe("ProfilePage", () => {
it("renders profile heading", () => {
renderPage();
expect(screen.getByText("个人中心")).toBeInTheDocument();
});
it("fetches user profile data with correct URL", async () => {
renderPage();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"/api/user?id=user-1",
);
});
});
it("renders user info after data loads", async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
});
it("renders navigation element", () => {
renderPage();
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
useParams: () => ({ id: "ROOM01" }),
}));
vi.mock("@/lib/userId", () => ({
getUserId: vi.fn().mockReturnValue("user-1"),
}));
vi.mock("@/lib/room", () => ({
joinRoom: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/sceneConfig", () => ({
getSceneConfig: vi.fn().mockReturnValue({
label: "吃什么",
icon: "🍔",
gradient: "from-orange-500 to-red-500",
bg: "bg-amber-50",
}),
}));
vi.mock("@/hooks/useRoomPolling", () => ({
useRoomPolling: vi.fn().mockReturnValue({
userCount: 2,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2],
notFound: false,
mutate: vi.fn(),
creatorId: "user-1",
locked: false,
users: ["user-1", "user-2"],
userProfiles: {},
scene: "eat",
}),
}));
vi.mock("@/components/TopNav", () => ({
default: ({ roomId }: { roomId: string }) => (
<nav data-testid="top-nav">{roomId}</nav>
),
}));
vi.mock("@/components/SwipeDeck", () => ({
default: () => <div data-testid="swipe-deck" />,
}));
vi.mock("@/components/Skeleton", () => ({
SwipeDeckSkeleton: () => <div data-testid="swipe-skeleton" />,
}));
vi.mock("@/components/LeaveConfirmModal", () => ({
default: () => null,
}));
import RoomPage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(RoomPage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("RoomPage", () => {
it("renders TopNav with room ID", async () => {
renderPage();
await waitFor(() => {
expect(screen.getByTestId("top-nav")).toBeInTheDocument();
expect(screen.getByText("ROOM01")).toBeInTheDocument();
});
});
it("renders SwipeDeck when joined", async () => {
renderPage();
await waitFor(() => {
expect(screen.getByTestId("swipe-deck")).toBeInTheDocument();
});
});
it("shows 404 message when room not found", async () => {
const { useRoomPolling } = await import("@/hooks/useRoomPolling");
vi.mocked(useRoomPolling).mockReturnValue({
userCount: 0,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [],
notFound: true,
mutate: vi.fn(),
creatorId: null,
locked: false,
users: [],
userProfiles: {},
scene: "eat",
});
renderPage();
await waitFor(() => {
expect(screen.getByText(/不存在/)).toBeInTheDocument();
});
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AuthModal from "./AuthModal";
vi.mock("@/lib/userId", () => ({
setCachedProfile: vi.fn(),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const mockOnAuth = vi.fn();
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Reset localStorage
Object.defineProperty(window, "localStorage", {
value: { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() },
writable: true,
});
});
function renderModal(props = {}) {
return render(
<AuthModal open onClose={mockOnClose} onAuth={mockOnAuth} {...props} />,
);
}
describe("AuthModal", () => {
it("renders login tab by default", () => {
renderModal();
expect(screen.getAllByText("登录").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("注册").length).toBeGreaterThanOrEqual(1);
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
});
it("renders register tab when specified", () => {
renderModal({ defaultTab: "register" });
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
expect(screen.getByText("确认密码")).toBeInTheDocument();
});
it("switches between login and register tabs", async () => {
renderModal();
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
fireEvent.click(screen.getByText("注册"));
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
expect(screen.getByText("确认密码")).toBeInTheDocument();
});
it("shows error when username is empty", async () => {
renderModal();
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("请输入用户名")).toBeInTheDocument();
});
});
it("shows error when password is empty", async () => {
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("请输入密码")).toBeInTheDocument();
});
});
it("shows register-specific validation errors", async () => {
renderModal({ defaultTab: "register" });
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("2-16 个字符"), "a");
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
const submitBtn = screen.getAllByText("注册").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("用户名需要 2-16 个字符")).toBeInTheDocument();
});
});
it("shows password mismatch error during registration", async () => {
renderModal({ defaultTab: "register" });
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("2-16 个字符"), "testuser");
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
const submitBtn = screen.getAllByText("注册").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("两次密码不一致")).toBeInTheDocument();
});
});
it("submits login successfully", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: "u1", username: "testuser", avatar: "🐱" }),
});
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(mockOnAuth).toHaveBeenCalledWith({
id: "u1",
username: "testuser",
avatar: "🐱",
});
expect(mockOnClose).toHaveBeenCalled();
});
});
it("shows API error on failed login", async () => {
mockFetch.mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: "用户名或密码错误" }),
});
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "wrong");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("用户名或密码错误")).toBeInTheDocument();
});
});
it("shows network error on fetch failure", async () => {
mockFetch.mockRejectedValue(new Error("network"));
renderModal();
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
const submitBtn = screen.getAllByText("登录").find(
(el) => el.closest("button[class*='bg-accent']"),
)!;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(screen.getByText("网络错误,请重试")).toBeInTheDocument();
});
});
it("toggles password visibility", () => {
renderModal();
const toggle = screen.getByLabelText("显示密码");
const passwordInput = screen.getByPlaceholderText("请输入密码");
expect(passwordInput).toHaveAttribute("type", "password");
fireEvent.click(toggle);
expect(passwordInput).toHaveAttribute("type", "text");
});
it("does not render when closed", () => {
render(
<AuthModal open={false} onClose={mockOnClose} onAuth={mockOnAuth} />,
);
expect(screen.queryByText("欢迎")).not.toBeInTheDocument();
});
it("renders avatar selection in register mode", () => {
renderModal({ defaultTab: "register" });
expect(screen.getByText("选择头像")).toBeInTheDocument();
expect(screen.getByText("🐱")).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import BlindboxDrawnHistory, { type DrawnIdea } from "./BlindboxDrawnHistory";
const mockItems: DrawnIdea[] = [
{
id: "drawn-1",
content: "去公园野餐",
createdAt: "2025-03-01T10:00:00Z",
user: { id: "u1", username: "小明", avatar: "🐱" },
drawnBy: { id: "u2", username: "小红", avatar: "🐶" },
},
{
id: "drawn-2",
content: "看展览",
createdAt: "2025-03-02T10:00:00Z",
user: { id: "u2", username: "小红", avatar: "🐶" },
drawnBy: null,
},
];
describe("BlindboxDrawnHistory", () => {
it("renders nothing when items are empty", () => {
const { container } = render(<BlindboxDrawnHistory items={[]} />);
expect(container.innerHTML).toBe("");
});
it("renders history title and items", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
expect(screen.getByText("履约记录")).toBeInTheDocument();
expect(screen.getByText("去公园野餐")).toBeInTheDocument();
expect(screen.getByText("看展览")).toBeInTheDocument();
});
it("shows user who contributed and who drew", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
expect(screen.getByText(/小明 投入/)).toBeInTheDocument();
expect(screen.getByText(/小红 抽中/)).toBeInTheDocument();
});
it("renders formatted date", () => {
render(<BlindboxDrawnHistory items={mockItems} />);
const dateElements = screen.getAllByText(/月/);
expect(dateElements.length).toBeGreaterThan(0);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import BlindboxMyIdeas, { type MyIdea, CategoryBadge, DurationLabel } from "./BlindboxMyIdeas";
const mockIdeas: MyIdea[] = [
{
id: "idea-1",
content: "去爬山",
createdAt: new Date().toISOString(),
category: "outdoor",
estimatedMinutes: 120,
},
{
id: "idea-2",
content: "看电影",
createdAt: new Date().toISOString(),
category: "entertainment",
estimatedMinutes: 150,
},
];
const mockOnEdit = vi.fn().mockResolvedValue(undefined);
const mockOnDelete = vi.fn().mockResolvedValue(undefined);
describe("BlindboxMyIdeas", () => {
it("renders idea list with count", () => {
render(
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText(/我投入的想法(2/)).toBeInTheDocument();
expect(screen.getByText("去爬山")).toBeInTheDocument();
expect(screen.getByText("看电影")).toBeInTheDocument();
});
it("shows duration labels", () => {
render(
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText("~2h")).toBeInTheDocument();
expect(screen.getByText("~2.5h")).toBeInTheDocument();
});
it("renders empty list", () => {
render(
<BlindboxMyIdeas ideas={[]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
expect(screen.getByText(/我投入的想法(0/)).toBeInTheDocument();
});
it("enters edit mode on pencil button click", async () => {
render(
<BlindboxMyIdeas ideas={[mockIdeas[0]]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
);
const editButtons = screen.getAllByRole("button");
const pencilBtn = editButtons.find((b) => b.querySelector("svg"));
fireEvent.click(pencilBtn!);
await waitFor(() => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});
});
describe("CategoryBadge", () => {
it("renders icon for known category", () => {
const { container } = render(<CategoryBadge category="dining" />);
expect(container.querySelector("svg")).toBeInTheDocument();
});
it("renders fallback for unknown category", () => {
const { container } = render(<CategoryBadge category="unknown" />);
expect(container.textContent).toBe("💡");
});
it("renders fallback for null category", () => {
const { container } = render(<CategoryBadge category={null} />);
expect(container.textContent).toBe("💡");
});
});
describe("DurationLabel", () => {
it("renders minutes", () => {
render(<DurationLabel minutes={45} />);
expect(screen.getByText("~45min")).toBeInTheDocument();
});
it("renders hours", () => {
render(<DurationLabel minutes={60} />);
expect(screen.getByText("~1h")).toBeInTheDocument();
});
it("renders nothing for null", () => {
const { container } = render(<DurationLabel minutes={null} />);
expect(container.innerHTML).toBe("");
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import BlindboxPlan from "./BlindboxPlan";
import type { WeekendPlanData } from "@/types";
beforeEach(() => {
Element.prototype.scrollTo = vi.fn();
});
vi.mock("@/components/BlindboxMyIdeas", () => ({
CategoryBadge: ({ category }: { category: string }) => (
<span data-testid="category-badge">{category}</span>
),
}));
const mockDays: WeekendPlanData[] = [
{
date: "周六",
summary: "轻松的一天",
items: [
{
time: "10:00",
activity: "去公园散步",
poi: "朝阳公园",
address: "朝阳区",
duration: 120,
lat: 39.9,
lng: 116.4,
reason: "空气好",
},
{
time: "14:00",
activity: "午餐",
poi: "海底捞",
address: "朝阳区",
duration: 90,
lat: 39.9,
lng: 116.4,
},
],
},
{
date: "周日",
summary: "文艺的一天",
items: [
{
time: "10:00",
activity: "参观博物馆",
poi: "国家博物馆",
address: "东城区",
duration: 180,
lat: 39.9,
lng: 116.4,
},
],
},
];
const mockOnAccept = vi.fn();
const mockOnRegenerate = vi.fn();
const mockOnShare = vi.fn();
const mockOnBack = vi.fn();
function renderPlan(overrides = {}) {
return render(
<BlindboxPlan
days={mockDays}
onAccept={mockOnAccept}
onRegenerate={mockOnRegenerate}
onShare={mockOnShare}
onBack={mockOnBack}
{...overrides}
/>,
);
}
describe("BlindboxPlan", () => {
it("renders day header with date", () => {
renderPlan();
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
});
it("renders summary text", () => {
renderPlan();
expect(screen.getByText("轻松的一天")).toBeInTheDocument();
});
it("renders timeline items", () => {
renderPlan();
expect(screen.getByText("去公园散步")).toBeInTheDocument();
expect(screen.getByText("朝阳公园")).toBeInTheDocument();
expect(screen.getByText("午餐")).toBeInTheDocument();
});
it("shows duration formatted", () => {
renderPlan();
expect(screen.getByText("2h")).toBeInTheDocument();
expect(screen.getByText("1h30min")).toBeInTheDocument();
});
it("shows accept and regenerate buttons when not accepted", () => {
renderPlan();
expect(screen.getByText("接受契约")).toBeInTheDocument();
expect(screen.getByText("换一个方案")).toBeInTheDocument();
});
it("shows share button when accepted", () => {
renderPlan({ accepted: true });
expect(screen.getByText("分享计划")).toBeInTheDocument();
expect(screen.queryByText("接受契约")).not.toBeInTheDocument();
});
it("calls onAccept", () => {
renderPlan();
fireEvent.click(screen.getByText("接受契约"));
expect(mockOnAccept).toHaveBeenCalled();
});
it("calls onRegenerate", () => {
renderPlan();
fireEvent.click(screen.getByText("换一个方案"));
expect(mockOnRegenerate).toHaveBeenCalled();
});
it("calls onBack", () => {
renderPlan();
fireEvent.click(screen.getByText("返回想法池"));
expect(mockOnBack).toHaveBeenCalled();
});
it("navigates between days", async () => {
renderPlan();
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
fireEvent.click(screen.getByText("周日"));
await waitFor(() => {
expect(screen.getByText(/周日 · 行程规划/)).toBeInTheDocument();
});
});
it("shows day indicators for multi-day plans", () => {
renderPlan();
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
it("shows navigation link for items with coordinates", () => {
renderPlan();
const navLinks = screen.getAllByText("导航");
expect(navLinks.length).toBeGreaterThan(0);
});
it("shows reason when available", () => {
renderPlan();
expect(screen.getByText("空气好")).toBeInTheDocument();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Button from "./Button";
describe("Button", () => {
it("renders children text", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("renders with primary variant by default", () => {
render(<Button>Primary</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-accent");
});
it("renders secondary variant", () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-surface");
});
it("renders danger variant", () => {
render(<Button variant="danger">Danger</Button>);
const button = screen.getByRole("button");
expect(button.className).toContain("bg-rose-600");
});
it("shows loading spinner and disables button", () => {
render(<Button loading>Loading</Button>);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button.querySelector(".animate-spin")).toBeTruthy();
});
it("shows loadingText when loading", () => {
render(
<Button loading loadingText="请稍候...">
Submit
</Button>,
);
expect(screen.getByText("请稍候...")).toBeInTheDocument();
});
it("disables button when disabled prop is set", () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("applies full width class", () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole("button").className).toContain("w-full");
});
it("applies pill shape", () => {
render(<Button shape="pill">Pill</Button>);
expect(screen.getByRole("button").className).toContain("rounded-full");
});
it("applies size classes", () => {
const { rerender } = render(<Button size="sm">Small</Button>);
expect(screen.getByRole("button").className).toContain("h-8");
rerender(<Button size="lg">Large</Button>);
expect(screen.getByRole("button").className).toContain("h-11");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Package } from "lucide-react";
import EmptyState from "./EmptyState";
describe("EmptyState", () => {
it("renders title", () => {
render(<EmptyState icon={Package} title="没有数据" />);
expect(screen.getByText("没有数据")).toBeInTheDocument();
});
it("renders subtitle when provided", () => {
render(
<EmptyState icon={Package} title="空" subtitle="还没有任何内容" />,
);
expect(screen.getByText("还没有任何内容")).toBeInTheDocument();
});
it("does not render subtitle when not provided", () => {
const { container } = render(<EmptyState icon={Package} title="空" />);
expect(container.querySelectorAll("p")).toHaveLength(1);
});
it("renders CTA button when ctaLabel and onCta provided", () => {
const onCta = vi.fn();
render(
<EmptyState icon={Package} title="空" ctaLabel="添加" onCta={onCta} />,
);
const button = screen.getByText("添加");
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onCta).toHaveBeenCalled();
});
it("does not render CTA without both ctaLabel and onCta", () => {
render(<EmptyState icon={Package} title="空" ctaLabel="添加" />);
expect(screen.queryByText("添加")).not.toBeInTheDocument();
});
it("renders image when provided", () => {
render(
<EmptyState icon={Package} title="空" image="/test-image.png" />,
);
expect(screen.getByAltText("空")).toBeInTheDocument();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Input from "./Input";
describe("Input", () => {
it("renders an input element", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("accepts user input", async () => {
const user = userEvent.setup();
render(<Input placeholder="Type here" />);
const input = screen.getByPlaceholderText("Type here");
await user.type(input, "hello");
expect(input).toHaveValue("hello");
});
it("applies size variants", () => {
const { rerender } = render(<Input size="sm" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-8");
rerender(<Input size="lg" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-10");
rerender(<Input size="xl" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("h-11");
});
it("applies purple variant", () => {
render(<Input variant="purple" data-testid="input" />);
expect(screen.getByTestId("input").className).toContain("focus:ring-purple-600");
});
it("forwards ref", () => {
const ref = { current: null as HTMLInputElement | null };
render(<Input ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
it("supports disabled state", () => {
render(<Input disabled data-testid="input" />);
expect(screen.getByTestId("input")).toBeDisabled();
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures";
import type { ReactNode } from "react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/celebrate", () => ({
fireCelebration: vi.fn(),
playChime: vi.fn(),
}));
vi.mock("@/lib/userId", () => ({
isRegistered: vi.fn().mockReturnValue(true),
}));
vi.mock("./ShareCardModal", () => ({
default: () => null,
}));
vi.mock("./RestaurantImage", () => ({
default: ({ alt }: { alt: string }) => <img alt={alt} />,
}));
vi.mock("./AuthModal", () => ({
default: () => null,
}));
vi.mock("./NoMatchResult", () => ({
default: ({ onReset }: { onReset: () => void }) => (
<div data-testid="no-match">
<button onClick={onReset}></button>
</div>
),
}));
vi.mock("./RunnerUpCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="runner-up">{restaurant.name}</div>
),
}));
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
vi.stubGlobal("fetch", mockFetch);
import MatchResult from "./MatchResult";
const toastCtx: ToastContextValue = { show: vi.fn() };
function Wrapper({ children }: { children: ReactNode }) {
return React.createElement(
ToastContext.Provider,
{ value: toastCtx },
children,
);
}
const baseProps = {
restaurant: TEST_RESTAURANT,
matchLikes: 2,
runnerUps: [],
allRestaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2],
userCount: 2,
roomId: "ROOM01",
userId: "user-1",
onReset: vi.fn().mockResolvedValue(undefined),
onNarrow: vi.fn().mockResolvedValue(undefined),
resetting: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("MatchResult", () => {
it("renders unanimous match display", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("就去这了")).toBeInTheDocument();
expect(screen.getByText("大家一拍即合!")).toBeInTheDocument();
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
});
it("renders best match display", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="best" />
</Wrapper>,
);
expect(screen.getByText("就去这了")).toBeInTheDocument();
expect(screen.getByText("2/2 人想去这家")).toBeInTheDocument();
});
it("renders no_match by delegating to NoMatchResult", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="no_match" />
</Wrapper>,
);
expect(screen.getByTestId("no-match")).toBeInTheDocument();
});
it("shows navigation button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("导航过去")).toBeInTheDocument();
});
it("shows phone button when tel available", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("打电话订位")).toBeInTheDocument();
});
it("shows share button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="best" />
</Wrapper>,
);
expect(screen.getByText("生成分享卡片")).toBeInTheDocument();
});
it("shows solo message when userCount is 1", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" userCount={1} />
</Wrapper>,
);
expect(screen.getByText("帮你选好了")).toBeInTheDocument();
expect(screen.getByText("你的首选,别犹豫了")).toBeInTheDocument();
});
it("shows reset button", () => {
render(
<Wrapper>
<MatchResult {...baseProps} matchType="unanimous" />
</Wrapper>,
);
expect(screen.getByText("再来一轮")).toBeInTheDocument();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Modal from "./Modal";
describe("Modal", () => {
it("renders children when open", () => {
render(
<Modal open onClose={() => {}}>
<p>Modal content</p>
</Modal>,
);
expect(screen.getByText("Modal content")).toBeInTheDocument();
});
it("does not render when closed", () => {
render(
<Modal open={false} onClose={() => {}}>
<p>Hidden content</p>
</Modal>,
);
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
});
it("calls onClose when clicking backdrop", () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose}>
<p>Content</p>
</Modal>,
);
const backdrop = screen.getByText("Content").closest("[class*='fixed']");
if (backdrop) {
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalled();
}
});
it("does not close when clicking content", () => {
const onClose = vi.fn();
render(
<Modal open onClose={onClose}>
<p>Content</p>
</Modal>,
);
fireEvent.click(screen.getByText("Content"));
expect(onClose).not.toHaveBeenCalled();
});
it("applies sheet variant by default", () => {
render(
<Modal open onClose={() => {}}>
<p>Sheet</p>
</Modal>,
);
const content = screen.getByText("Sheet").closest("div[class*='rounded']");
expect(content?.className).toContain("rounded-t-3xl");
});
it("applies dialog variant", () => {
render(
<Modal open onClose={() => {}} variant="dialog">
<p>Dialog</p>
</Modal>,
);
const content = screen.getByText("Dialog").closest("div[class*='rounded']");
expect(content?.className).toContain("rounded-2xl");
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
Skeleton,
SkeletonCircle,
RoomCardSkeleton,
ProfileCardSkeleton,
RecordItemSkeleton,
SwipeDeckSkeleton,
BlindboxRoomSkeleton,
BlindboxListSkeleton,
} from "./Skeleton";
describe("Skeleton", () => {
it("renders with animate-pulse", () => {
const { container } = render(<Skeleton />);
expect(container.firstChild).toHaveClass("animate-pulse");
});
it("accepts custom className", () => {
const { container } = render(<Skeleton className="h-4 w-20" />);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain("h-4");
expect(el.className).toContain("w-20");
});
});
describe("SkeletonCircle", () => {
it("renders rounded-full shape", () => {
const { container } = render(<SkeletonCircle />);
expect(container.firstChild).toHaveClass("rounded-full");
});
});
describe("Skeleton composites", () => {
it("renders RoomCardSkeleton", () => {
const { container } = render(<RoomCardSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders ProfileCardSkeleton", () => {
const { container } = render(<ProfileCardSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders RecordItemSkeleton", () => {
const { container } = render(<RecordItemSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders SwipeDeckSkeleton", () => {
const { container } = render(<SwipeDeckSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders BlindboxRoomSkeleton", () => {
const { container } = render(<BlindboxRoomSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("renders BlindboxListSkeleton", () => {
const { container } = render(<BlindboxListSkeleton />);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SwipeDeck from "./SwipeDeck";
import {
TEST_RESTAURANT,
TEST_RESTAURANT_2,
TEST_RESTAURANT_3,
} from "@/__tests__/helpers/fixtures";
vi.mock("./SwipeableCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="swipeable-card">{restaurant.name}</div>
),
}));
vi.mock("./ActionButtons", () => ({
default: ({ disabled }: { disabled: boolean }) => (
<div data-testid="action-buttons" data-disabled={disabled} />
),
}));
vi.mock("./MatchResult", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="match-result">{restaurant.name}</div>
),
}));
vi.mock("./SwipeGuide", () => ({
default: () => <div data-testid="swipe-guide" />,
}));
vi.mock("./UserAvatar", () => ({
default: () => <span data-testid="user-avatar" />,
}));
const restaurants = [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3];
const defaultProps = {
restaurants,
roomId: "ROOM01",
userId: "user-1",
initialIndex: 0,
matchedRestaurantId: null,
matchType: null as const,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
userCount: 2,
userProfiles: {},
onReset: vi.fn().mockResolvedValue(undefined),
onNarrow: vi.fn().mockResolvedValue(undefined),
};
describe("SwipeDeck", () => {
it("renders restaurant cards at initial index", () => {
render(<SwipeDeck {...defaultProps} />);
const cards = screen.getAllByTestId("swipeable-card");
expect(cards.length).toBeGreaterThan(0);
});
it("renders action buttons", () => {
render(<SwipeDeck {...defaultProps} />);
expect(screen.getByTestId("action-buttons")).toBeInTheDocument();
});
it("shows swipe guide at index 0", () => {
render(<SwipeDeck {...defaultProps} />);
expect(screen.getByTestId("swipe-guide")).toBeInTheDocument();
});
it("does not show swipe guide when initialIndex > 0", () => {
render(<SwipeDeck {...defaultProps} initialIndex={1} />);
expect(screen.queryByTestId("swipe-guide")).not.toBeInTheDocument();
});
it("shows match result when matchedRestaurantId is set", () => {
render(
<SwipeDeck
{...defaultProps}
matchedRestaurantId={TEST_RESTAURANT.id}
matchType="unanimous"
matchLikes={2}
/>,
);
expect(screen.getByTestId("match-result")).toBeInTheDocument();
});
it("disables action buttons when match exists", () => {
render(
<SwipeDeck
{...defaultProps}
matchedRestaurantId={TEST_RESTAURANT.id}
matchType="unanimous"
/>,
);
const actions = screen.getByTestId("action-buttons");
expect(actions.getAttribute("data-disabled")).toBe("true");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SwipeableCard from "./SwipeableCard";
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("./RestaurantCard", () => ({
default: ({ restaurant }: { restaurant: { name: string } }) => (
<div data-testid="restaurant-card">{restaurant.name}</div>
),
}));
describe("SwipeableCard", () => {
it("renders restaurant card", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
});
it("renders swipe overlays (LIKE and NOPE)", () => {
const { container } = render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(container.textContent).toContain("LIKE");
expect(container.textContent).toContain("NOPE");
});
it("calls registerSwipe when provided and isTop", () => {
const registerSwipe = vi.fn();
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
registerSwipe={registerSwipe}
likeCount={0}
/>,
);
expect(registerSwipe).toHaveBeenCalledWith(expect.any(Function));
});
it("does not pass registerSwipe for non-top cards", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop={false}
onSwipe={() => {}}
likeCount={0}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
});
it("displays like count", () => {
render(
<SwipeableCard
restaurant={TEST_RESTAURANT}
isTop
onSwipe={() => {}}
likeCount={3}
/>,
);
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Toast from "./Toast";
describe("Toast", () => {
it("renders message when provided", () => {
render(<Toast message="操作成功" />);
expect(screen.getByText("操作成功")).toBeInTheDocument();
});
it("does not render when message is empty", () => {
const { container } = render(<Toast message="" />);
expect(container.textContent).toBe("");
});
it("applies top position class by default", () => {
render(<Toast message="test" />);
const el = screen.getByText("test").closest("div[class*='fixed']");
expect(el?.className).toContain("top-10");
});
it("applies bottom position class", () => {
render(<Toast message="test" position="bottom" />);
const el = screen.getByText("test").closest("div[class*='fixed']");
expect(el?.className).toContain("bottom-8");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import TopNav from "./TopNav";
vi.mock("./QrInviteModal", () => ({
default: ({ open }: { open: boolean }) =>
open ? <div data-testid="qr-modal">QR Modal</div> : null,
}));
vi.mock("./RoomManageModal", () => ({
default: ({ open }: { open: boolean }) =>
open ? <div data-testid="manage-modal">Manage Modal</div> : null,
}));
describe("TopNav", () => {
it("renders room ID in invite button", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.getByText(/ROOM01/)).toBeInTheDocument();
});
it("renders exit button", () => {
const onExit = vi.fn();
render(<TopNav roomId="ROOM01" onExit={onExit} />);
const exitBtn = screen.getByLabelText("退出房间");
fireEvent.click(exitBtn);
expect(onExit).toHaveBeenCalled();
});
it("shows manage button only for creator", () => {
const { rerender } = render(<TopNav roomId="ROOM01" isCreator={false} />);
expect(screen.queryByText("管理")).not.toBeInTheDocument();
rerender(<TopNav roomId="ROOM01" isCreator />);
expect(screen.getByText("管理")).toBeInTheDocument();
});
it("opens QR invite modal on button click", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.queryByTestId("qr-modal")).not.toBeInTheDocument();
fireEvent.click(screen.getByText(/ROOM01/));
expect(screen.getByTestId("qr-modal")).toBeInTheDocument();
});
it("opens manage modal on manage button click", () => {
render(<TopNav roomId="ROOM01" isCreator />);
expect(screen.queryByTestId("manage-modal")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("管理"));
expect(screen.getByTestId("manage-modal")).toBeInTheDocument();
});
it("renders brand title", () => {
render(<TopNav roomId="ROOM01" />);
expect(screen.getByText("NoWhatever")).toBeInTheDocument();
expect(screen.getByText("别说随便")).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import WeekendTimeSelector from "./WeekendTimeSelector";
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
function renderSelector(props = {}) {
return render(
<WeekendTimeSelector
onConfirm={mockOnConfirm}
onClose={mockOnClose}
{...props}
/>,
);
}
describe("WeekendTimeSelector", () => {
it("renders time presets", () => {
renderSelector();
expect(screen.getByText("周六全天")).toBeInTheDocument();
expect(screen.getByText("周日全天")).toBeInTheDocument();
expect(screen.getByText("整个周末")).toBeInTheDocument();
});
it("renders hour selectors", () => {
renderSelector();
const selects = screen.getAllByRole("combobox");
expect(selects).toHaveLength(2);
});
it("shows confirm button", () => {
renderSelector();
expect(screen.getByText("生成周末计划")).toBeInTheDocument();
});
it("calls onConfirm with default config", () => {
renderSelector();
fireEvent.click(screen.getByText("生成周末计划"));
expect(mockOnConfirm).toHaveBeenCalledWith({
date: "周六",
startHour: 10,
endHour: 21,
});
});
it("calls onClose when close button clicked", () => {
renderSelector();
fireEvent.click(screen.getByLabelText("关闭"));
expect(mockOnClose).toHaveBeenCalled();
});
it("switches preset on click", () => {
renderSelector();
fireEvent.click(screen.getByText("周日全天"));
fireEvent.click(screen.getByText("生成周末计划"));
expect(mockOnConfirm).toHaveBeenCalledWith({
date: "周日",
startHour: 10,
endHour: 21,
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
const mockGetCurrentPosition = vi.fn();
const mockFetch = vi.fn();
vi.stubGlobal("navigator", {
geolocation: {
getCurrentPosition: mockGetCurrentPosition,
},
});
vi.stubGlobal("fetch", mockFetch);
import { useGeolocation } from "./useGeolocation";
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ name: "黄浦区", formatted: "上海市黄浦区" }),
});
});
describe("useGeolocation", () => {
it("returns success with coords on successful geolocation", async () => {
mockGetCurrentPosition.mockImplementation((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("success");
});
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
});
it("reverse geocodes after getting coords", async () => {
mockGetCurrentPosition.mockImplementation((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.locationName).toBe("黄浦区");
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/location/regeo"),
);
});
it("sets denied status on permission denied", async () => {
mockGetCurrentPosition.mockImplementation((_success, error) => {
error({ code: 1, PERMISSION_DENIED: 1 });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("denied");
});
expect(result.current.coords).toBeNull();
});
it("sets failed status on timeout", async () => {
mockGetCurrentPosition.mockImplementation((_success, error) => {
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("failed");
});
});
it("provides retry function", async () => {
mockGetCurrentPosition
.mockImplementationOnce((_success, error) => {
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
})
.mockImplementationOnce((success) => {
success({ coords: { latitude: 31.23, longitude: 121.47 } });
});
const { result } = renderHook(() => useGeolocation());
await waitFor(() => {
expect(result.current.status).toBe("failed");
});
await act(async () => {
await result.current.retry();
});
expect(result.current.status).toBe("success");
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType<typeof vi.fn> }[] = [];
class MockEventSource {
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onopen: (() => void) | null = null;
close = vi.fn();
constructor(_url: string) {
esInstances.push(this);
}
}
vi.stubGlobal("EventSource", MockEventSource);
vi.mock("swr", () => ({
default: vi.fn().mockReturnValue({
data: {
roomId: "ROOM01",
userCount: 2,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [],
creatorId: "user-1",
locked: false,
users: ["user-1", "user-2"],
userProfiles: {},
scene: "eat",
},
error: null,
isLoading: false,
mutate: vi.fn(),
}),
}));
import { useRoomPolling } from "./useRoomPolling";
beforeEach(() => {
esInstances = [];
});
afterEach(() => {
vi.clearAllMocks();
});
describe("useRoomPolling", () => {
it("returns room data from SWR", () => {
const { result } = renderHook(() => useRoomPolling("ROOM01"));
expect(result.current.userCount).toBe(2);
expect(result.current.users).toEqual(["user-1", "user-2"]);
expect(result.current.scene).toBe("eat");
});
it("creates EventSource connection", () => {
renderHook(() => useRoomPolling("ROOM01"));
expect(esInstances.length).toBeGreaterThan(0);
});
it("returns defaults when no roomId", () => {
const { result } = renderHook(() => useRoomPolling(undefined));
expect(result.current.userCount).toBe(2);
});
it("returns notFound from error state", async () => {
const useSWR = vi.mocked((await import("swr")).default);
useSWR.mockReturnValue({
data: undefined,
error: new Error("NOT_FOUND"),
isLoading: false,
mutate: vi.fn(),
} as never);
const { result } = renderHook(() => useRoomPolling("ROOM01"));
expect(result.current.notFound).toBe(true);
});
it("cleans up EventSource on unmount", () => {
const { unmount } = renderHook(() => useRoomPolling("ROOM01"));
const es = esInstances[esInstances.length - 1];
unmount();
expect(es.close).toHaveBeenCalled();
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { type ReactNode } from "react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
import { useShare } from "./useShare";
const mockShow = vi.fn();
const ctxValue: ToastContextValue = { show: mockShow };
function wrapper({ children }: { children: ReactNode }) {
return React.createElement(
ToastContext.Provider,
{ value: ctxValue },
children,
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useShare", () => {
describe("copyToClipboard", () => {
it("copies text and shows success toast", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello");
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
expect(mockShow).toHaveBeenCalledWith("已复制");
});
it("uses custom success message", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello", "复制成功");
});
expect(mockShow).toHaveBeenCalledWith("复制成功");
});
it("shows error toast on clipboard failure", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) },
});
const { result } = renderHook(() => useShare(), { wrapper });
await act(async () => {
await result.current.copyToClipboard("hello");
});
expect(mockShow).toHaveBeenCalledWith("复制失败,请手动复制");
});
});
describe("share", () => {
it("uses native share when available", async () => {
const mockNativeShare = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
share: mockNativeShare,
canShare: vi.fn().mockReturnValue(true),
});
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test", text: "test" });
});
expect(shared).toBe(true);
expect(mockNativeShare).toHaveBeenCalled();
});
it("calls fallback when native share unavailable", async () => {
Object.assign(navigator, {
share: undefined,
canShare: undefined,
});
const fallback = vi.fn();
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test" }, fallback);
});
expect(shared).toBe(false);
expect(fallback).toHaveBeenCalled();
});
it("handles AbortError gracefully", async () => {
const abortError = new Error("User aborted");
abortError.name = "AbortError";
Object.assign(navigator, {
share: vi.fn().mockRejectedValue(abortError),
canShare: vi.fn().mockReturnValue(true),
});
const { result } = renderHook(() => useShare(), { wrapper });
let shared = false;
await act(async () => {
shared = await result.current.share({ title: "Test" });
});
expect(shared).toBe(true);
});
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { type ReactNode } from "react";
import { useToast, ToastContext, type ToastContextValue } from "./useToast";
describe("useToast", () => {
it("throws error when used outside provider", () => {
expect(() => {
renderHook(() => useToast());
}).toThrow("useToast must be used within ToastProvider");
});
it("returns context value when inside provider", () => {
const mockShow = () => {};
const ctxValue: ToastContextValue = { show: mockShow };
const wrapper = ({ children }: { children: ReactNode }) => (
<ToastContext.Provider value={ctxValue}>
{children}
</ToastContext.Provider>
);
const { result } = renderHook(() => useToast(), { wrapper });
expect(result.current.show).toBe(mockShow);
});
});
+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);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["src/__tests__/helpers/setup.ts"],
environmentMatchGlobs: [
["src/app/api/**/*.test.ts", "node"],
["src/lib/**/*.test.ts", "node"],
],
env: {
AMAP_API_KEY: "test-amap-key",
DEEPSEEK_API_KEY: "test-deepseek-key",
},
},
});