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