사용자 로그인, 가입 처리
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
||||
const inMemoryUsers: any[] = [];
|
||||
|
||||
vi.mock("@/generated/prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
user = {
|
||||
findUnique: async ({ where: { email } }: any) => {
|
||||
return inMemoryUsers.find((u) => u.email === email) ?? null;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
const now = new Date();
|
||||
const user = {
|
||||
id: String(inMemoryUsers.length + 1),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
};
|
||||
inMemoryUsers.push(user);
|
||||
return user;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
||||
inMemoryUsers.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
});
|
||||
|
||||
describe("/api/auth", () => {
|
||||
describe("POST /api/auth/signup", () => {
|
||||
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
const payload = { email: "user@example.com", password: "securePass1" };
|
||||
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.email).toBe(payload.email);
|
||||
expect(json.id).toBeDefined();
|
||||
// 응답에는 passwordHash 가 포함되면 안 된다.
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
|
||||
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
|
||||
expect(inMemoryUsers.length).toBe(1);
|
||||
expect(inMemoryUsers[0].email).toBe(payload.email);
|
||||
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
|
||||
|
||||
// JWT 세션 쿠키가 설정되어야 한다.
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
});
|
||||
|
||||
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "short@example.com", password: "short" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
|
||||
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
|
||||
// 먼저 한 번 성공적으로 가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 같은 이메일로 다시 요청
|
||||
const res = await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login", () => {
|
||||
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||
|
||||
const email = "login@example.com";
|
||||
const password = "securePass1";
|
||||
|
||||
// 사전 회원가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.email).toBe(email);
|
||||
expect(json.id).toBeDefined();
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
});
|
||||
|
||||
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
|
||||
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||
|
||||
const email = "wrong@example.com";
|
||||
const password = "securePass1";
|
||||
|
||||
// 사전 회원가입
|
||||
await signup(
|
||||
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 존재하지 않는 이메일
|
||||
const res1 = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "nope@example.com", password }),
|
||||
}),
|
||||
);
|
||||
expect(res1.status).toBe(401);
|
||||
|
||||
// 비밀번호 불일치
|
||||
const res2 = await login(
|
||||
new Request(`${BASE_URL}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password: "wrongPass1" }),
|
||||
}),
|
||||
);
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/logout", () => {
|
||||
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
|
||||
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||
|
||||
const res = await logout(
|
||||
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: "pb_access=dummy.token.value",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||
});
|
||||
|
||||
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
|
||||
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||
|
||||
const res = await logout(
|
||||
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
expect(setCookie).toContain("pb_access=");
|
||||
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me", () => {
|
||||
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 res = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
headers: {
|
||||
cookie: `pb_access=${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.id).toBe(user.id);
|
||||
expect(json.email).toBe(user.email);
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
|
||||
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||
|
||||
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
|
||||
expect(res1.status).toBe(401);
|
||||
|
||||
const res2 = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
headers: { cookie: "pb_access=invalid.token.here" },
|
||||
}),
|
||||
);
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
+218
-12
@@ -1,5 +1,6 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
||||
@@ -58,9 +59,13 @@ vi.mock("@/generated/prisma/client", () => {
|
||||
const [removed] = inMemoryProjects.splice(index, 1);
|
||||
return removed;
|
||||
},
|
||||
findMany: async ({ orderBy, take, select }: any = {}) => {
|
||||
findMany: async ({ where, orderBy, take, select }: any = {}) => {
|
||||
let items = [...inMemoryProjects];
|
||||
|
||||
if (where?.userId) {
|
||||
items = items.filter((p) => p.userId === where.userId);
|
||||
}
|
||||
|
||||
if (orderBy?.createdAt === "desc") {
|
||||
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
@@ -91,6 +96,23 @@ vi.mock("@/generated/prisma/client", () => {
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
const TEST_USER = { id: "user-1", email: "projects@example.com", tokenVersion: 1 };
|
||||
const OTHER_USER = { id: "user-2", email: "projects2@example.com", tokenVersion: 1 };
|
||||
|
||||
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
|
||||
const token = await signAccessToken(user);
|
||||
return { cookie: `pb_access=${token}` };
|
||||
}
|
||||
|
||||
async function buildAuthHeaders() {
|
||||
return buildAuthHeadersFor(TEST_USER);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
||||
inMemoryProjects.length = 0;
|
||||
});
|
||||
|
||||
describe("/api/projects", () => {
|
||||
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
||||
const payload = {
|
||||
@@ -107,10 +129,12 @@ describe("/api/projects", () => {
|
||||
|
||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const createResponse = await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
@@ -127,8 +151,10 @@ describe("/api/projects", () => {
|
||||
|
||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const getHeaders = await buildAuthHeaders();
|
||||
|
||||
const getResponse = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
|
||||
{ params: { slug: payload.slug } } as any,
|
||||
);
|
||||
|
||||
@@ -153,10 +179,12 @@ describe("/api/projects", () => {
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
@@ -166,14 +194,16 @@ describe("/api/projects", () => {
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
|
||||
const listHeaders = await buildAuthHeaders();
|
||||
|
||||
const res = await listProjects(new Request(`${BASE_URL}/api/projects`, { headers: listHeaders }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const list = (await res.json()) as any[];
|
||||
@@ -193,25 +223,29 @@ describe("/api/projects", () => {
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const deleteHeaders = await buildAuthHeaders();
|
||||
|
||||
const deleteResponse = await deleteProject(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: deleteHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
||||
const getAfterDelete = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`),
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
|
||||
@@ -247,10 +281,12 @@ describe("/api/projects", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const firstRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(firstPayload),
|
||||
}),
|
||||
);
|
||||
@@ -260,7 +296,7 @@ describe("/api/projects", () => {
|
||||
const secondRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(secondPayload),
|
||||
}),
|
||||
);
|
||||
@@ -272,4 +308,174 @@ describe("/api/projects", () => {
|
||||
expect(updated.title).toBe("두 번째 제목");
|
||||
expect(updated.contentJson).toEqual(secondPayload.contentJson);
|
||||
});
|
||||
|
||||
it("GET /api/projects 는 현재 로그인 유저의 프로젝트만 반환해야 한다", async () => {
|
||||
const { POST: createProject, GET: listProjects } = await import("@/app/api/projects/route");
|
||||
|
||||
const slugA = "user-a-project";
|
||||
const slugB = "user-b-project";
|
||||
|
||||
const headersA = await buildAuthHeadersFor(TEST_USER);
|
||||
const headersB = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payloadA = {
|
||||
title: "유저 A 프로젝트",
|
||||
slug: slugA,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const payloadB = {
|
||||
title: "유저 B 프로젝트",
|
||||
slug: slugB,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headersA },
|
||||
body: JSON.stringify(payloadA),
|
||||
}),
|
||||
);
|
||||
|
||||
await createProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headersB },
|
||||
body: JSON.stringify(payloadB),
|
||||
}),
|
||||
);
|
||||
|
||||
const resA = await listProjects(
|
||||
new Request(`${BASE_URL}/api/projects`, { headers: headersA }),
|
||||
);
|
||||
expect(resA.status).toBe(200);
|
||||
const listA = (await resA.json()) as any[];
|
||||
const slugsA = listA.map((p) => p.slug);
|
||||
expect(slugsA).toContain(slugA);
|
||||
expect(slugsA).not.toContain(slugB);
|
||||
|
||||
const resB = await listProjects(
|
||||
new Request(`${BASE_URL}/api/projects`, { headers: headersB }),
|
||||
);
|
||||
expect(resB.status).toBe(200);
|
||||
const listB = (await resB.json()) as any[];
|
||||
const slugsB = listB.map((p) => p.slug);
|
||||
expect(slugsB).toContain(slugB);
|
||||
expect(slugsB).not.toContain(slugA);
|
||||
});
|
||||
|
||||
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
|
||||
const slug = "shared-slug";
|
||||
|
||||
const ownerPayload = {
|
||||
title: "소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const otherPayload = {
|
||||
title: "다른 유저 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const firstRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(ownerPayload),
|
||||
}),
|
||||
);
|
||||
expect(firstRes.status).toBe(201);
|
||||
|
||||
const secondRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...otherHeaders },
|
||||
body: JSON.stringify(otherPayload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(secondRes.status).toBe(409);
|
||||
});
|
||||
|
||||
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const slug = "owner-only-slug";
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payload = {
|
||||
title: "소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const createRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const resOther = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: otherHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOther.status).toBe(404);
|
||||
|
||||
const resOwner = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOwner.status).toBe(200);
|
||||
});
|
||||
|
||||
it("DELETE /api/projects/[slug] 는 소유자가 아닌 유저가 호출하면 404 를 반환하고 실제 데이터는 삭제되지 않아야 한다", async () => {
|
||||
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||
|
||||
const slug = "owner-delete-slug";
|
||||
|
||||
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||
|
||||
const payload = {
|
||||
title: "삭제 소유자 프로젝트",
|
||||
slug,
|
||||
contentJson: [],
|
||||
};
|
||||
|
||||
const createRes = await handleProject(
|
||||
new Request(`${BASE_URL}/api/projects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const deleteResOther = await deleteProject(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: otherHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(deleteResOther.status).toBe(404);
|
||||
|
||||
const resOwner = await getProjectBySlug(
|
||||
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||
{ params: { slug } } as any,
|
||||
);
|
||||
expect(resOwner.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 인증 라우트 가드 E2E
|
||||
// - 비로그인 사용자가 보호된 페이지에 접근하면 /login 으로 리다이렉트되어야 한다.
|
||||
|
||||
test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
// /api/projects 응답을 401 으로 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "로그인이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
// /api/auth/me 를 401 으로 응답하도록 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/preview");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
|
||||
const email = `e2e+${Date.now()}@example.com`;
|
||||
const password = "securePass1";
|
||||
|
||||
// 백엔드/DB 에 의존하지 않고 프론트 동작만 검증하기 위해 관련 API 를 모두 목 처리한다.
|
||||
// isLoggedIn 플래그를 두고, 회원가입 전에는 /api/auth/me 가 401 을, 회원가입 후에는 200 을 반환하도록 시뮬레이션한다.
|
||||
let isLoggedIn = false;
|
||||
|
||||
await page.route("**/api/auth/signup", async (route) => {
|
||||
isLoggedIn = true;
|
||||
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-1", email }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
if (!isLoggedIn) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// 1) 회원가입
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("이메일").fill(email);
|
||||
await page.getByLabel("비밀번호").fill(password);
|
||||
await page.getByRole("button", { name: "회원가입" }).click();
|
||||
|
||||
// /projects 로 이동했는지, 헤더가 보이는지 확인
|
||||
await expect(page).toHaveURL(/\/projects/);
|
||||
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
|
||||
|
||||
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||
await page.goto("/editor");
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
|
||||
// 3) /preview 접근 확인
|
||||
await page.goto("/preview");
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수 있어야 한다", async ({ page }) => {
|
||||
type Project = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
let currentUser: "A" | "B" = "A";
|
||||
const projectsA: Project[] = [];
|
||||
const projectsB: Project[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const email = currentUser === "A" ? "user-a@example.com" : "user-b@example.com";
|
||||
const id = currentUser === "A" ? "user-a" : "user-b";
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id, email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const method = request.method();
|
||||
|
||||
const ownerProjects = currentUser === "A" ? projectsA : projectsB;
|
||||
const otherProjects = currentUser === "A" ? projectsB : projectsA;
|
||||
|
||||
if (url.pathname === "/api/projects" && method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(ownerProjects),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/projects" && method === "POST") {
|
||||
const bodyText = request.postData() ?? "{}";
|
||||
const body = JSON.parse(bodyText) as Partial<Project> & { title?: string; slug?: string };
|
||||
const slug = body.slug ?? "";
|
||||
const title = body.title ?? "";
|
||||
|
||||
const conflict = otherProjects.find((p) => p.slug === slug);
|
||||
if (conflict) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = ownerProjects.findIndex((p) => p.slug === slug);
|
||||
const now = nowIso();
|
||||
|
||||
let project: Project;
|
||||
if (existingIndex >= 0) {
|
||||
const existing = ownerProjects[existingIndex];
|
||||
project = { ...existing, title: title || existing.title, updatedAt: now };
|
||||
ownerProjects[existingIndex] = project;
|
||||
} else {
|
||||
project = {
|
||||
id: String(nextId++),
|
||||
title: title || "제목 없음",
|
||||
slug,
|
||||
status: "DRAFT",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
ownerProjects.push(project);
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
||||
});
|
||||
});
|
||||
|
||||
// 1) 유저 A: 에디터에서 프로젝트를 저장하고, /projects 에서 자신의 프로젝트만 보여야 한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
const propertiesSidebarA = page.getByTestId("properties-sidebar");
|
||||
await propertiesSidebarA.getByLabel("프로젝트 제목").fill("유저 A 프로젝트");
|
||||
await propertiesSidebarA.getByLabel("프로젝트 주소 (slug)").fill("user-a-project");
|
||||
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/_?projects/);
|
||||
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||
await expect(page.getByText("user-b-project")).toHaveCount(0);
|
||||
|
||||
// 2) 유저 B: currentUser 전환 후 별도 프로젝트를 저장하고, /projects 에서 자신의 것만 보여야 한다.
|
||||
currentUser = "B";
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
const propertiesSidebarB = page.getByTestId("properties-sidebar");
|
||||
await propertiesSidebarB.getByLabel("프로젝트 제목").fill("유저 B 프로젝트");
|
||||
await propertiesSidebarB.getByLabel("프로젝트 주소 (slug)").fill("user-b-project");
|
||||
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/_?projects/);
|
||||
await expect(page.getByText("유저 B 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-b-project")).toBeVisible();
|
||||
await expect(page.getByText("유저 A 프로젝트")).toHaveCount(0);
|
||||
|
||||
// 3) 다시 유저 A 로 전환했을 때, /projects 에서는 유저 A 의 프로젝트만 보여야 한다.
|
||||
currentUser = "A";
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
|
||||
});
|
||||
@@ -88,6 +88,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 자동저장", () => {
|
||||
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
|
||||
const storageProto = Object.getPrototypeOf(window.localStorage);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 버튼 블록 스타일", () => {
|
||||
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -86,6 +86,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
|
||||
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 이미지 블록 스타일", () => {
|
||||
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -90,6 +90,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function openJsonModal() {
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
||||
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const clean = hex.replace("#", "");
|
||||
const int = parseInt(clean, 16);
|
||||
|
||||
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function setupThreeBlocks() {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
// editorStore 는 이미 여러 유닛 테스트에서 사용 중이므로 실제 store 를 그대로 사용해도 무방하다.
|
||||
|
||||
describe("EditorPage 인증 가드", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 요청은 200 으로 처리.
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 페이지/캔버스 배경", () => {
|
||||
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
||||
const { container } = render(<EditorPage />);
|
||||
|
||||
@@ -89,6 +89,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -96,6 +96,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
@@ -135,7 +144,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const localKey = "pb:project:save-test-slug";
|
||||
@@ -147,7 +160,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
||||
expect(parsed.contentJson[0].id).toBe("blk_1");
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
const projectCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||
) as any;
|
||||
|
||||
const [url, options] = projectCall;
|
||||
expect(url).toBe("/api/projects");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
@@ -162,6 +179,55 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||
} as any);
|
||||
}
|
||||
|
||||
// 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
} as any);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { rerender } = render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||
fireEvent.click(projectMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||
|
||||
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
|
||||
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
|
||||
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
|
||||
|
||||
rerender(<EditorPage />);
|
||||
|
||||
const saveButton = screen.getByText("저장 (로컬 + 서버)");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
|
||||
expect(message).toBeTruthy();
|
||||
});
|
||||
|
||||
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
|
||||
const slug = "local-test-slug";
|
||||
const localKey = `pb:project:${slug}`;
|
||||
@@ -210,7 +276,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
});
|
||||
|
||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const serverCalls = fetchMock.mock.calls.filter(
|
||||
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
|
||||
);
|
||||
expect(serverCalls.length).toBe(0);
|
||||
|
||||
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||
expect(message).toBeTruthy();
|
||||
@@ -253,10 +323,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as any;
|
||||
const serverCall = fetchMock.mock.calls.find(
|
||||
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||
) as any;
|
||||
|
||||
const [url] = serverCall;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -293,10 +371,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
const deleteCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||
) as any;
|
||||
|
||||
const [url, options] = deleteCall;
|
||||
expect(url).toBe(`/api/projects/${slug}`);
|
||||
expect(options.method).toBe("DELETE");
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// 섹션 배경 이미지 TDD
|
||||
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
|
||||
@@ -76,6 +76,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 섹션 레이아웃 속성", () => {
|
||||
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
||||
const section: Block = {
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 선택 포커스", () => {
|
||||
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - 텍스트 블록 스타일", () => {
|
||||
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
|
||||
@@ -74,6 +74,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
||||
describe("EditorPage - 비디오 블록 스타일", () => {
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import LoginPage from "@/app/login/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/login" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/login" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(loginCall).toBeDefined();
|
||||
});
|
||||
|
||||
const loginCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/login") as any;
|
||||
const [url, options] = loginCall;
|
||||
expect(url).toBe("/api/auth/login");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.email).toBe("user@example.com");
|
||||
expect(body.password).toBe("securePass1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
|
||||
it("로그인 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/login" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const errorText = await screen.findByText(/이메일 또는 비밀번호가 올바르지 않습니다./);
|
||||
expect(errorText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - 자동 복원", () => {
|
||||
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
|
||||
const savedBlocks: Block[] = [
|
||||
|
||||
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,15 @@ vi.mock("next/link", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - ZIP Export", () => {
|
||||
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
@@ -83,10 +92,18 @@ describe("PreviewPage - ZIP Export", () => {
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
const exportCall = fetchMock.mock.calls.find(
|
||||
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||
) as any;
|
||||
|
||||
const [url, options] = exportCall;
|
||||
expect(url).toBe("/api/export");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage 인증 가드", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 기타 요청은 200 으로 처리하되, 테스트에서는 사용하지 않는다.
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import ProjectsPage from "@/app/projects/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
// ProjectsPage 프로젝트 목록 TDD
|
||||
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
||||
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
||||
@@ -228,4 +238,229 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/logout" && method === "POST") {
|
||||
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||
fireEvent.click(logoutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/logout" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(logoutCall).toBeDefined();
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response("server error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "삭제 실패 프로젝트",
|
||||
slug: "delete-fail-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/delete-fail-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||
|
||||
const deleteButton = screen.getByText("삭제");
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).toBeTruthy();
|
||||
|
||||
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
|
||||
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
|
||||
const projects = [
|
||||
{
|
||||
id: "1",
|
||||
title: "성공 프로젝트",
|
||||
slug: "success-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "실패 프로젝트",
|
||||
slug: "fail-project",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/projects" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(projects), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/projects/success-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
|
||||
if (url === "/api/projects/fail-project" && method === "DELETE") {
|
||||
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
|
||||
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
|
||||
|
||||
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
|
||||
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
|
||||
|
||||
fireEvent.click(checkboxSuccess);
|
||||
fireEvent.click(checkboxFail);
|
||||
|
||||
const bulkDeleteButton = screen.getByText("선택 삭제");
|
||||
fireEvent.click(bulkDeleteButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
|
||||
expect(screen.queryByText("성공 프로젝트")).toBeNull();
|
||||
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
import SignupPage from "@/app/signup/page";
|
||||
|
||||
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||
export const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
};
|
||||
});
|
||||
|
||||
describe("SignupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/signup" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||
return url === "/api/auth/signup" && options?.method === "POST";
|
||||
});
|
||||
|
||||
expect(signupCall).toBeDefined();
|
||||
});
|
||||
|
||||
const signupCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/signup") as any;
|
||||
const [url, options] = signupCall;
|
||||
expect(url).toBe("/api/auth/signup");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.email).toBe("new@example.com");
|
||||
expect(body.password).toBe("securePass1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
|
||||
it("회원가입 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
if (url === "/api/auth/me" && method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/auth/signup" && method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ message: "이미 가입된 이메일입니다." }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
|
||||
expect(errorText).toBeTruthy();
|
||||
});
|
||||
|
||||
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as any);
|
||||
|
||||
render(<SignupPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
// 앞으로 구현할 인증/보안 유틸리티 모듈을 대상으로 TDD를 진행한다.
|
||||
// - 비밀번호 해시/검증 (bcrypt 기반)
|
||||
// - JWT 액세스 토큰 발급/검증
|
||||
// - 민감정보 JSON 암호화/복호화
|
||||
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
} from "@/features/auth/authCrypto";
|
||||
|
||||
interface TestUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
describe("authCrypto 유틸리티", () => {
|
||||
beforeEach(() => {
|
||||
// 테스트 환경에서 사용할 JWT/암호화 키를 설정한다.
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-key";
|
||||
// 32바이트 키를 hex 로 표현 (예시)
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
});
|
||||
|
||||
describe("비밀번호 해시/검증", () => {
|
||||
it("8자 이상 비밀번호를 bcrypt 해시로 안전하게 저장하고, 검증에 성공해야 한다", async () => {
|
||||
const password = "securePass1";
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
expect(hash).toBeTypeOf("string");
|
||||
expect(hash).not.toBe(password);
|
||||
|
||||
const ok = await verifyPassword(password, hash);
|
||||
const fail = await verifyPassword("wrongPass", hash);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(fail).toBe(false);
|
||||
});
|
||||
|
||||
it("8자 미만 비밀번호는 해시 시도 시 오류를 발생시켜야 한다", async () => {
|
||||
const shortPassword = "short"; // 5자
|
||||
|
||||
await expect(hashPassword(shortPassword)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT 액세스 토큰", () => {
|
||||
it("유저 정보를 기반으로 JWT 액세스 토큰을 발급하고, 검증 시 동일한 정보가 나와야 한다", async () => {
|
||||
const user: TestUser = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
tokenVersion: 1,
|
||||
};
|
||||
|
||||
const token = await signAccessToken(user);
|
||||
|
||||
expect(token).toBeTypeOf("string");
|
||||
expect(token.length).toBeGreaterThan(10);
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.sub).toBe(user.id);
|
||||
expect(payload?.email).toBe(user.email);
|
||||
expect(payload?.tokenVersion).toBe(user.tokenVersion);
|
||||
|
||||
const anyPayload = payload as any;
|
||||
const iat = anyPayload.iat;
|
||||
const exp = anyPayload.exp;
|
||||
|
||||
expect(typeof iat).toBe("number");
|
||||
expect(typeof exp).toBe("number");
|
||||
expect(exp > iat).toBe(true);
|
||||
});
|
||||
|
||||
it("잘못된 토큰이나 시크릿으로 검증할 경우 null 을 반환해야 한다", async () => {
|
||||
const user: TestUser = {
|
||||
id: "user-2",
|
||||
email: "user2@example.com",
|
||||
tokenVersion: 3,
|
||||
};
|
||||
|
||||
const token = await signAccessToken(user);
|
||||
|
||||
// 시크릿을 바꿔서 검증 실패 상황을 만든다.
|
||||
process.env.AUTH_JWT_SECRET = "other-secret";
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("민감정보 JSON 암호화/복호화", () => {
|
||||
it("JSON 객체를 암호화한 뒤 복호화하면 원래 객체와 동일해야 한다", async () => {
|
||||
const original = {
|
||||
cardToken: "tok_123",
|
||||
customerId: "cus_456",
|
||||
meta: { plan: "pro", country: "KR" },
|
||||
};
|
||||
|
||||
const ciphertext = await encryptJson(original);
|
||||
|
||||
expect(ciphertext).toBeTypeOf("string");
|
||||
expect(ciphertext).not.toContain("tok_123");
|
||||
expect(ciphertext).not.toContain("cus_456");
|
||||
|
||||
const decrypted = await decryptJson<typeof original>(ciphertext);
|
||||
|
||||
expect(decrypted).toEqual(original);
|
||||
});
|
||||
|
||||
it("암호화 문자열이 손상되었거나 키가 잘못된 경우 복호화 시 오류를 발생시켜야 한다", async () => {
|
||||
const original = { secret: "value" };
|
||||
const ciphertext = await encryptJson(original);
|
||||
|
||||
// 중간 일부를 잘라 손상시킨다.
|
||||
const broken = ciphertext.slice(0, Math.floor(ciphertext.length / 2));
|
||||
|
||||
await expect(decryptJson(broken as string)).rejects.toThrow();
|
||||
|
||||
// 키를 바꿔서 복호화 시도
|
||||
process.env.AUTH_ENCRYPTION_KEY = "ffffffffffffffffffffffffffffffff";
|
||||
await expect(decryptJson(ciphertext)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user