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
+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();
});
});
});