마이페이지, 리포트, 메일인증
This commit is contained in:
+195
-4
@@ -9,11 +9,25 @@ const BASE_URL = "http://localhost";
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
||||
const inMemoryUsers: any[] = [];
|
||||
|
||||
// 이메일 인증 토큰 발송 유틸을 목 처리해, 실제 SMTP 로 메일을 보내지 않고 호출 여부/인자를 검증한다.
|
||||
const sendVerificationEmailMock = vi.fn();
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
user = {
|
||||
findUnique: async ({ where: { email } }: any) => {
|
||||
return inMemoryUsers.find((u) => u.email === email) ?? null;
|
||||
// where.email 또는 where.id 기준으로 단일 유저를 찾는다.
|
||||
findUnique: async ({ where }: any) => {
|
||||
if (!where) return null;
|
||||
if (where.email) {
|
||||
return inMemoryUsers.find((u) => u.email === where.email) ?? null;
|
||||
}
|
||||
if (where.id) {
|
||||
return inMemoryUsers.find((u) => u.id === where.id) ?? null;
|
||||
}
|
||||
if (where.emailVerifyToken) {
|
||||
return inMemoryUsers.find((u) => u.emailVerifyToken === where.emailVerifyToken) ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
const now = new Date();
|
||||
@@ -26,17 +40,34 @@ vi.mock("@prisma/client", () => {
|
||||
inMemoryUsers.push(user);
|
||||
return user;
|
||||
},
|
||||
// change-password / change-email 에서 사용할 update 목
|
||||
update: async ({ where, data }: any) => {
|
||||
const target = inMemoryUsers.find((u) => u.id === where.id);
|
||||
if (!target) {
|
||||
throw new Error("User not found in inMemoryUsers");
|
||||
}
|
||||
Object.assign(target, data, { updatedAt: new Date() });
|
||||
return target;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
vi.mock("@/features/email/sendVerificationEmail", () => {
|
||||
return {
|
||||
sendVerificationEmail: (...args: any[]) => sendVerificationEmailMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
||||
inMemoryUsers.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
sendVerificationEmailMock.mockReset();
|
||||
});
|
||||
|
||||
describe("/api/auth", () => {
|
||||
@@ -232,8 +263,22 @@ describe("/api/auth", () => {
|
||||
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
|
||||
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||
|
||||
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
|
||||
const token = await signAccessToken(user);
|
||||
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1, emailVerified: true };
|
||||
|
||||
// /api/auth/me 가 DB(User) 기준으로 동작하므로, Prisma 목(inMemoryUsers)에 동일한 유저를 추가한다.
|
||||
inMemoryUsers.push({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: user.tokenVersion,
|
||||
emailVerified: user.emailVerified,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyTokenExpiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const token = await signAccessToken(user as any);
|
||||
|
||||
const res = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
@@ -247,6 +292,7 @@ describe("/api/auth", () => {
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.id).toBe(user.id);
|
||||
expect(json.email).toBe(user.email);
|
||||
expect(json.emailVerified).toBe(true);
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -264,4 +310,149 @@ describe("/api/auth", () => {
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/request-email-verification", () => {
|
||||
it("로그인된 사용자가 호출하면 emailVerifyToken 과 만료 시간이 설정되어야 한다", async () => {
|
||||
const { POST: requestEmailVerification } = await import(
|
||||
"@/app/api/auth/request-email-verification/route",
|
||||
);
|
||||
|
||||
const email = "verify@example.com";
|
||||
inMemoryUsers.push({
|
||||
id: "user-20",
|
||||
email,
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyTokenExpiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const token = await signAccessToken({ id: "user-20", email, tokenVersion: 1 });
|
||||
|
||||
const res = await requestEmailVerification(
|
||||
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `pb_access=${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
expect(typeof json.verificationToken).toBe("string");
|
||||
expect(json.verificationToken.length).toBeGreaterThan(0);
|
||||
|
||||
// in-memory 유저에 토큰과 만료 시간이 설정되었는지 확인한다.
|
||||
const user = inMemoryUsers[0];
|
||||
expect(typeof user.emailVerifyToken).toBe("string");
|
||||
expect(user.emailVerifyToken.length).toBeGreaterThan(0);
|
||||
expect(user.emailVerifyTokenExpiresAt instanceof Date).toBe(true);
|
||||
|
||||
// 이메일 발송 유틸이 올바른 인자로 한 번 호출되었는지 확인한다.
|
||||
expect(sendVerificationEmailMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendVerificationEmailMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: email,
|
||||
token: json.verificationToken,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("인증 정보가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { POST: requestEmailVerification } = await import(
|
||||
"@/app/api/auth/request-email-verification/route",
|
||||
);
|
||||
|
||||
const res = await requestEmailVerification(
|
||||
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/verify-email", () => {
|
||||
it("유효한 토큰으로 호출하면 emailVerified 가 true 로 설정되고 토큰 필드가 제거되어야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
const token = "verify-token-123";
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000);
|
||||
inMemoryUsers.push({
|
||||
id: "user-21",
|
||||
email: "verify2@example.com",
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: token,
|
||||
emailVerifyTokenExpiresAt: future,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
||||
);
|
||||
|
||||
// 리다이렉트 응답이어야 한다.
|
||||
expect(res.status).toBeGreaterThanOrEqual(300);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toContain("/mypage");
|
||||
|
||||
// in-memory 유저 상태가 업데이트되었는지 확인한다.
|
||||
const user = inMemoryUsers[0];
|
||||
expect(user.emailVerified).toBe(true);
|
||||
expect(user.emailVerifyToken).toBeNull();
|
||||
expect(user.emailVerifyTokenExpiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("토큰이 없거나 잘못된 경우 400 을 반환해야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
// 쿼리 파라미터 없이 호출
|
||||
const res1 = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email`),
|
||||
);
|
||||
expect(res1.status).toBe(400);
|
||||
|
||||
// 잘못된 토큰으로 호출
|
||||
const res2 = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=unknown-token`),
|
||||
);
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
|
||||
it("만료된 토큰으로 호출하면 400 을 반환해야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
const token = "expired-token-123";
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000);
|
||||
inMemoryUsers.push({
|
||||
id: "user-22",
|
||||
email: "expired@example.com",
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: token,
|
||||
emailVerifyTokenExpiresAt: past,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// in-memory Prisma 목 데이터
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemorySubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemoryProjects.filter((p) => p.userId === where.userId);
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemorySubmissions.filter((s) => s.userId === where.userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryProjects.length = 0;
|
||||
inMemorySubmissions.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-reports-leads";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-10T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/api/reports/leads", () => {
|
||||
it("range=all 에서 필터된 모든 리드를 최신순으로 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-all";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{ id: "p1", userId, title: "Project A", slug: "project-a" },
|
||||
{ id: "p2", userId, title: "Project B", slug: "project-b" },
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
payloadJson: { name: "Alice" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2025-01-08T09:00:00.000Z"),
|
||||
payloadJson: { email: "bob@example.com" },
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-10T00:00:00.000Z"),
|
||||
payloadJson: { phone: "010-0000-0000" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(3);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.items.length).toBe(3);
|
||||
|
||||
// 최신순 정렬: createdAt 이 가장 최근인 s3 가 첫 번째여야 한다.
|
||||
expect(json.items[0].id).toBe("s3");
|
||||
expect(json.items[1].id).toBe("s1");
|
||||
expect(json.items[2].id).toBe("s2");
|
||||
|
||||
const first = json.items[0];
|
||||
expect(first.projectSlug).toBe("project-a");
|
||||
expect(first.projectTitle).toBe("Project A");
|
||||
expect(first.payload).toEqual({ phone: "010-0000-0000" });
|
||||
});
|
||||
|
||||
it("range=7d 와 projectId 가 적용되면 해당 조건에 맞는 리드만 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-filter";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
// 기준 시간: 2025-01-10
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"), // within 7d
|
||||
payloadJson: { name: "Alice" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"), // older than 7d
|
||||
payloadJson: { name: "Old" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads-filter@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?range=7d&projectId=p1`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(1);
|
||||
expect(json.items.length).toBe(1);
|
||||
expect(json.items[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
it("from/to 쿼리로 커스텀 기간의 리드만 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-from-to";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"),
|
||||
payloadJson: { label: "old" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T12:00:00.000Z"),
|
||||
payloadJson: { label: "mid" },
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
payloadJson: { label: "new" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads-from-to@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?from=2025-01-02&to=2025-01-08`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(1);
|
||||
expect(json.items.length).toBe(1);
|
||||
expect(json.items[0].id).toBe("s2");
|
||||
});
|
||||
|
||||
it("JWT 가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const res = await leads(new Request(`${BASE_URL}/api/reports/leads?range=all`));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// in-memory Prisma 목 데이터
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemorySubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemoryProjects.filter((p) => p.userId === where.userId);
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemorySubmissions.filter((s) => s.userId === where.userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryProjects.length = 0;
|
||||
inMemorySubmissions.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-reports";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-10T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/api/reports/overview", () => {
|
||||
it("range=all 에서 전체 프로젝트/제출 기준으로 summary/daily/byProject 를 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{
|
||||
id: "p1",
|
||||
userId,
|
||||
title: "Project A",
|
||||
slug: "project-a",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
userId,
|
||||
title: "Project B",
|
||||
slug: "project-b",
|
||||
},
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T09:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2024-12-20T08:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(3);
|
||||
expect(json.summary.uniqueProjects).toBe(2);
|
||||
expect(json.summary.avgPerDay).toBeGreaterThan(0);
|
||||
|
||||
expect(Array.isArray(json.daily)).toBe(true);
|
||||
expect(json.daily.length).toBe(3);
|
||||
|
||||
expect(Array.isArray(json.byProject)).toBe(true);
|
||||
expect(json.byProject).toHaveLength(2);
|
||||
|
||||
const byId: Record<string, any> = {};
|
||||
for (const row of json.byProject) {
|
||||
byId[row.projectId] = row;
|
||||
}
|
||||
|
||||
expect(byId["p1"].submissions).toBe(2);
|
||||
expect(byId["p2"].submissions).toBe(1);
|
||||
expect(byId["p1"].ratio + byId["p2"].ratio).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("range=7d 에서는 최근 7일간의 제출만 포함해 summary/daily/byProject 를 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-7d";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"), // within 7d
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"), // older than 7d
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports7d@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=7d`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.daily).toHaveLength(1);
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("projectId 를 지정하면 해당 프로젝트의 데이터만 포함해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-project";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{ id: "p1", userId, title: "Project A", slug: "project-a" },
|
||||
{ id: "p2", userId, title: "Project B", slug: "project-b" },
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2025-01-09T15:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-project@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all&projectId=p1`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].projectId).toBe("p1");
|
||||
});
|
||||
|
||||
it("range=all 에서 timePatterns 과 projectStatusSummary 를 함께 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-v2";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{
|
||||
id: "p1",
|
||||
userId,
|
||||
title: "Published Project",
|
||||
slug: "published-project",
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
userId,
|
||||
title: "Draft Project",
|
||||
slug: "draft-project",
|
||||
status: "DRAFT",
|
||||
},
|
||||
);
|
||||
|
||||
// 3개의 제출: p1 에 2개, p2 에 1개, 서로 다른 시간대
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "published-project",
|
||||
createdAt: new Date("2025-01-09T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "published-project",
|
||||
createdAt: new Date("2025-01-09T15:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "draft-project",
|
||||
createdAt: new Date("2025-01-08T09:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-v2@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(3);
|
||||
|
||||
// timePatterns
|
||||
expect(json.timePatterns).toBeDefined();
|
||||
expect(Array.isArray(json.timePatterns.byHour)).toBe(true);
|
||||
const sumByHour = (json.timePatterns.byHour as Array<{ hour: number; count: number }>).reduce(
|
||||
(sum, item) => sum + item.count,
|
||||
0,
|
||||
);
|
||||
expect(sumByHour).toBe(3);
|
||||
|
||||
// projectStatusSummary
|
||||
expect(json.projectStatusSummary).toBeDefined();
|
||||
expect(Array.isArray(json.projectStatusSummary.byStatus)).toBe(true);
|
||||
const byStatus: Record<string, { projects: number; submissions: number }> = {};
|
||||
for (const row of json.projectStatusSummary.byStatus as Array<{
|
||||
status: string;
|
||||
projects: number;
|
||||
submissions: number;
|
||||
}>) {
|
||||
byStatus[row.status] = { projects: row.projects, submissions: row.submissions };
|
||||
}
|
||||
|
||||
expect(byStatus["PUBLISHED"].projects).toBe(1);
|
||||
expect(byStatus["PUBLISHED"].submissions).toBe(2);
|
||||
expect(byStatus["DRAFT"].projects).toBe(1);
|
||||
expect(byStatus["DRAFT"].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("from/to 쿼리로 커스텀 기간을 필터링해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-from-to";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-from-to@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?from=2025-01-02&to=2025-01-08`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.daily).toHaveLength(1);
|
||||
expect(json.daily[0].date).toBe("2025-01-05");
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("JWT 가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const res = await reports(new Request(`${BASE_URL}/api/reports/overview?range=all`));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user