리팩터링
CI / test (push) Successful in 5m38s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m12s

This commit is contained in:
2025-12-16 17:49:25 +09:00
parent 87cdc1868b
commit 7491dacba2
19 changed files with 1857 additions and 99 deletions
+217 -1
View File
@@ -5,6 +5,7 @@ import { signAccessToken } from "@/features/auth/authCrypto";
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
@@ -24,6 +25,23 @@ vi.mock("@prisma/client", () => {
findUnique: async ({ where: { slug } }: any) => {
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
},
update: async ({ where: { slug }, data }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index === -1) {
const error: any = new Error("Project not found");
error.code = "P2025";
throw error;
}
const existing = inMemoryProjects[index];
const updated = {
...existing,
...data,
updatedAt: new Date(),
};
inMemoryProjects[index] = updated;
return updated;
},
upsert: async ({ where: { slug }, update, create }: any) => {
const index = inMemoryProjects.findIndex((p) => p.slug === slug);
if (index >= 0) {
@@ -89,6 +107,35 @@ vi.mock("@prisma/client", () => {
return items;
},
};
formSubmission = {
findMany: async ({ where, select }: any = {}) => {
let items = [...inMemoryFormSubmissions];
if (where?.userId) {
items = items.filter((s) => s.userId === where.userId);
}
if (where?.projectId?.in && Array.isArray(where.projectId.in)) {
const ids: string[] = where.projectId.in;
items = items.filter((s) => s.projectId && ids.includes(s.projectId));
}
if (select) {
return items.map((s) => {
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) {
picked[key] = s[key];
}
}
return picked;
});
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
@@ -111,6 +158,7 @@ async function buildAuthHeaders() {
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/projects", () => {
@@ -161,7 +209,15 @@ describe("/api/projects", () => {
expect(getResponse.status).toBe(200);
const fetched = (await getResponse.json()) as any;
expect(fetched.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson);
// contentJson 은 배열 또는 { blocks, projectConfig } 스냅샷으로 저장될 수 있다.
const rawContent = fetched.contentJson;
if (Array.isArray(rawContent)) {
expect(rawContent).toEqual(payload.contentJson);
} else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) {
expect(rawContent.blocks).toEqual(payload.contentJson);
} else {
throw new Error("unexpected contentJson shape from /api/projects/[slug]");
}
});
it("GET /api/projects 는 최근 생성 순으로 프로젝트 목록을 반환해야 한다", async () => {
@@ -365,6 +421,126 @@ describe("/api/projects", () => {
expect(slugsB).not.toContain(slugA);
});
it("GET /api/projects 는 각 프로젝트별 오늘/전체 제출 수를 함께 반환해야 한다", async () => {
const { GET: listProjects } = await import("@/app/api/projects/route");
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const projectA = {
id: "proj-a",
userId: TEST_USER.id,
title: "프로젝트 A",
slug: "project-a",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
const projectB = {
id: "proj-b",
userId: TEST_USER.id,
title: "프로젝트 B",
slug: "project-b",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
const projectOther = {
id: "proj-other",
userId: OTHER_USER.id,
title: "다른 유저 프로젝트",
slug: "other-project",
status: "PUBLISHED",
createdAt: now,
updatedAt: now,
};
inMemoryProjects.push(projectA, projectB, projectOther);
inMemoryFormSubmissions.push(
// 프로젝트 A: 오늘 1건, 어제 1건 (총 2건)
{
id: "sub-1",
projectId: projectA.id,
projectSlug: projectA.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-2",
projectId: projectA.id,
projectSlug: projectA.slug,
userId: TEST_USER.id,
createdAt: yesterday,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
// 프로젝트 B: 오늘 2건
{
id: "sub-3",
projectId: projectB.id,
projectSlug: projectB.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-4",
projectId: projectB.id,
projectSlug: projectB.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
// 다른 유저 프로젝트 제출: 집계에 포함되면 안 된다.
{
id: "sub-5",
projectId: projectOther.id,
projectSlug: projectOther.slug,
userId: OTHER_USER.id,
createdAt: now,
payloadJson: {},
sensitiveEnc: undefined,
metaJson: null,
},
);
const headers = await buildAuthHeadersFor(TEST_USER);
const res = await listProjects(
new Request(`${BASE_URL}/api/projects`, {
headers,
}),
);
expect(res.status).toBe(200);
const list = (await res.json()) as any[];
const a = list.find((p) => p.slug === projectA.slug);
const b = list.find((p) => p.slug === projectB.slug);
const other = list.find((p) => p.slug === projectOther.slug);
expect(a).toBeTruthy();
expect(b).toBeTruthy();
expect(other).toBeUndefined();
expect(a.todaySubmissions).toBe(1);
expect(a.totalSubmissions).toBe(2);
expect(b.todaySubmissions).toBe(2);
expect(b.totalSubmissions).toBe(2);
});
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");
@@ -404,6 +580,46 @@ describe("/api/projects", () => {
expect(secondRes.status).toBe(409);
});
it("PATCH /api/projects/[slug] 로 프로젝트 status 를 변경할 수 있어야 한다", async () => {
const { POST: createProject } = await import("@/app/api/projects/route");
const { PATCH: updateProjectStatus } = await import("@/app/api/projects/[slug]/route");
const slug = "status-change-slug";
const payload = {
title: "상태 변경 프로젝트",
slug,
contentJson: [],
};
const headers = await buildAuthHeaders();
const createRes = await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
}),
);
expect(createRes.status).toBe(201);
const patchBody = { status: "PUBLISHED" };
const patchRes = await updateProjectStatus(
new Request(`${BASE_URL}/api/projects/${slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(patchBody),
}),
{ params: { slug } } as any,
);
expect(patchRes.status).toBe(200);
const updated = (await patchRes.json()) as any;
expect(updated.slug).toBe(slug);
expect(updated.status).toBe("PUBLISHED");
});
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
const { POST: handleProject } = await import("@/app/api/projects/route");