test: 添加完整测试套件(52 个文件,326 个用例)
基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、 前端 hooks、UI 组件和页面级集成测试。
This commit is contained in:
Generated
+2822
-1
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -6,7 +6,10 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
@@ -25,14 +28,22 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"msw": "^2.12.10",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"vitest-mock-extended": "^3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function createRequest(
|
||||||
|
url: string,
|
||||||
|
options: {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
} = {},
|
||||||
|
): NextRequest {
|
||||||
|
const { method = "GET", body, headers = {} } = options;
|
||||||
|
const init: RequestInit = { method, headers };
|
||||||
|
if (body !== undefined) {
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
(init.headers as Record<string, string>)["content-type"] = "application/json";
|
||||||
|
}
|
||||||
|
return new NextRequest(new URL(url, "http://localhost:3000"), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteContext(
|
||||||
|
params: Record<string, string>,
|
||||||
|
): { params: Promise<Record<string, string>> } {
|
||||||
|
return { params: Promise.resolve(params) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseJsonResponse(response: Response) {
|
||||||
|
const data = await response.json();
|
||||||
|
return { status: response.status, data };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Restaurant } from "@/types";
|
||||||
|
|
||||||
|
export const TEST_USER = {
|
||||||
|
id: "user-1",
|
||||||
|
username: "testuser",
|
||||||
|
avatar: "🐱",
|
||||||
|
email: null,
|
||||||
|
passwordHash: "$2a$10$hashedpassword",
|
||||||
|
preferences: "{}",
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_USER_2 = {
|
||||||
|
id: "user-2",
|
||||||
|
username: "testuser2",
|
||||||
|
avatar: "🐶",
|
||||||
|
email: null,
|
||||||
|
passwordHash: "$2a$10$hashedpassword2",
|
||||||
|
preferences: "{}",
|
||||||
|
createdAt: new Date("2025-01-02"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_RESTAURANT: Restaurant = {
|
||||||
|
id: "rest-1",
|
||||||
|
name: "测试餐厅",
|
||||||
|
rating: 4.5,
|
||||||
|
price: "¥80",
|
||||||
|
distance: "500m",
|
||||||
|
images: ["https://example.com/img.jpg"],
|
||||||
|
category: "中餐",
|
||||||
|
address: "测试地址",
|
||||||
|
openTime: "09:00-22:00",
|
||||||
|
tel: "021-12345678",
|
||||||
|
tag: "川菜",
|
||||||
|
location: "121.4,31.2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_RESTAURANT_2: Restaurant = {
|
||||||
|
id: "rest-2",
|
||||||
|
name: "测试餐厅2",
|
||||||
|
rating: 4.0,
|
||||||
|
price: "¥60",
|
||||||
|
distance: "800m",
|
||||||
|
images: ["https://example.com/img2.jpg"],
|
||||||
|
category: "日料",
|
||||||
|
address: "测试地址2",
|
||||||
|
openTime: "10:00-21:00",
|
||||||
|
tel: "021-87654321",
|
||||||
|
tag: "寿司",
|
||||||
|
location: "121.5,31.3",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_RESTAURANT_3: Restaurant = {
|
||||||
|
id: "rest-3",
|
||||||
|
name: "测试餐厅3",
|
||||||
|
rating: 3.5,
|
||||||
|
price: "¥120",
|
||||||
|
distance: "1200m",
|
||||||
|
images: ["https://example.com/img3.jpg"],
|
||||||
|
category: "西餐",
|
||||||
|
address: "测试地址3",
|
||||||
|
openTime: "11:00-23:00",
|
||||||
|
tel: "021-11111111",
|
||||||
|
tag: "牛排",
|
||||||
|
location: "121.6,31.4",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_ROOM_DATA = {
|
||||||
|
users: [TEST_USER.id, TEST_USER_2.id],
|
||||||
|
restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3],
|
||||||
|
likes: {} as Record<string, string[]>,
|
||||||
|
swipeCounts: {} as Record<string, number>,
|
||||||
|
match: null as string | null,
|
||||||
|
creatorId: TEST_USER.id,
|
||||||
|
locked: false,
|
||||||
|
kickedUsers: [] as string[],
|
||||||
|
scene: "eat" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_BLINDBOX_ROOM = {
|
||||||
|
id: "bb-room-1",
|
||||||
|
code: "ABC123",
|
||||||
|
name: "我们的周末",
|
||||||
|
creatorId: TEST_USER.id,
|
||||||
|
city: null,
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_BLINDBOX_IDEA = {
|
||||||
|
id: "idea-1",
|
||||||
|
roomId: "bb-room-1",
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
content: "去公园野餐",
|
||||||
|
status: "in_pool",
|
||||||
|
category: "outdoor",
|
||||||
|
timeSlot: "morning",
|
||||||
|
estimatedMinutes: 120,
|
||||||
|
outdoor: true,
|
||||||
|
searchQuery: "公园",
|
||||||
|
searchType: "category",
|
||||||
|
drawnById: null,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_WEEKEND_PLAN = {
|
||||||
|
id: "plan-1",
|
||||||
|
roomId: "bb-room-1",
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
status: "active",
|
||||||
|
planData: JSON.stringify({
|
||||||
|
days: [{
|
||||||
|
date: "周六",
|
||||||
|
items: [
|
||||||
|
{ time: "10:00", activity: "去公园", poi: "某公园", address: "xx路", lat: 31.2, lng: 121.4, duration: 120, reason: "天气好" },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
summary: "愉快的一天",
|
||||||
|
}),
|
||||||
|
endTime: null,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prismaMock = mockDeep<PrismaClient>();
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function resetPrismaMock() {
|
||||||
|
mockReset(prismaMock);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import AuthModal from "./AuthModal";
|
||||||
|
|
||||||
|
vi.mock("@/lib/userId", () => ({
|
||||||
|
setCachedProfile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
const mockOnAuth = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset localStorage
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderModal(props = {}) {
|
||||||
|
return render(
|
||||||
|
<AuthModal open onClose={mockOnClose} onAuth={mockOnAuth} {...props} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AuthModal", () => {
|
||||||
|
it("renders login tab by default", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getAllByText("登录").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("注册").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders register tab when specified", () => {
|
||||||
|
renderModal({ defaultTab: "register" });
|
||||||
|
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("确认密码")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches between login and register tabs", async () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("注册"));
|
||||||
|
expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("确认密码")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when username is empty", async () => {
|
||||||
|
renderModal();
|
||||||
|
const submitBtn = screen.getAllByText("登录").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("请输入用户名")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when password is empty", async () => {
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
|
||||||
|
const submitBtn = screen.getAllByText("登录").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("请输入密码")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows register-specific validation errors", async () => {
|
||||||
|
renderModal({ defaultTab: "register" });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("2-16 个字符"), "a");
|
||||||
|
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
|
||||||
|
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText("注册").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("用户名需要 2-16 个字符")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows password mismatch error during registration", async () => {
|
||||||
|
renderModal({ defaultTab: "register" });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("2-16 个字符"), "testuser");
|
||||||
|
await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456");
|
||||||
|
await user.type(screen.getByPlaceholderText("再次输入密码"), "654321");
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText("注册").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("两次密码不一致")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits login successfully", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: "u1", username: "testuser", avatar: "🐱" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText("登录").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnAuth).toHaveBeenCalledWith({
|
||||||
|
id: "u1",
|
||||||
|
username: "testuser",
|
||||||
|
avatar: "🐱",
|
||||||
|
});
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows API error on failed login", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "用户名或密码错误" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入密码"), "wrong");
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText("登录").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("用户名或密码错误")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows network error on fetch failure", async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error("network"));
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser");
|
||||||
|
await user.type(screen.getByPlaceholderText("请输入密码"), "password123");
|
||||||
|
|
||||||
|
const submitBtn = screen.getAllByText("登录").find(
|
||||||
|
(el) => el.closest("button[class*='bg-accent']"),
|
||||||
|
)!;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("网络错误,请重试")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles password visibility", () => {
|
||||||
|
renderModal();
|
||||||
|
const toggle = screen.getByLabelText("显示密码");
|
||||||
|
const passwordInput = screen.getByPlaceholderText("请输入密码");
|
||||||
|
|
||||||
|
expect(passwordInput).toHaveAttribute("type", "password");
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(passwordInput).toHaveAttribute("type", "text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when closed", () => {
|
||||||
|
render(
|
||||||
|
<AuthModal open={false} onClose={mockOnClose} onAuth={mockOnAuth} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("欢迎")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders avatar selection in register mode", () => {
|
||||||
|
renderModal({ defaultTab: "register" });
|
||||||
|
expect(screen.getByText("选择头像")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("🐱")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import BlindboxDrawnHistory, { type DrawnIdea } from "./BlindboxDrawnHistory";
|
||||||
|
|
||||||
|
const mockItems: DrawnIdea[] = [
|
||||||
|
{
|
||||||
|
id: "drawn-1",
|
||||||
|
content: "去公园野餐",
|
||||||
|
createdAt: "2025-03-01T10:00:00Z",
|
||||||
|
user: { id: "u1", username: "小明", avatar: "🐱" },
|
||||||
|
drawnBy: { id: "u2", username: "小红", avatar: "🐶" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "drawn-2",
|
||||||
|
content: "看展览",
|
||||||
|
createdAt: "2025-03-02T10:00:00Z",
|
||||||
|
user: { id: "u2", username: "小红", avatar: "🐶" },
|
||||||
|
drawnBy: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("BlindboxDrawnHistory", () => {
|
||||||
|
it("renders nothing when items are empty", () => {
|
||||||
|
const { container } = render(<BlindboxDrawnHistory items={[]} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders history title and items", () => {
|
||||||
|
render(<BlindboxDrawnHistory items={mockItems} />);
|
||||||
|
expect(screen.getByText("履约记录")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("去公园野餐")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("看展览")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows user who contributed and who drew", () => {
|
||||||
|
render(<BlindboxDrawnHistory items={mockItems} />);
|
||||||
|
expect(screen.getByText(/小明 投入/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/小红 抽中/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders formatted date", () => {
|
||||||
|
render(<BlindboxDrawnHistory items={mockItems} />);
|
||||||
|
const dateElements = screen.getAllByText(/月/);
|
||||||
|
expect(dateElements.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import BlindboxMyIdeas, { type MyIdea, CategoryBadge, DurationLabel } from "./BlindboxMyIdeas";
|
||||||
|
|
||||||
|
const mockIdeas: MyIdea[] = [
|
||||||
|
{
|
||||||
|
id: "idea-1",
|
||||||
|
content: "去爬山",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
category: "outdoor",
|
||||||
|
estimatedMinutes: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "idea-2",
|
||||||
|
content: "看电影",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
category: "entertainment",
|
||||||
|
estimatedMinutes: 150,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockOnEdit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockOnDelete = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
describe("BlindboxMyIdeas", () => {
|
||||||
|
it("renders idea list with count", () => {
|
||||||
|
render(
|
||||||
|
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/我投入的想法(2)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("去爬山")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("看电影")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows duration labels", () => {
|
||||||
|
render(
|
||||||
|
<BlindboxMyIdeas ideas={mockIdeas} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("~2h")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("~2.5h")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty list", () => {
|
||||||
|
render(
|
||||||
|
<BlindboxMyIdeas ideas={[]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/我投入的想法(0)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters edit mode on pencil button click", async () => {
|
||||||
|
render(
|
||||||
|
<BlindboxMyIdeas ideas={[mockIdeas[0]]} onEdit={mockOnEdit} onDelete={mockOnDelete} />,
|
||||||
|
);
|
||||||
|
const editButtons = screen.getAllByRole("button");
|
||||||
|
const pencilBtn = editButtons.find((b) => b.querySelector("svg"));
|
||||||
|
fireEvent.click(pencilBtn!);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CategoryBadge", () => {
|
||||||
|
it("renders icon for known category", () => {
|
||||||
|
const { container } = render(<CategoryBadge category="dining" />);
|
||||||
|
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fallback for unknown category", () => {
|
||||||
|
const { container } = render(<CategoryBadge category="unknown" />);
|
||||||
|
expect(container.textContent).toBe("💡");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fallback for null category", () => {
|
||||||
|
const { container } = render(<CategoryBadge category={null} />);
|
||||||
|
expect(container.textContent).toBe("💡");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DurationLabel", () => {
|
||||||
|
it("renders minutes", () => {
|
||||||
|
render(<DurationLabel minutes={45} />);
|
||||||
|
expect(screen.getByText("~45min")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hours", () => {
|
||||||
|
render(<DurationLabel minutes={60} />);
|
||||||
|
expect(screen.getByText("~1h")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing for null", () => {
|
||||||
|
const { container } = render(<DurationLabel minutes={null} />);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import BlindboxPlan from "./BlindboxPlan";
|
||||||
|
import type { WeekendPlanData } from "@/types";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Element.prototype.scrollTo = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/components/BlindboxMyIdeas", () => ({
|
||||||
|
CategoryBadge: ({ category }: { category: string }) => (
|
||||||
|
<span data-testid="category-badge">{category}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDays: WeekendPlanData[] = [
|
||||||
|
{
|
||||||
|
date: "周六",
|
||||||
|
summary: "轻松的一天",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
time: "10:00",
|
||||||
|
activity: "去公园散步",
|
||||||
|
poi: "朝阳公园",
|
||||||
|
address: "朝阳区",
|
||||||
|
duration: 120,
|
||||||
|
lat: 39.9,
|
||||||
|
lng: 116.4,
|
||||||
|
reason: "空气好",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: "14:00",
|
||||||
|
activity: "午餐",
|
||||||
|
poi: "海底捞",
|
||||||
|
address: "朝阳区",
|
||||||
|
duration: 90,
|
||||||
|
lat: 39.9,
|
||||||
|
lng: 116.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "周日",
|
||||||
|
summary: "文艺的一天",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
time: "10:00",
|
||||||
|
activity: "参观博物馆",
|
||||||
|
poi: "国家博物馆",
|
||||||
|
address: "东城区",
|
||||||
|
duration: 180,
|
||||||
|
lat: 39.9,
|
||||||
|
lng: 116.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockOnAccept = vi.fn();
|
||||||
|
const mockOnRegenerate = vi.fn();
|
||||||
|
const mockOnShare = vi.fn();
|
||||||
|
const mockOnBack = vi.fn();
|
||||||
|
|
||||||
|
function renderPlan(overrides = {}) {
|
||||||
|
return render(
|
||||||
|
<BlindboxPlan
|
||||||
|
days={mockDays}
|
||||||
|
onAccept={mockOnAccept}
|
||||||
|
onRegenerate={mockOnRegenerate}
|
||||||
|
onShare={mockOnShare}
|
||||||
|
onBack={mockOnBack}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BlindboxPlan", () => {
|
||||||
|
it("renders day header with date", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders summary text", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("轻松的一天")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders timeline items", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("去公园散步")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("朝阳公园")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("午餐")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows duration formatted", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("2h")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1h30min")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows accept and regenerate buttons when not accepted", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("接受契约")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("换一个方案")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows share button when accepted", () => {
|
||||||
|
renderPlan({ accepted: true });
|
||||||
|
expect(screen.getByText("分享计划")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("接受契约")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onAccept", () => {
|
||||||
|
renderPlan();
|
||||||
|
fireEvent.click(screen.getByText("接受契约"));
|
||||||
|
expect(mockOnAccept).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRegenerate", () => {
|
||||||
|
renderPlan();
|
||||||
|
fireEvent.click(screen.getByText("换一个方案"));
|
||||||
|
expect(mockOnRegenerate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onBack", () => {
|
||||||
|
renderPlan();
|
||||||
|
fireEvent.click(screen.getByText("返回想法池"));
|
||||||
|
expect(mockOnBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates between days", async () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("周日"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/周日 · 行程规划/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows day indicators for multi-day plans", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("1 / 2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows navigation link for items with coordinates", () => {
|
||||||
|
renderPlan();
|
||||||
|
const navLinks = screen.getAllByText("导航");
|
||||||
|
expect(navLinks.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reason when available", () => {
|
||||||
|
renderPlan();
|
||||||
|
expect(screen.getByText("空气好")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders children text", () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByText("Click me")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with primary variant by default", () => {
|
||||||
|
render(<Button>Primary</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders secondary variant", () => {
|
||||||
|
render(<Button variant="secondary">Secondary</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-surface");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders danger variant", () => {
|
||||||
|
render(<Button variant="danger">Danger</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button.className).toContain("bg-rose-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading spinner and disables button", () => {
|
||||||
|
render(<Button loading>Loading</Button>);
|
||||||
|
const button = screen.getByRole("button");
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button.querySelector(".animate-spin")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loadingText when loading", () => {
|
||||||
|
render(
|
||||||
|
<Button loading loadingText="请稍候...">
|
||||||
|
Submit
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("请稍候...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables button when disabled prop is set", () => {
|
||||||
|
render(<Button disabled>Disabled</Button>);
|
||||||
|
expect(screen.getByRole("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies full width class", () => {
|
||||||
|
render(<Button fullWidth>Full Width</Button>);
|
||||||
|
expect(screen.getByRole("button").className).toContain("w-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies pill shape", () => {
|
||||||
|
render(<Button shape="pill">Pill</Button>);
|
||||||
|
expect(screen.getByRole("button").className).toContain("rounded-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies size classes", () => {
|
||||||
|
const { rerender } = render(<Button size="sm">Small</Button>);
|
||||||
|
expect(screen.getByRole("button").className).toContain("h-8");
|
||||||
|
|
||||||
|
rerender(<Button size="lg">Large</Button>);
|
||||||
|
expect(screen.getByRole("button").className).toContain("h-11");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import EmptyState from "./EmptyState";
|
||||||
|
|
||||||
|
describe("EmptyState", () => {
|
||||||
|
it("renders title", () => {
|
||||||
|
render(<EmptyState icon={Package} title="没有数据" />);
|
||||||
|
expect(screen.getByText("没有数据")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders subtitle when provided", () => {
|
||||||
|
render(
|
||||||
|
<EmptyState icon={Package} title="空" subtitle="还没有任何内容" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("还没有任何内容")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render subtitle when not provided", () => {
|
||||||
|
const { container } = render(<EmptyState icon={Package} title="空" />);
|
||||||
|
expect(container.querySelectorAll("p")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders CTA button when ctaLabel and onCta provided", () => {
|
||||||
|
const onCta = vi.fn();
|
||||||
|
render(
|
||||||
|
<EmptyState icon={Package} title="空" ctaLabel="添加" onCta={onCta} />,
|
||||||
|
);
|
||||||
|
const button = screen.getByText("添加");
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onCta).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render CTA without both ctaLabel and onCta", () => {
|
||||||
|
render(<EmptyState icon={Package} title="空" ctaLabel="添加" />);
|
||||||
|
expect(screen.queryByText("添加")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders image when provided", () => {
|
||||||
|
render(
|
||||||
|
<EmptyState icon={Package} title="空" image="/test-image.png" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByAltText("空")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import Input from "./Input";
|
||||||
|
|
||||||
|
describe("Input", () => {
|
||||||
|
it("renders an input element", () => {
|
||||||
|
render(<Input placeholder="Enter text" />);
|
||||||
|
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts user input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Input placeholder="Type here" />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Type here");
|
||||||
|
await user.type(input, "hello");
|
||||||
|
expect(input).toHaveValue("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies size variants", () => {
|
||||||
|
const { rerender } = render(<Input size="sm" data-testid="input" />);
|
||||||
|
expect(screen.getByTestId("input").className).toContain("h-8");
|
||||||
|
|
||||||
|
rerender(<Input size="lg" data-testid="input" />);
|
||||||
|
expect(screen.getByTestId("input").className).toContain("h-10");
|
||||||
|
|
||||||
|
rerender(<Input size="xl" data-testid="input" />);
|
||||||
|
expect(screen.getByTestId("input").className).toContain("h-11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies purple variant", () => {
|
||||||
|
render(<Input variant="purple" data-testid="input" />);
|
||||||
|
expect(screen.getByTestId("input").className).toContain("focus:ring-purple-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards ref", () => {
|
||||||
|
const ref = { current: null as HTMLInputElement | null };
|
||||||
|
render(<Input ref={ref} />);
|
||||||
|
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports disabled state", () => {
|
||||||
|
render(<Input disabled data-testid="input" />);
|
||||||
|
expect(screen.getByTestId("input")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/celebrate", () => ({
|
||||||
|
fireCelebration: vi.fn(),
|
||||||
|
playChime: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/userId", () => ({
|
||||||
|
isRegistered: vi.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ShareCardModal", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./RestaurantImage", () => ({
|
||||||
|
default: ({ alt }: { alt: string }) => <img alt={alt} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./AuthModal", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./NoMatchResult", () => ({
|
||||||
|
default: ({ onReset }: { onReset: () => void }) => (
|
||||||
|
<div data-testid="no-match">
|
||||||
|
<button onClick={onReset}>重来</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./RunnerUpCard", () => ({
|
||||||
|
default: ({ restaurant }: { restaurant: { name: string } }) => (
|
||||||
|
<div data-testid="runner-up">{restaurant.name}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
import MatchResult from "./MatchResult";
|
||||||
|
|
||||||
|
const toastCtx: ToastContextValue = { show: vi.fn() };
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return React.createElement(
|
||||||
|
ToastContext.Provider,
|
||||||
|
{ value: toastCtx },
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
restaurant: TEST_RESTAURANT,
|
||||||
|
matchLikes: 2,
|
||||||
|
runnerUps: [],
|
||||||
|
allRestaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2],
|
||||||
|
userCount: 2,
|
||||||
|
roomId: "ROOM01",
|
||||||
|
userId: "user-1",
|
||||||
|
onReset: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onNarrow: vi.fn().mockResolvedValue(undefined),
|
||||||
|
resetting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatchResult", () => {
|
||||||
|
it("renders unanimous match display", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="unanimous" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("就去这了")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("大家一拍即合!")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders best match display", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="best" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("就去这了")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2/2 人想去这家")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no_match by delegating to NoMatchResult", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="no_match" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("no-match")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows navigation button", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="unanimous" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("导航过去")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows phone button when tel available", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="unanimous" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("打电话订位")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows share button", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="best" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("生成分享卡片")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows solo message when userCount is 1", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="unanimous" userCount={1} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("帮你选好了")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("你的首选,别犹豫了")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows reset button", () => {
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<MatchResult {...baseProps} matchType="unanimous" />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("再来一轮")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
describe("Modal", () => {
|
||||||
|
it("renders children when open", () => {
|
||||||
|
render(
|
||||||
|
<Modal open onClose={() => {}}>
|
||||||
|
<p>Modal content</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Modal content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when closed", () => {
|
||||||
|
render(
|
||||||
|
<Modal open={false} onClose={() => {}}>
|
||||||
|
<p>Hidden content</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when clicking backdrop", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal open onClose={onClose}>
|
||||||
|
<p>Content</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrop = screen.getByText("Content").closest("[class*='fixed']");
|
||||||
|
if (backdrop) {
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not close when clicking content", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal open onClose={onClose}>
|
||||||
|
<p>Content</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Content"));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies sheet variant by default", () => {
|
||||||
|
render(
|
||||||
|
<Modal open onClose={() => {}}>
|
||||||
|
<p>Sheet</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const content = screen.getByText("Sheet").closest("div[class*='rounded']");
|
||||||
|
expect(content?.className).toContain("rounded-t-3xl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies dialog variant", () => {
|
||||||
|
render(
|
||||||
|
<Modal open onClose={() => {}} variant="dialog">
|
||||||
|
<p>Dialog</p>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const content = screen.getByText("Dialog").closest("div[class*='rounded']");
|
||||||
|
expect(content?.className).toContain("rounded-2xl");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonCircle,
|
||||||
|
RoomCardSkeleton,
|
||||||
|
ProfileCardSkeleton,
|
||||||
|
RecordItemSkeleton,
|
||||||
|
SwipeDeckSkeleton,
|
||||||
|
BlindboxRoomSkeleton,
|
||||||
|
BlindboxListSkeleton,
|
||||||
|
} from "./Skeleton";
|
||||||
|
|
||||||
|
describe("Skeleton", () => {
|
||||||
|
it("renders with animate-pulse", () => {
|
||||||
|
const { container } = render(<Skeleton />);
|
||||||
|
expect(container.firstChild).toHaveClass("animate-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts custom className", () => {
|
||||||
|
const { container } = render(<Skeleton className="h-4 w-20" />);
|
||||||
|
const el = container.firstChild as HTMLElement;
|
||||||
|
expect(el.className).toContain("h-4");
|
||||||
|
expect(el.className).toContain("w-20");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SkeletonCircle", () => {
|
||||||
|
it("renders rounded-full shape", () => {
|
||||||
|
const { container } = render(<SkeletonCircle />);
|
||||||
|
expect(container.firstChild).toHaveClass("rounded-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Skeleton composites", () => {
|
||||||
|
it("renders RoomCardSkeleton", () => {
|
||||||
|
const { container } = render(<RoomCardSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ProfileCardSkeleton", () => {
|
||||||
|
const { container } = render(<ProfileCardSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders RecordItemSkeleton", () => {
|
||||||
|
const { container } = render(<RecordItemSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders SwipeDeckSkeleton", () => {
|
||||||
|
const { container } = render(<SwipeDeckSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders BlindboxRoomSkeleton", () => {
|
||||||
|
const { container } = render(<BlindboxRoomSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders BlindboxListSkeleton", () => {
|
||||||
|
const { container } = render(<BlindboxListSkeleton />);
|
||||||
|
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import SwipeDeck from "./SwipeDeck";
|
||||||
|
import {
|
||||||
|
TEST_RESTAURANT,
|
||||||
|
TEST_RESTAURANT_2,
|
||||||
|
TEST_RESTAURANT_3,
|
||||||
|
} from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("./SwipeableCard", () => ({
|
||||||
|
default: ({ restaurant }: { restaurant: { name: string } }) => (
|
||||||
|
<div data-testid="swipeable-card">{restaurant.name}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./ActionButtons", () => ({
|
||||||
|
default: ({ disabled }: { disabled: boolean }) => (
|
||||||
|
<div data-testid="action-buttons" data-disabled={disabled} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./MatchResult", () => ({
|
||||||
|
default: ({ restaurant }: { restaurant: { name: string } }) => (
|
||||||
|
<div data-testid="match-result">{restaurant.name}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SwipeGuide", () => ({
|
||||||
|
default: () => <div data-testid="swipe-guide" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./UserAvatar", () => ({
|
||||||
|
default: () => <span data-testid="user-avatar" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const restaurants = [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
restaurants,
|
||||||
|
roomId: "ROOM01",
|
||||||
|
userId: "user-1",
|
||||||
|
initialIndex: 0,
|
||||||
|
matchedRestaurantId: null,
|
||||||
|
matchType: null as const,
|
||||||
|
matchLikes: 0,
|
||||||
|
runnerUps: [],
|
||||||
|
likeCounts: {},
|
||||||
|
swipeCounts: {},
|
||||||
|
userCount: 2,
|
||||||
|
userProfiles: {},
|
||||||
|
onReset: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onNarrow: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SwipeDeck", () => {
|
||||||
|
it("renders restaurant cards at initial index", () => {
|
||||||
|
render(<SwipeDeck {...defaultProps} />);
|
||||||
|
const cards = screen.getAllByTestId("swipeable-card");
|
||||||
|
expect(cards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders action buttons", () => {
|
||||||
|
render(<SwipeDeck {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId("action-buttons")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows swipe guide at index 0", () => {
|
||||||
|
render(<SwipeDeck {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId("swipe-guide")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show swipe guide when initialIndex > 0", () => {
|
||||||
|
render(<SwipeDeck {...defaultProps} initialIndex={1} />);
|
||||||
|
expect(screen.queryByTestId("swipe-guide")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows match result when matchedRestaurantId is set", () => {
|
||||||
|
render(
|
||||||
|
<SwipeDeck
|
||||||
|
{...defaultProps}
|
||||||
|
matchedRestaurantId={TEST_RESTAURANT.id}
|
||||||
|
matchType="unanimous"
|
||||||
|
matchLikes={2}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("match-result")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables action buttons when match exists", () => {
|
||||||
|
render(
|
||||||
|
<SwipeDeck
|
||||||
|
{...defaultProps}
|
||||||
|
matchedRestaurantId={TEST_RESTAURANT.id}
|
||||||
|
matchType="unanimous"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const actions = screen.getByTestId("action-buttons");
|
||||||
|
expect(actions.getAttribute("data-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import SwipeableCard from "./SwipeableCard";
|
||||||
|
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("./RestaurantCard", () => ({
|
||||||
|
default: ({ restaurant }: { restaurant: { name: string } }) => (
|
||||||
|
<div data-testid="restaurant-card">{restaurant.name}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("SwipeableCard", () => {
|
||||||
|
it("renders restaurant card", () => {
|
||||||
|
render(
|
||||||
|
<SwipeableCard
|
||||||
|
restaurant={TEST_RESTAURANT}
|
||||||
|
isTop
|
||||||
|
onSwipe={() => {}}
|
||||||
|
likeCount={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("测试餐厅")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders swipe overlays (LIKE and NOPE)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SwipeableCard
|
||||||
|
restaurant={TEST_RESTAURANT}
|
||||||
|
isTop
|
||||||
|
onSwipe={() => {}}
|
||||||
|
likeCount={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("LIKE");
|
||||||
|
expect(container.textContent).toContain("NOPE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls registerSwipe when provided and isTop", () => {
|
||||||
|
const registerSwipe = vi.fn();
|
||||||
|
render(
|
||||||
|
<SwipeableCard
|
||||||
|
restaurant={TEST_RESTAURANT}
|
||||||
|
isTop
|
||||||
|
onSwipe={() => {}}
|
||||||
|
registerSwipe={registerSwipe}
|
||||||
|
likeCount={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(registerSwipe).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pass registerSwipe for non-top cards", () => {
|
||||||
|
render(
|
||||||
|
<SwipeableCard
|
||||||
|
restaurant={TEST_RESTAURANT}
|
||||||
|
isTop={false}
|
||||||
|
onSwipe={() => {}}
|
||||||
|
likeCount={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays like count", () => {
|
||||||
|
render(
|
||||||
|
<SwipeableCard
|
||||||
|
restaurant={TEST_RESTAURANT}
|
||||||
|
isTop
|
||||||
|
onSwipe={() => {}}
|
||||||
|
likeCount={3}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("restaurant-card")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import Toast from "./Toast";
|
||||||
|
|
||||||
|
describe("Toast", () => {
|
||||||
|
it("renders message when provided", () => {
|
||||||
|
render(<Toast message="操作成功" />);
|
||||||
|
expect(screen.getByText("操作成功")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when message is empty", () => {
|
||||||
|
const { container } = render(<Toast message="" />);
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies top position class by default", () => {
|
||||||
|
render(<Toast message="test" />);
|
||||||
|
const el = screen.getByText("test").closest("div[class*='fixed']");
|
||||||
|
expect(el?.className).toContain("top-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies bottom position class", () => {
|
||||||
|
render(<Toast message="test" position="bottom" />);
|
||||||
|
const el = screen.getByText("test").closest("div[class*='fixed']");
|
||||||
|
expect(el?.className).toContain("bottom-8");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import TopNav from "./TopNav";
|
||||||
|
|
||||||
|
vi.mock("./QrInviteModal", () => ({
|
||||||
|
default: ({ open }: { open: boolean }) =>
|
||||||
|
open ? <div data-testid="qr-modal">QR Modal</div> : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./RoomManageModal", () => ({
|
||||||
|
default: ({ open }: { open: boolean }) =>
|
||||||
|
open ? <div data-testid="manage-modal">Manage Modal</div> : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("TopNav", () => {
|
||||||
|
it("renders room ID in invite button", () => {
|
||||||
|
render(<TopNav roomId="ROOM01" />);
|
||||||
|
expect(screen.getByText(/ROOM01/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders exit button", () => {
|
||||||
|
const onExit = vi.fn();
|
||||||
|
render(<TopNav roomId="ROOM01" onExit={onExit} />);
|
||||||
|
|
||||||
|
const exitBtn = screen.getByLabelText("退出房间");
|
||||||
|
fireEvent.click(exitBtn);
|
||||||
|
expect(onExit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows manage button only for creator", () => {
|
||||||
|
const { rerender } = render(<TopNav roomId="ROOM01" isCreator={false} />);
|
||||||
|
expect(screen.queryByText("管理")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<TopNav roomId="ROOM01" isCreator />);
|
||||||
|
expect(screen.getByText("管理")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens QR invite modal on button click", () => {
|
||||||
|
render(<TopNav roomId="ROOM01" />);
|
||||||
|
expect(screen.queryByTestId("qr-modal")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/ROOM01/));
|
||||||
|
expect(screen.getByTestId("qr-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens manage modal on manage button click", () => {
|
||||||
|
render(<TopNav roomId="ROOM01" isCreator />);
|
||||||
|
expect(screen.queryByTestId("manage-modal")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("管理"));
|
||||||
|
expect(screen.getByTestId("manage-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders brand title", () => {
|
||||||
|
render(<TopNav roomId="ROOM01" />);
|
||||||
|
expect(screen.getByText("NoWhatever")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("别说随便")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import WeekendTimeSelector from "./WeekendTimeSelector";
|
||||||
|
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
function renderSelector(props = {}) {
|
||||||
|
return render(
|
||||||
|
<WeekendTimeSelector
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WeekendTimeSelector", () => {
|
||||||
|
it("renders time presets", () => {
|
||||||
|
renderSelector();
|
||||||
|
expect(screen.getByText("周六全天")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("周日全天")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("整个周末")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hour selectors", () => {
|
||||||
|
renderSelector();
|
||||||
|
const selects = screen.getAllByRole("combobox");
|
||||||
|
expect(selects).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirm button", () => {
|
||||||
|
renderSelector();
|
||||||
|
expect(screen.getByText("生成周末计划")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onConfirm with default config", () => {
|
||||||
|
renderSelector();
|
||||||
|
fireEvent.click(screen.getByText("生成周末计划"));
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith({
|
||||||
|
date: "周六",
|
||||||
|
startHour: 10,
|
||||||
|
endHour: 21,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when close button clicked", () => {
|
||||||
|
renderSelector();
|
||||||
|
fireEvent.click(screen.getByLabelText("关闭"));
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches preset on click", () => {
|
||||||
|
renderSelector();
|
||||||
|
fireEvent.click(screen.getByText("周日全天"));
|
||||||
|
fireEvent.click(screen.getByText("生成周末计划"));
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith({
|
||||||
|
date: "周日",
|
||||||
|
startHour: 10,
|
||||||
|
endHour: 21,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
const mockGetCurrentPosition = vi.fn();
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal("navigator", {
|
||||||
|
geolocation: {
|
||||||
|
getCurrentPosition: mockGetCurrentPosition,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
import { useGeolocation } from "./useGeolocation";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ name: "黄浦区", formatted: "上海市黄浦区" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useGeolocation", () => {
|
||||||
|
it("returns success with coords on successful geolocation", async () => {
|
||||||
|
mockGetCurrentPosition.mockImplementation((success) => {
|
||||||
|
success({ coords: { latitude: 31.23, longitude: 121.47 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.status).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverse geocodes after getting coords", async () => {
|
||||||
|
mockGetCurrentPosition.mockImplementation((success) => {
|
||||||
|
success({ coords: { latitude: 31.23, longitude: 121.47 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.locationName).toBe("黄浦区");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/location/regeo"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets denied status on permission denied", async () => {
|
||||||
|
mockGetCurrentPosition.mockImplementation((_success, error) => {
|
||||||
|
error({ code: 1, PERMISSION_DENIED: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.status).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.coords).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets failed status on timeout", async () => {
|
||||||
|
mockGetCurrentPosition.mockImplementation((_success, error) => {
|
||||||
|
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.status).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides retry function", async () => {
|
||||||
|
mockGetCurrentPosition
|
||||||
|
.mockImplementationOnce((_success, error) => {
|
||||||
|
error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 });
|
||||||
|
})
|
||||||
|
.mockImplementationOnce((success) => {
|
||||||
|
success({ coords: { latitude: 31.23, longitude: 121.47 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useGeolocation());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.status).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.retry();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.status).toBe("success");
|
||||||
|
expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType<typeof vi.fn> }[] = [];
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
|
||||||
|
constructor(_url: string) {
|
||||||
|
esInstances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal("EventSource", MockEventSource);
|
||||||
|
|
||||||
|
vi.mock("swr", () => ({
|
||||||
|
default: vi.fn().mockReturnValue({
|
||||||
|
data: {
|
||||||
|
roomId: "ROOM01",
|
||||||
|
userCount: 2,
|
||||||
|
match: null,
|
||||||
|
matchType: null,
|
||||||
|
matchLikes: 0,
|
||||||
|
runnerUps: [],
|
||||||
|
likeCounts: {},
|
||||||
|
swipeCounts: {},
|
||||||
|
restaurants: [],
|
||||||
|
creatorId: "user-1",
|
||||||
|
locked: false,
|
||||||
|
users: ["user-1", "user-2"],
|
||||||
|
userProfiles: {},
|
||||||
|
scene: "eat",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
mutate: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useRoomPolling } from "./useRoomPolling";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
esInstances = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useRoomPolling", () => {
|
||||||
|
it("returns room data from SWR", () => {
|
||||||
|
const { result } = renderHook(() => useRoomPolling("ROOM01"));
|
||||||
|
|
||||||
|
expect(result.current.userCount).toBe(2);
|
||||||
|
expect(result.current.users).toEqual(["user-1", "user-2"]);
|
||||||
|
expect(result.current.scene).toBe("eat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates EventSource connection", () => {
|
||||||
|
renderHook(() => useRoomPolling("ROOM01"));
|
||||||
|
expect(esInstances.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults when no roomId", () => {
|
||||||
|
const { result } = renderHook(() => useRoomPolling(undefined));
|
||||||
|
expect(result.current.userCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns notFound from error state", async () => {
|
||||||
|
const useSWR = vi.mocked((await import("swr")).default);
|
||||||
|
useSWR.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
error: new Error("NOT_FOUND"),
|
||||||
|
isLoading: false,
|
||||||
|
mutate: vi.fn(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRoomPolling("ROOM01"));
|
||||||
|
expect(result.current.notFound).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up EventSource on unmount", () => {
|
||||||
|
const { unmount } = renderHook(() => useRoomPolling("ROOM01"));
|
||||||
|
const es = esInstances[esInstances.length - 1];
|
||||||
|
unmount();
|
||||||
|
expect(es.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
|
||||||
|
import { useShare } from "./useShare";
|
||||||
|
|
||||||
|
const mockShow = vi.fn();
|
||||||
|
const ctxValue: ToastContextValue = { show: mockShow };
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return React.createElement(
|
||||||
|
ToastContext.Provider,
|
||||||
|
{ value: ctxValue },
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useShare", () => {
|
||||||
|
describe("copyToClipboard", () => {
|
||||||
|
it("copies text and shows success toast", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copyToClipboard("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
|
||||||
|
expect(mockShow).toHaveBeenCalledWith("已复制");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom success message", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copyToClipboard("hello", "复制成功");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockShow).toHaveBeenCalledWith("复制成功");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast on clipboard failure", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.copyToClipboard("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockShow).toHaveBeenCalledWith("复制失败,请手动复制");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("share", () => {
|
||||||
|
it("uses native share when available", async () => {
|
||||||
|
const mockNativeShare = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.assign(navigator, {
|
||||||
|
share: mockNativeShare,
|
||||||
|
canShare: vi.fn().mockReturnValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
let shared = false;
|
||||||
|
await act(async () => {
|
||||||
|
shared = await result.current.share({ title: "Test", text: "test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shared).toBe(true);
|
||||||
|
expect(mockNativeShare).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls fallback when native share unavailable", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
share: undefined,
|
||||||
|
canShare: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = vi.fn();
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
let shared = false;
|
||||||
|
await act(async () => {
|
||||||
|
shared = await result.current.share({ title: "Test" }, fallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shared).toBe(false);
|
||||||
|
expect(fallback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles AbortError gracefully", async () => {
|
||||||
|
const abortError = new Error("User aborted");
|
||||||
|
abortError.name = "AbortError";
|
||||||
|
Object.assign(navigator, {
|
||||||
|
share: vi.fn().mockRejectedValue(abortError),
|
||||||
|
canShare: vi.fn().mockReturnValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShare(), { wrapper });
|
||||||
|
|
||||||
|
let shared = false;
|
||||||
|
await act(async () => {
|
||||||
|
shared = await result.current.share({ title: "Test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shared).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { useToast, ToastContext, type ToastContextValue } from "./useToast";
|
||||||
|
|
||||||
|
describe("useToast", () => {
|
||||||
|
it("throws error when used outside provider", () => {
|
||||||
|
expect(() => {
|
||||||
|
renderHook(() => useToast());
|
||||||
|
}).toThrow("useToast must be used within ToastProvider");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns context value when inside provider", () => {
|
||||||
|
const mockShow = () => {};
|
||||||
|
const ctxValue: ToastContextValue = { show: mockShow };
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ToastContext.Provider value={ctxValue}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useToast(), { wrapper });
|
||||||
|
expect(result.current.show).toBe(mockShow);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { ApiError, requireUserId, apiHandler } from "@/lib/api";
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { user: { findUnique: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ApiError", () => {
|
||||||
|
it("creates with default 400 status", () => {
|
||||||
|
const err = new ApiError("bad request");
|
||||||
|
expect(err.message).toBe("bad request");
|
||||||
|
expect(err.status).toBe(400);
|
||||||
|
expect(err.name).toBe("ApiError");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates with custom status", () => {
|
||||||
|
const err = new ApiError("not found", 404);
|
||||||
|
expect(err.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is instance of Error", () => {
|
||||||
|
expect(new ApiError("test")).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireUserId", () => {
|
||||||
|
it("returns userId when valid string", () => {
|
||||||
|
expect(requireUserId("user-123")).toBe("user-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws 401 for empty string", () => {
|
||||||
|
expect(() => requireUserId("")).toThrow(ApiError);
|
||||||
|
try {
|
||||||
|
requireUserId("");
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as ApiError).status).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws 401 for null/undefined", () => {
|
||||||
|
expect(() => requireUserId(null)).toThrow(ApiError);
|
||||||
|
expect(() => requireUserId(undefined)).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws 401 for non-string", () => {
|
||||||
|
expect(() => requireUserId(123)).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("apiHandler", () => {
|
||||||
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
|
it("passes through successful responses", async () => {
|
||||||
|
const handler = apiHandler(async () =>
|
||||||
|
NextResponse.json({ ok: true }),
|
||||||
|
);
|
||||||
|
const req = new NextRequest("http://localhost/api/test");
|
||||||
|
const res = await handler(req, mockCtx);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts ApiError to JSON response", async () => {
|
||||||
|
const handler = apiHandler(async () => {
|
||||||
|
throw new ApiError("not found", 404);
|
||||||
|
});
|
||||||
|
const req = new NextRequest("http://localhost/api/test");
|
||||||
|
const res = await handler(req, mockCtx);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(data.error).toBe("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Prisma P2002 unique constraint error", async () => {
|
||||||
|
const handler = apiHandler(async () => {
|
||||||
|
const err = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "5.0.0",
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
const req = new NextRequest("http://localhost/api/test");
|
||||||
|
const res = await handler(req, mockCtx);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(data.error).toBe("该记录已存在或值已被占用");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unknown errors as 500", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const handler = apiHandler(async () => {
|
||||||
|
throw new Error("unexpected");
|
||||||
|
});
|
||||||
|
const req = new NextRequest("http://localhost/api/test");
|
||||||
|
const res = await handler(req, mockCtx);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(data.error).toBe("操作失败");
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
TEST_USER,
|
||||||
|
TEST_USER_2,
|
||||||
|
TEST_RESTAURANT,
|
||||||
|
TEST_RESTAURANT_2,
|
||||||
|
TEST_RESTAURANT_3,
|
||||||
|
TEST_ROOM_DATA,
|
||||||
|
} from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("@/lib/store", () => ({
|
||||||
|
getRoomData: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
user: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
|
import { getRoomData } from "@/lib/store";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const mockGetRoomData = vi.mocked(getRoomData);
|
||||||
|
const mockFindMany = vi.mocked(prisma.user.findMany);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{ id: TEST_USER.id, username: TEST_USER.username, avatar: TEST_USER.avatar } as never,
|
||||||
|
{ id: TEST_USER_2.id, username: TEST_USER_2.username, avatar: TEST_USER_2.avatar } as never,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildRoomStatus", () => {
|
||||||
|
it("returns null when room not found", async () => {
|
||||||
|
mockGetRoomData.mockResolvedValue(null);
|
||||||
|
const result = await buildRoomStatus("nonexistent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns base status for room with no match", async () => {
|
||||||
|
mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA });
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.roomId).toBe("room-1");
|
||||||
|
expect(result!.userCount).toBe(2);
|
||||||
|
expect(result!.match).toBeNull();
|
||||||
|
expect(result!.matchType).toBeNull();
|
||||||
|
expect(result!.restaurants).toHaveLength(3);
|
||||||
|
expect(result!.creatorId).toBe(TEST_USER.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unanimous match when data has match", async () => {
|
||||||
|
mockGetRoomData.mockResolvedValue({
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
match: TEST_RESTAURANT.id,
|
||||||
|
});
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.match).toBe(TEST_RESTAURANT.id);
|
||||||
|
expect(result!.matchType).toBe("unanimous");
|
||||||
|
expect(result!.matchLikes).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns best match when all finished and top has likes", async () => {
|
||||||
|
const data = {
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
swipeCounts: {
|
||||||
|
[TEST_USER.id]: 3,
|
||||||
|
[TEST_USER_2.id]: 3,
|
||||||
|
},
|
||||||
|
likes: {
|
||||||
|
[TEST_RESTAURANT.id]: [TEST_USER.id],
|
||||||
|
[TEST_RESTAURANT_2.id]: [TEST_USER.id, TEST_USER_2.id],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockGetRoomData.mockResolvedValue(data);
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.matchType).toBe("best");
|
||||||
|
expect(result!.match).toBe(TEST_RESTAURANT_2.id);
|
||||||
|
expect(result!.matchLikes).toBe(2);
|
||||||
|
expect(result!.runnerUps).toEqual([{ id: TEST_RESTAURANT.id, likes: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no_match when all finished but no likes", async () => {
|
||||||
|
const data = {
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
swipeCounts: {
|
||||||
|
[TEST_USER.id]: 3,
|
||||||
|
[TEST_USER_2.id]: 3,
|
||||||
|
},
|
||||||
|
likes: {},
|
||||||
|
};
|
||||||
|
mockGetRoomData.mockResolvedValue(data);
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.matchType).toBe("no_match");
|
||||||
|
expect(result!.matchLikes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks by likes descending then by rating", async () => {
|
||||||
|
const data = {
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
swipeCounts: {
|
||||||
|
[TEST_USER.id]: 3,
|
||||||
|
[TEST_USER_2.id]: 3,
|
||||||
|
},
|
||||||
|
likes: {
|
||||||
|
[TEST_RESTAURANT.id]: [TEST_USER.id],
|
||||||
|
[TEST_RESTAURANT_2.id]: [TEST_USER.id],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockGetRoomData.mockResolvedValue(data);
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
// Both have 1 like, TEST_RESTAURANT has higher rating (4.5 vs 4.0)
|
||||||
|
expect(result!.match).toBe(TEST_RESTAURANT.id);
|
||||||
|
expect(result!.runnerUps[0].id).toBe(TEST_RESTAURANT_2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consider allFinished when no users", async () => {
|
||||||
|
const data = {
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
users: [],
|
||||||
|
swipeCounts: {},
|
||||||
|
};
|
||||||
|
mockGetRoomData.mockResolvedValue(data);
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.matchType).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates user profiles", async () => {
|
||||||
|
mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA });
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.userProfiles[TEST_USER.id]).toEqual({
|
||||||
|
id: TEST_USER.id,
|
||||||
|
username: TEST_USER.username,
|
||||||
|
avatar: TEST_USER.avatar,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes likeCounts only for restaurants with likes", async () => {
|
||||||
|
const data = {
|
||||||
|
...TEST_ROOM_DATA,
|
||||||
|
likes: {
|
||||||
|
[TEST_RESTAURANT.id]: [TEST_USER.id, TEST_USER_2.id],
|
||||||
|
[TEST_RESTAURANT_2.id]: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockGetRoomData.mockResolvedValue(data);
|
||||||
|
const result = await buildRoomStatus("room-1");
|
||||||
|
|
||||||
|
expect(result!.likeCounts[TEST_RESTAURANT.id]).toBe(2);
|
||||||
|
expect(result!.likeCounts[TEST_RESTAURANT_2.id]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
validateUsername,
|
||||||
|
validatePassword,
|
||||||
|
validateEmail,
|
||||||
|
validateIdeaContent,
|
||||||
|
validateRoomName,
|
||||||
|
requireString,
|
||||||
|
} from "@/lib/validation";
|
||||||
|
|
||||||
|
describe("validateUsername", () => {
|
||||||
|
it("accepts 2-16 character names", () => {
|
||||||
|
expect(validateUsername("ab")).toBe("ab");
|
||||||
|
expect(validateUsername("a".repeat(16))).toBe("a".repeat(16));
|
||||||
|
expect(validateUsername("用户名")).toBe("用户名");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace", () => {
|
||||||
|
expect(validateUsername(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names shorter than 2 chars", () => {
|
||||||
|
expect(() => validateUsername("a")).toThrow(ApiError);
|
||||||
|
expect(() => validateUsername("")).toThrow(ApiError);
|
||||||
|
expect(() => validateUsername(" ")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names longer than 16 chars", () => {
|
||||||
|
expect(() => validateUsername("a".repeat(17))).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePassword", () => {
|
||||||
|
it("accepts 6-128 character passwords", () => {
|
||||||
|
expect(() => validatePassword("123456")).not.toThrow();
|
||||||
|
expect(() => validatePassword("a".repeat(128))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords shorter than 6 chars", () => {
|
||||||
|
expect(() => validatePassword("12345")).toThrow(ApiError);
|
||||||
|
expect(() => validatePassword("")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords longer than 128 chars", () => {
|
||||||
|
expect(() => validatePassword("a".repeat(129))).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom label in error message", () => {
|
||||||
|
expect(() => validatePassword("12345", "新密码")).toThrow("新密码至少 6 个字符");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateEmail", () => {
|
||||||
|
it("accepts valid emails", () => {
|
||||||
|
expect(() => validateEmail("test@example.com")).not.toThrow();
|
||||||
|
expect(() => validateEmail("user.name@domain.co")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid emails", () => {
|
||||||
|
expect(() => validateEmail("notanemail")).toThrow(ApiError);
|
||||||
|
expect(() => validateEmail("@domain.com")).toThrow(ApiError);
|
||||||
|
expect(() => validateEmail("user@")).toThrow(ApiError);
|
||||||
|
expect(() => validateEmail("user @domain.com")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateIdeaContent", () => {
|
||||||
|
it("accepts valid content and trims", () => {
|
||||||
|
expect(validateIdeaContent(" 去公园 ")).toBe("去公园");
|
||||||
|
expect(validateIdeaContent("a")).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty/falsy content", () => {
|
||||||
|
expect(() => validateIdeaContent("")).toThrow(ApiError);
|
||||||
|
expect(() => validateIdeaContent(null)).toThrow(ApiError);
|
||||||
|
expect(() => validateIdeaContent(undefined)).toThrow(ApiError);
|
||||||
|
expect(() => validateIdeaContent(" ")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string content", () => {
|
||||||
|
expect(() => validateIdeaContent(123)).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects content over 200 chars", () => {
|
||||||
|
expect(() => validateIdeaContent("a".repeat(201))).toThrow(ApiError);
|
||||||
|
expect(validateIdeaContent("a".repeat(200))).toBe("a".repeat(200));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateRoomName", () => {
|
||||||
|
it("accepts valid room name", () => {
|
||||||
|
expect(validateRoomName("我的房间")).toBe("我的房间");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback for empty name", () => {
|
||||||
|
expect(validateRoomName("")).toBe("我们的周末");
|
||||||
|
expect(validateRoomName(null)).toBe("我们的周末");
|
||||||
|
expect(validateRoomName(undefined)).toBe("我们的周末");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom fallback", () => {
|
||||||
|
expect(validateRoomName("", "默认名")).toBe("默认名");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names over 30 chars", () => {
|
||||||
|
expect(() => validateRoomName("a".repeat(31))).toThrow(ApiError);
|
||||||
|
expect(validateRoomName("a".repeat(30))).toBe("a".repeat(30));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireString", () => {
|
||||||
|
it("returns string value when valid", () => {
|
||||||
|
expect(requireString("hello", "field")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for empty/falsy values", () => {
|
||||||
|
expect(() => requireString("", "字段")).toThrow("字段不能为空");
|
||||||
|
expect(() => requireString(null, "字段")).toThrow(ApiError);
|
||||||
|
expect(() => requireString(undefined, "字段")).toThrow(ApiError);
|
||||||
|
expect(() => requireString(" ", "字段")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for non-string values", () => {
|
||||||
|
expect(() => requireString(123, "字段")).toThrow(ApiError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["src/__tests__/helpers/setup.ts"],
|
||||||
|
environmentMatchGlobs: [
|
||||||
|
["src/app/api/**/*.test.ts", "node"],
|
||||||
|
["src/lib/**/*.test.ts", "node"],
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
AMAP_API_KEY: "test-amap-key",
|
||||||
|
DEEPSEEK_API_KEY: "test-deepseek-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user