Skip to content
Merged
72 changes: 72 additions & 0 deletions .claude/commands/write-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
Write unit tests for the specified file or feature: $ARGUMENTS

## Project Testing Context

- **Framework**: Vitest + React Testing Library
- **Coverage**: `@vitest/coverage-istanbul`
- **Setup file**: `vitest.setup.ts` (`@testing-library/jest-dom` 자동 적용)
- **Path alias**: `@/` maps to `src/`
- **Run tests**: `npm run test:run src/path/to/file.test.ts`
- **Run all**: `npm run test:run`
- **Coverage**: `npm run test:coverage`

## Instructions

1. **Read the target file first** before writing any tests. Understand what it actually does.

2. **Follow existing test patterns** — check nearby `__tests__/` files for conventions used in this codebase.

3. **Test structure**:
- Place test file in `__tests__/` directory next to the source file
- `foo.ts` → `__tests__/foo.test.ts`
- Group with `describe`, name cases with `it`
- Import `describe`, `it`, `expect`, `vi`, `beforeEach`, `afterEach` from `vitest` (globals: false)

4. **Mocking**:
- API 함수: `vi.mock("@/entities/.../api/...")`
- 외부 라이브러리: `vi.mock("sonner")`, `vi.mock("next/navigation")` 등
- `vi.mocked(fn)`으로 타입 안전하게 사용
- 각 테스트 전 `vi.clearAllMocks()` 호출

5. **React components**: `render`, `screen`, `userEvent` from `@testing-library/react`
```ts
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
```

6. **Next.js middleware**: `NextRequest`를 직접 생성해서 테스트
```ts
import { NextRequest } from "next/server";
new NextRequest(new URL("/path", "http://localhost"), {
headers: { Cookie: "accessToken=abc; refreshToken=xyz" },
});
```

7. **Date-dependent logic**: `vi.setSystemTime(new Date(...))` + `vi.useRealTimers()`

8. **cookies (jsdom)**: `document.cookie`로 직접 설정/초기화
```ts
beforeEach(() => {
document.cookie.split(";").forEach(c => {
const name = c.split("=")[0].trim();
if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
});
});
```

9. **What to test**:
- Happy path (정상 동작)
- Error states (API 실패, 유효성 오류)
- Edge cases (오픈 리다이렉트 방지, 권한 분기 등)
- User interactions (UI 컴포넌트)

10. **What NOT to test**:
- 구현 세부사항 (내부 상태, private 메서드)
- 서드파티 라이브러리 내부 동작
- 단순 pass-through 코드

11. After writing, run the tests to confirm they pass:
```bash
npm run test:run src/path/to/__tests__/file.test.ts
```
Fix any failures before finishing.
165 changes: 165 additions & 0 deletions src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { middleware } from "../middleware";

vi.mock("@/shared/config/authConfig", () => ({
publicPages: ["/home", "/signin", "/signup", "/admin", "/vote", "/slogan"],
publicIn18: ["/booking"],
publicIn27: ["/vote"],
ticketOpenDate: new Date("2025-09-18T20:00:00"),
performerTicketOpenDate: new Date("2025-09-15T20:00:00"),
festivalDate: new Date("2025-09-27T00:00:00"),
sloganStartDate: new Date("2026-05-18T00:00:00+09:00"),
sloganEndDate: new Date("2026-05-28T23:59:59+09:00"),
}));

function makeRequest(path: string, cookies: Record<string, string> = {}) {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");

return new NextRequest(new URL(path, "http://localhost"), {
headers: cookieHeader ? { Cookie: cookieHeader } : {},
});
}

function getLocation(res: Response) {
return res.headers.get("location");
}

describe("middleware - 라우팅 보호", () => {
it("토큰 없이 보호된 경로 접근 시 /signin으로 리다이렉트한다", () => {
const res = middleware(makeRequest("/my-page"));
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/signin");
});

it("리다이렉트 URL에 next 파라미터가 포함된다", () => {
const res = middleware(makeRequest("/my-page"));
expect(getLocation(res)).toContain("next=%2Fmy-page");
});

it("토큰이 있으면 보호된 경로를 통과시킨다", () => {
const res = middleware(makeRequest("/my-page", { accessToken: "abc", refreshToken: "xyz" }));
expect(res.status).toBe(200);
});

it("/home은 토큰 없이 접근 가능하다", () => {
expect(middleware(makeRequest("/home")).status).toBe(200);
});

it("/signin은 토큰 없이 접근 가능하다", () => {
expect(middleware(makeRequest("/signin")).status).toBe(200);
});
});

describe("middleware - /signin 리다이렉트", () => {
it("로그인 상태에서 /signin 접근 시 /home으로 리다이렉트한다", () => {
const res = middleware(makeRequest("/signin", { accessToken: "abc", refreshToken: "xyz" }));
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/home");
});

it("올바른 next 파라미터가 있으면 해당 경로로 리다이렉트한다", () => {
const res = middleware(
makeRequest("/signin?next=%2Fvote", { accessToken: "abc", refreshToken: "xyz" }),
);
expect(getLocation(res)).toContain("/vote");
});

it("오픈 리다이렉트 방지: next가 /signin이면 /home으로 이동한다", () => {
const res = middleware(
makeRequest("/signin?next=%2Fsignin", { accessToken: "abc", refreshToken: "xyz" }),
);
expect(getLocation(res)).toContain("/home");
});

it("오픈 리다이렉트 방지: next가 외부 URL이면 /home으로 이동한다", () => {
const res = middleware(
makeRequest("/signin?next=https%3A%2F%2Fevil.com", { accessToken: "abc", refreshToken: "xyz" }),
);
expect(getLocation(res)).toContain("/home");
});
});

describe("middleware - 어드민 접근 제어", () => {
it("ROLE_ADMIN이 아니면 /admin 접근 시 /home으로 리다이렉트한다", () => {
const res = middleware(
makeRequest("/admin/dashboard", { accessToken: "abc", refreshToken: "xyz", role: "ROLE_USER" }),
);
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/home");
});

it("ROLE_ADMIN이면 /admin 접근이 허용된다", () => {
const res = middleware(
makeRequest("/admin/dashboard", { accessToken: "abc", refreshToken: "xyz", role: "ROLE_ADMIN" }),
);
expect(res.status).toBe(200);
});

it("/admin/lottery/:id는 비어드민도 접근 가능하다", () => {
const res = middleware(
makeRequest("/admin/lottery/123", { accessToken: "abc", refreshToken: "xyz", role: "ROLE_USER" }),
);
expect(res.status).toBe(200);
});

it("/admin/score/:id는 비어드민도 접근 가능하다", () => {
const res = middleware(
makeRequest("/admin/score/456", { accessToken: "abc", refreshToken: "xyz", role: "ROLE_USER" }),
);
expect(res.status).toBe(200);
});
});

describe("middleware - publicIn27 (축제 날짜 접근 제한)", () => {
afterEach(() => {
vi.useRealTimers();
});

it("축제 이전에는 비어드민의 /vote 접근을 /home으로 리다이렉트한다", () => {
vi.setSystemTime(new Date("2025-09-20T00:00:00"));
const res = middleware(makeRequest("/vote", { role: "ROLE_USER" }));
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/home");
});

it("축제 이후에는 비어드민도 /vote 접근이 허용된다", () => {
vi.setSystemTime(new Date("2025-09-28T00:00:00"));
const res = middleware(makeRequest("/vote", { role: "ROLE_USER" }));
expect(res.status).toBe(200);
});

it("어드민은 축제 이전에도 /vote 접근이 허용된다", () => {
vi.setSystemTime(new Date("2025-09-20T00:00:00"));
const res = middleware(makeRequest("/vote", { accessToken: "abc", refreshToken: "xyz", role: "ROLE_ADMIN" }));
expect(res.status).toBe(200);
});
});

describe("middleware - 슬로건 게이트", () => {
afterEach(() => {
vi.useRealTimers();
});

it("슬로건 기간 이전에는 /slogan 접근 시 /home으로 리다이렉트한다", () => {
vi.setSystemTime(new Date("2026-04-01T00:00:00"));
const res = middleware(makeRequest("/slogan", { accessToken: "abc", refreshToken: "xyz" }));
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/home");
});

it("슬로건 기간 내에는 /slogan 접근이 허용된다", () => {
vi.setSystemTime(new Date("2026-05-20T00:00:00"));
const res = middleware(makeRequest("/slogan", { accessToken: "abc", refreshToken: "xyz" }));
expect(res.status).toBe(200);
});

it("슬로건 기간 이후에는 /slogan 접근 시 /home으로 리다이렉트한다", () => {
vi.setSystemTime(new Date("2026-06-01T00:00:00"));
const res = middleware(makeRequest("/slogan", { accessToken: "abc", refreshToken: "xyz" }));
expect(res.status).toBe(307);
expect(getLocation(res)).toContain("/home");
});
});
Comment thread
g-hyxn marked this conversation as resolved.
Comment thread
g-hyxn marked this conversation as resolved.
93 changes: 93 additions & 0 deletions src/entities/user/model/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import {
passwordSchema,
verificationCodeSchema,
signinSchema,
signUpSchema,
} from "../schema";

describe("passwordSchema", () => {
it("8자 이상 영문+숫자 포함이면 유효하다", () => {
expect(passwordSchema.safeParse("abc12345").success).toBe(true);
});

it("8자 미만이면 실패한다", () => {
const result = passwordSchema.safeParse("ab1234");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("비밀번호는 최소 8자 이상이어야 합니다.");
});

it("숫자가 없으면 실패한다", () => {
const result = passwordSchema.safeParse("abcdefgh");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("비밀번호는 영문과 숫자를 포함해야 합니다.");
});

it("영문이 없으면 실패한다", () => {
const result = passwordSchema.safeParse("12345678");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("비밀번호는 영문과 숫자를 포함해야 합니다.");
});
});

describe("verificationCodeSchema", () => {
it("6자리면 유효하다", () => {
expect(verificationCodeSchema.safeParse("123456").success).toBe(true);
});

it("빈 값이면 실패한다", () => {
const result = verificationCodeSchema.safeParse("");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("인증번호를 입력해주세요.");
});

it("6자리 미만이면 실패한다", () => {
const result = verificationCodeSchema.safeParse("12345");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("인증번호는 6자리여야 합니다.");
});

it("6자리 초과면 실패한다", () => {
const result = verificationCodeSchema.safeParse("1234567");
expect(result.success).toBe(false);
expect(result.error?.errors[0].message).toBe("인증번호는 6자리여야 합니다.");
});
Comment thread
g-hyxn marked this conversation as resolved.
});

describe("signinSchema", () => {
it("올바른 전화번호와 비밀번호면 유효하다", () => {
expect(signinSchema.safeParse({ phoneNumber: "01012345678", password: "pass1234" }).success).toBe(true);
});

it("전화번호 형식이 잘못되면 실패한다", () => {
expect(signinSchema.safeParse({ phoneNumber: "010-1234-5678", password: "pass1234" }).success).toBe(false);
});

it("비밀번호가 짧으면 실패한다", () => {
expect(signinSchema.safeParse({ phoneNumber: "01012345678", password: "ab1" }).success).toBe(false);
});
});

describe("signUpSchema", () => {
it("모든 필드가 올바르면 유효하다", () => {
expect(
signUpSchema.safeParse({
phoneNumber: "01012345678",
verificationCode: "123456",
password: "pass1234",
passwordConfirm: "pass1234",
}).success,
).toBe(true);
});

it("인증코드가 없으면 실패한다", () => {
expect(
signUpSchema.safeParse({
phoneNumber: "01012345678",
verificationCode: "",
password: "pass1234",
passwordConfirm: "pass1234",
}).success,
).toBe(false);
});
});
Loading
Loading