refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化

批次A:重命名 + 路由拆分
- store.ts → roomRepository.ts,更新全部 import
- blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts

批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/)
- agentPlan.ts:Agent 工具调用与系统提示
- legacyPlan.ts:非 Agent 备用生成逻辑
- ideaSelection.ts:Idea 筛选与 Slot 映射
- transitEnrichment.ts:交通信息查询与填充
- index.ts:runPlanGeneration 主入口

批次C:SSE 连接稳定性
- useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter)
- plan/stream/route.ts 添加30s心跳 + abort信号清理

批次D:无障碍修复
- Modal:role=dialog、aria-modal、aria-labelledby
- AuthModal:aria-label关闭按钮、tablist/tab/aria-selected
- PlanItemEditModal、QrInviteModal:补全aria-label
- BlindboxPlan:图标按钮aria-label

批次E:Zod 引入
- src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个)
- src/lib/schemas/requests.ts:请求体 Schema
- ai.ts 手工验证替换为 Zod safeParse

批次F:Server Components
- achievements/page.tsx → Server Component + AchievementsClient.tsx
- profile/page.tsx → Server Component + ProfileClient.tsx

批次G:Room 关系化模型
- prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表
- migration:20260302010000_room_relational_model
- roomRepository.ts 完整重写(关系查询+应用锁)
- buildRoomStatus.ts 适配关系查询

测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
2026-03-02 20:27:06 +08:00
parent 6bb0e65d4c
commit 4f4220652e
59 changed files with 2369 additions and 1999 deletions
+8 -5
View File
@@ -1,7 +1,10 @@
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/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
@@ -36,7 +39,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -62,7 +65,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
@@ -82,7 +85,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(409);
@@ -91,7 +94,7 @@ describe("POST /api/blindbox/draw", () => {
it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { userId: "user-1" },
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
+13 -12
View File
@@ -3,6 +3,10 @@ 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/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
@@ -29,7 +33,6 @@ describe("POST /api/blindbox/plan", () => {
method: "POST",
body: {
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
@@ -45,7 +48,6 @@ describe("POST /api/blindbox/plan", () => {
method: "POST",
body: {
roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
},
});
@@ -57,7 +59,6 @@ describe("POST /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
@@ -76,7 +77,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -94,7 +95,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "complete" },
body: { planId: "plan-1", action: "complete" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
@@ -109,7 +110,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "expire" },
body: { planId: "plan-1", action: "expire" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
@@ -123,7 +124,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
@@ -137,7 +138,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" },
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(403);
@@ -148,7 +149,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "invalid" },
body: { planId: "plan-1", action: "invalid" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
@@ -164,7 +165,7 @@ describe("GET /api/blindbox/plan", () => {
createdAt: new Date(),
} as never);
const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1");
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -176,7 +177,7 @@ describe("GET /api/blindbox/plan", () => {
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 req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
@@ -184,7 +185,7 @@ describe("GET /api/blindbox/plan", () => {
});
it("returns 400 for invalid mode", async () => {
const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1");
const req = createRequest("/api/blindbox/plan?mode=invalid");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
+7 -163
View File
@@ -3,6 +3,8 @@ import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
import { handlePlanUpdate } from "@/lib/planActions";
import { getLatestPlan, getPendingPlans, getHistoryPlans } from "@/lib/planQueries";
interface AvailableTime {
date: string;
@@ -37,97 +39,13 @@ export const POST = apiHandler(async (req) => {
});
});
/**
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
* Returns a Date at 00:00 of that day.
*/
function nextWeekday(dayLabel: string, from: Date): Date {
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
const d = new Date(from);
d.setHours(0, 0, 0, 0);
const diff = (targetDow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
return d;
}
function computeEndTime(planData: string, now: Date): Date | null {
try {
const parsed = JSON.parse(planData);
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
if (!days?.length) return null;
const lastDay = days[days.length - 1];
const lastItem = lastDay.items[lastDay.items.length - 1];
if (!lastItem) return null;
const base = nextWeekday(lastDay.date, now);
const [h, m] = lastItem.time.split(":").map(Number);
base.setHours(h, m, 0, 0);
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
if (base.getTime() < now.getTime()) {
base.setDate(base.getDate() + 7);
}
return base;
} catch (e) {
console.error("computeEndTime failed:", e);
return null;
}
}
export const PATCH = apiHandler(async (req) => {
const userId = await getAuthUserId(req);
const { planId, action, days } = await req.json();
if (!planId) throw new ApiError("planId 不能为空");
const { prisma } = await import("@/lib/prisma");
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
if (!plan) throw new ApiError("计划不存在", 404);
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
const act = action || "accept";
if (act === "accept") {
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
const endTime = computeEndTime(plan.planData, new Date());
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: "accepted", endTime },
});
return NextResponse.json({ ok: true, endTime });
}
if (act === "complete" || act === "expire") {
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: act === "complete" ? "completed" : "expired" },
});
return NextResponse.json({ ok: true });
}
if (act === "update_plan") {
if (plan.status !== "active" && plan.status !== "accepted") {
throw new ApiError("只能编辑进行中的计划", 400);
}
if (!Array.isArray(days) || days.length === 0) {
throw new ApiError("days 数据无效", 400);
}
const newPlanData = JSON.stringify({ days });
await prisma.weekendPlan.update({
where: { id: planId },
data: {
planData: newPlanData,
...(plan.status === "accepted"
? { endTime: computeEndTime(newPlanData, new Date()) }
: {}),
},
});
return NextResponse.json({ ok: true });
}
throw new ApiError("无效的操作", 400);
const result = await handlePlanUpdate(planId, userId, action, days);
return NextResponse.json(result);
});
export const GET = apiHandler(async (req) => {
@@ -135,92 +53,18 @@ export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url);
const mode = searchParams.get("mode") || "latest";
const { prisma } = await import("@/lib/prisma");
if (mode === "latest") {
const roomId = searchParams.get("roomId");
if (!roomId) throw new ApiError("roomId 不能为空");
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, endTime: true, createdAt: true },
});
if (!plan) return NextResponse.json({ plan: null });
const parsed = JSON.parse(plan.planData);
return NextResponse.json({
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
});
return NextResponse.json(await getLatestPlan(roomId, userId));
}
if (mode === "pending") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId,
status: "accepted",
endTime: { not: null, lt: new Date() },
},
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, roomId: true, createdAt: true },
take: 5,
});
const result = await Promise.all(
plans.map(async (p) => {
const room = await prisma.blindBoxRoom.findUnique({
where: { id: p.roomId },
select: { name: true, code: true },
});
const parsed = JSON.parse(p.planData);
const days = parsed.days as { date: string; items: { activity: string }[] }[];
return {
id: p.id,
roomName: room?.name ?? "未知房间",
roomCode: room?.code ?? "",
date: days.map((d) => d.date).join(" + "),
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
createdAt: p.createdAt,
};
}),
);
return NextResponse.json({ pending: result });
return NextResponse.json(await getPendingPlans(userId));
}
if (mode === "history") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId,
status: { in: ["completed", "expired"] },
},
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
take: 50,
});
const result = await Promise.all(
plans.map(async (p) => {
const room = await prisma.blindBoxRoom.findUnique({
where: { id: p.roomId },
select: { name: true, code: true },
});
const parsed = JSON.parse(p.planData);
const days = parsed.days as { date: string; items: { activity: string }[] }[];
return {
id: p.id,
status: p.status,
roomName: room?.name ?? "未知房间",
roomCode: room?.code ?? "",
date: days.map((d) => d.date).join(" + "),
dayCount: days.length,
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
createdAt: p.createdAt,
};
}),
);
return NextResponse.json({ history: result });
return NextResponse.json(await getHistoryPlans(userId));
}
throw new ApiError("无效的 mode 参数", 400);
+15 -18
View File
@@ -2,15 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/api", () => ({
requireUserId: vi.fn((v) => {
if (!v || typeof v !== "string") throw new Error("请先登录");
return v;
}),
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/blindboxPlanGen", () => ({
@@ -19,11 +16,14 @@ vi.mock("@/lib/blindboxPlanGen", () => ({
import { POST } from "./route";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
import { ApiError } from "@/lib/api";
const mockRunPlan = vi.mocked(runPlanGeneration);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
async function readStream(response: Response): Promise<string> {
@@ -55,12 +55,11 @@ describe("POST /api/blindbox/plan/stream", () => {
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 res = await POST(req as never);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
@@ -79,12 +78,11 @@ describe("POST /api/blindbox/plan/stream", () => {
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 res = await POST(req as never);
const text = await readStream(res);
expect(text).toContain("event: error");
expect(text).toContain("AI 服务不可用");
@@ -95,12 +93,11 @@ describe("POST /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);
const res = await POST(req as never);
expect(res.status).toBe(400);
});
@@ -110,16 +107,16 @@ describe("POST /api/blindbox/plan/stream", () => {
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);
const res = await POST(req as never);
expect(res.status).toBe(400);
});
it("returns 400 when userId is missing", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -129,7 +126,7 @@ describe("POST /api/blindbox/plan/stream", () => {
}),
});
const res = await POST(req);
expect(res.status).toBe(400);
const res = await POST(req as never);
expect(res.status).toBe(401);
});
});
+21 -1
View File
@@ -49,13 +49,33 @@ export async function POST(req: NextRequest): Promise<Response> {
});
}
const signal = req.signal;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let closed = false;
const push = (event: string, data: string) => {
if (closed) return;
controller.enqueue(encoder.encode(encodeSSE(event, data)));
};
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(heartbeatId);
try { controller.close(); } catch {}
};
// 30s heartbeat to prevent proxy disconnects
const heartbeatId = setInterval(() => {
push("heartbeat", "ping");
}, 30_000);
// Clean up when client disconnects
signal.addEventListener("abort", cleanup);
try {
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
push("status", message);
@@ -65,7 +85,7 @@ export async function POST(req: NextRequest): Promise<Response> {
const message = e instanceof Error ? e.message : "生成计划失败";
push("error", message);
} finally {
controller.close();
cleanup();
}
},
});
+11 -3
View File
@@ -2,6 +2,11 @@ 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";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
}));
import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox/retag", () => {
@@ -36,7 +43,7 @@ describe("POST /api/blindbox/retag", () => {
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -51,7 +58,7 @@ describe("POST /api/blindbox/retag", () => {
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" },
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -60,7 +67,8 @@ describe("POST /api/blindbox/retag", () => {
expect(data.retagged).toBe(0);
});
it("returns 401 when no userId", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1" },
+16 -14
View File
@@ -3,6 +3,10 @@ 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/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
getRoomByCode: vi.fn(),
requireMembership: vi.fn().mockResolvedValue({}),
@@ -10,12 +14,14 @@ vi.mock("@/lib/blindbox", () => ({
import { GET, PATCH, DELETE } from "./route";
import { getRoomByCode } from "@/lib/blindbox";
import { getAuthUserId } from "@/lib/auth";
const mockGetRoomByCode = vi.mocked(getRoomByCode);
beforeEach(() => {
resetPrismaMock();
vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/blindbox/room/[code]", () => {
@@ -61,7 +67,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 },
body: { city: "上海", lat: 31.2, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
@@ -76,7 +82,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { userId: "user-1", lat: 999, lng: 121.4 },
body: { lat: 999, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
@@ -86,13 +92,11 @@ describe("PATCH /api/blindbox/room/[code]", () => {
describe("DELETE /api/blindbox/room/[code]", () => {
it("deletes room when creator", async () => {
// user-1 is creator (TEST_BLINDBOX_ROOM.creatorId = "user-1")
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 req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
@@ -101,14 +105,13 @@ describe("DELETE /api/blindbox/room/[code]", () => {
});
it("leaves room when not creator", async () => {
// Authenticate as user-2 (not creator)
vi.mocked(getAuthUserId).mockResolvedValueOnce("user-2");
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 req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
@@ -117,13 +120,12 @@ describe("DELETE /api/blindbox/room/[code]", () => {
});
it("returns 403 when not a member and not creator", async () => {
// Authenticate as stranger (not creator, not member)
vi.mocked(getAuthUserId).mockResolvedValueOnce("stranger");
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 req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
expect(res.status).toBe(403);
+8 -4
View File
@@ -3,6 +3,10 @@ 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";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
@@ -19,7 +23,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-2", code: "ABC123" },
body: { code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -34,7 +38,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1", code: "ABC123" },
body: { code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
@@ -47,7 +51,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1", code: "BADCODE" },
body: { code: "BADCODE" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
@@ -56,7 +60,7 @@ describe("POST /api/blindbox/room/join", () => {
it("returns 400 when code is missing", async () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { userId: "user-1" },
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
+12 -4
View File
@@ -2,17 +2,24 @@ 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";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
}));
import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox/room", () => {
@@ -25,7 +32,7 @@ describe("POST /api/blindbox/room", () => {
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { userId: "user-1", name: "周末计划" },
body: { name: "周末计划" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -40,13 +47,14 @@ describe("POST /api/blindbox/room", () => {
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { userId: "user-1" },
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(201);
});
it("returns 401 when no userId", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { name: "test" },
@@ -58,7 +66,7 @@ describe("POST /api/blindbox/room", () => {
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) },
body: { name: "a".repeat(31) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
+10 -2
View File
@@ -1,13 +1,20 @@
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 { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { GET } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/blindbox/rooms", () => {
@@ -33,7 +40,7 @@ describe("GET /api/blindbox/rooms", () => {
{ roomId: "bb-room-1", _count: 3 },
] as never);
const req = createRequest("/api/blindbox/rooms?userId=user-1");
const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -43,7 +50,8 @@ describe("GET /api/blindbox/rooms", () => {
expect(data.rooms[0].poolCount).toBe(3);
});
it("returns 401 when no userId", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
+19 -10
View File
@@ -2,6 +2,11 @@ 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";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
}));
import { POST, GET, PUT, DELETE } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox (create idea)", () => {
@@ -35,7 +42,7 @@ describe("POST /api/blindbox (create idea)", () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" },
body: { roomId: "bb-room-1", content: "去公园野餐" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -44,7 +51,8 @@ describe("POST /api/blindbox (create idea)", () => {
expect(data.id).toBe("idea-1");
});
it("returns 401 when no userId", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "test" },
@@ -56,7 +64,7 @@ describe("POST /api/blindbox (create idea)", () => {
it("returns 400 when content is empty", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "" },
body: { roomId: "bb-room-1", content: "" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
@@ -65,7 +73,7 @@ describe("POST /api/blindbox (create idea)", () => {
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) },
body: { roomId: "bb-room-1", content: "a".repeat(201) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
@@ -79,7 +87,7 @@ describe("GET /api/blindbox (get pool data)", () => {
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
.mockResolvedValueOnce([] as never);
const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1");
const req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -89,7 +97,8 @@ describe("GET /api/blindbox (get pool data)", () => {
expect(data.drawn).toHaveLength(0);
});
it("returns 401 when no userId", async () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
@@ -102,7 +111,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" },
body: { ideaId: "idea-1", content: "去公园散步" },
});
const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
@@ -116,7 +125,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "nonexistent", userId: "user-1", content: "test" },
body: { ideaId: "nonexistent", content: "test" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(404);
@@ -129,7 +138,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "idea-1", userId: "user-1" },
body: { ideaId: "idea-1" },
});
const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res);
@@ -142,7 +151,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "nonexistent", userId: "user-1" },
body: { ideaId: "nonexistent" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
@@ -2,6 +2,10 @@ 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/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));