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