Skip to content

Commit d8b9c2f

Browse files
authored
test: add week3 quality workflow (#49)
* test: add week3 quality workflow * ci: keep required check name
1 parent bd7010b commit d8b9c2f

28 files changed

Lines changed: 2173 additions & 219 deletions

.github/workflows/ci.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
check:
1616
name: typecheck + lint + build
1717
runs-on: ubuntu-latest
18-
timeout-minutes: 10
18+
timeout-minutes: 20
1919

2020
steps:
2121
- name: Checkout
@@ -68,6 +68,9 @@ jobs:
6868
- name: Lint
6969
run: pnpm lint
7070

71+
- name: Unit and component tests
72+
run: pnpm test
73+
7174
- name: Build
7275
run: pnpm build
7376
env:
@@ -80,3 +83,18 @@ jobs:
8083
AUTH_GOOGLE_SECRET: ci-stub
8184
AUTH_GOOGLE_HD: snu.ac.kr
8285
NEXT_PUBLIC_APP_URL: http://localhost:3000
86+
87+
- name: Install Playwright Chromium
88+
run: pnpm exec playwright install --with-deps chromium
89+
90+
- name: E2E smoke tests
91+
run: pnpm test:e2e
92+
env:
93+
DATABASE_URL: file:./data/e2e.db
94+
AUTH_URL: http://127.0.0.1:3000
95+
AUTH_SECRET: e2e-secret-e2e-secret-e2e-secret-e2e-secret
96+
AUTH_TRUST_HOST: "true"
97+
AUTH_GOOGLE_ID: e2e-stub
98+
AUTH_GOOGLE_SECRET: e2e-stub
99+
AUTH_GOOGLE_HD: snu.ac.kr
100+
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pnpm-debug.log*
3434
# Test/coverage
3535
coverage/
3636
.nyc_output
37+
playwright-report/
38+
test-results/
3739

3840
# OS
3941
.DS_Store

.gjc/skills/murun-feature/SKILL.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
- **데이터 모델 진실의 원천**: `prisma/schema.prisma`
1919
- **권한 매트릭스 진실의 원천**: [`docs/wiki/03-Screen-Flow.md`](../../../docs/wiki/03-Screen-Flow.md) §5
2020
- **인증/권한 진입점**: `lib/auth.ts` (Auth.js config) + `lib/guard.ts` (`requireApproved`, `requireAdmin`, `requireHost(sessionId)`). Server Action 첫 줄에서 호출.
21-
- **PR 대상은 항상 `dev`**. `main`에는 절대 직접 PR/커밋 금지.
21+
- **브랜치 정책**: prod 배포 대상 작업은 `main` 기준 feature/fix 브랜치에서 시작해 `main` PR로 끝낸다. `dev`는 로컬 통합/실험 브랜치이며 N100에는 배포하지 않는다.
2222

2323
## Steps
2424

@@ -43,16 +43,19 @@ Schema 변경이 있다면 별도로:
4343

4444
### 3. Branch
4545
```bash
46-
git switch dev && git pull
47-
git switch -c feat/<issue-num>-<slug>
46+
git switch main && git pull
47+
git switch -c feature/<issue-num>-<slug>
4848
```
4949

50+
버그 수정은 `fix/<issue-num>-<slug>` 를 쓴다. `dev`에서 시작하는 작업은 N100 prod 배포 대상이 아닌 실험/통합 작업으로 제한한다.
51+
5052
### 4. Implement (순서 고정)
51-
1. `prisma/schema.prisma` 수정 → `pnpm prisma migrate dev --name <slug>` **명령어만 출력**, 사용자 실행 대기
52-
2. `lib/...` 의 Server Action (**zod schema → guard 호출(인증/권한) → 비즈니스 로직 → revalidate**)
53-
3. `app/(app)/...` 의 page/component
54-
4. `loading.tsx`, `error.tsx`, 빈 상태 처리 누락 없는지 확인
55-
5. `tests/<feature>.test.ts` 스모크 1개 (도메인 함수 위주, e2e는 욕심내지 않음)
53+
1. 도메인/검증 로직을 `lib/...` 순수 함수로 먼저 분리한다.
54+
2. schema 변경이 있으면 `prisma/schema.prisma` 수정 → `pnpm prisma migrate dev --name <slug>` **명령어만 출력**, 사용자 실행 대기
55+
3. Server Action (**input parser/zod → guard 호출(인증/권한) → 비즈니스 로직 → revalidate**)
56+
4. `app/...` 의 page/component
57+
5. `loading.tsx`, `error.tsx`, 빈 상태, inline error, pending state 누락 없는지 확인
58+
6. 테스트 추가: 순수 함수는 Vitest, 컴포넌트는 RTL, 사용자 smoke는 Playwright
5659

5760
### 4.5 폼/입력이 들어가는 경우 (강제)
5861

@@ -77,7 +80,7 @@ PR #28, #29 의 사고 (silent noop, 더블 클릭 row 중복) 가 이 단계
7780
- client/server 가 같은 상수(`lib/upload-limits.ts`) 를 import 하는가 — 두 곳이 어긋나면 메시지/제한 불일치
7881

7982
### 5. Self-check
80-
[`docs/wiki/06-Checkpoints.md`](../../../docs/wiki/06-Checkpoints.md) 의 7개 항목을 PR 본문에 박은 채로 사용자가 직접 확인하도록 둔다. Agent는 박스를 자기가 채우지 않는다.
83+
[`docs/wiki/06-Checkpoints.md`](../../../docs/wiki/06-Checkpoints.md) 의 7개 항목을 PR 본문에 박은 채로 사용자가 직접 확인하도록 둔다. Agent는 박스를 자기가 채우지 않는다. 코드 변경이면 최소 `pnpm check`, smoke가 있으면 `pnpm test:e2e` 결과를 함께 남긴다.
8184

8285
### 6. PR 본문 작성
8386
- `Closes #<issue-num>`
@@ -87,7 +90,7 @@ PR #28, #29 의 사고 (silent noop, 더블 클릭 row 중복) 가 이 단계
8790

8891
## Hard constraints (절대 금지)
8992
- `prisma migrate deploy`, `prisma db push` 직접 실행
90-
- `main` 브랜치 변경 / merge
93+
- `main` 브랜치에 직접 커밋하거나 PR 없이 merge
9194
- `.env*` 파일에 실제 키 작성
9295
- **외부 콘솔 변경 시도**: Google Cloud Console (OAuth 클라이언트), duckdns, N100 호스트 SSH 명령, Docker compose 직접 실행
9396
- spec에 없는 기능 추가
@@ -97,6 +100,7 @@ PR #28, #29 의 사고 (silent noop, 더블 클릭 row 중복) 가 이 단계
97100
## Done criteria
98101
- 작업이 끝나면 다음을 출력한다:
99102
1. 변경 파일 목록
100-
2. 사용자가 직접 실행해야 할 명령어 목록 (`prisma migrate dev`, env 추가, 외부 콘솔 등록 등)
101-
3. 미해결 TODO (있다면)
102-
4. PR 본문 (markdown)
103+
2. 실행한 검증 명령어와 결과
104+
3. 사용자가 직접 실행해야 할 명령어 목록 (`prisma migrate dev`, env 추가, 외부 콘솔 등록 등)
105+
4. 미해결 TODO (있다면)
106+
5. PR 본문 (markdown)

README.md

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,17 @@
2424
## 3. 브랜치 전략
2525

2626
```
27-
main ← 배포 가능한 상태만 (보호 브랜치, dev → main PR로만 merge)
28-
dev ← 통합 브랜치, 모든 feature가 PR로 모임
29-
feat/* ← 작업 단위. PR 대상은 항상 dev
30-
fix/* ← 버그 수정
31-
chore/*← 설정/문서/리팩터링
27+
main ← N100 prod 배포 기준. 항상 배포 가능한 상태만 유지
28+
feature/* ← 기능/품질 개선 작업. PR 대상은 main
29+
fix/* ← prod 버그 수정. PR 대상은 main
30+
chore/* ← 설정/문서/리팩터링. PR 대상은 main
31+
dev ← 로컬 통합/실험 브랜치. N100에는 배포하지 않음
3232
```
3333

3434
PR 규칙:
35-
- `feat/* → dev` 는 self-review + 체크리스트 통과 후 merge (squash)
36-
- `dev → main` 은 매주 마지막에 배포 가능 상태에서 merge
35+
- 의미 있는 task 1개 = PR 1개 (atomic PR)
36+
- `feature/*`, `fix/*`, `chore/* → main` 은 self-review + CI 통과 후 squash merge
37+
- `main` merge 시 GitHub Actions가 GHCR prod image build/push 후 N100 prod를 재시작
3738
- PR 제목: `[feat] 세션 등록 폼 (#12)` 형태
3839

3940
## 4. 개발/문서 관리
@@ -44,7 +45,8 @@ PR 규칙:
4445
| Task 관리 | [GitHub Issues](https://github.com/boostcampwm-snu-2026-1/murun-peterabcd/issues) |
4546
| 작업 단위 | feature 단위 (vertical slice) — [`docs/wiki/04-Agent-Workflow.md`](./docs/wiki/04-Agent-Workflow.md) 참고 |
4647
| Agent skill | [`.gjc/skills/murun-feature/SKILL.md`](./.gjc/skills/murun-feature/SKILL.md) |
47-
| 회고 | [`docs/wiki/Retrospective-Week1.md`](./docs/wiki/Retrospective-Week1.md) |
48+
| Week 3 워크플로우 리포트 | [`docs/wiki/09-Agent-Workflow-Report.md`](./docs/wiki/09-Agent-Workflow-Report.md) |
49+
| 회고 | [`docs/wiki/Retrospective-Week1.md`](./docs/wiki/Retrospective-Week1.md), [`docs/wiki/Retrospective-Week2.md`](./docs/wiki/Retrospective-Week2.md) |
4850

4951
## 5. 빠른 시작
5052

@@ -86,12 +88,20 @@ pnpm db:studio # 또는 sqlite3 ./data/murun.db
8688
체크:
8789

8890
```bash
89-
pnpm typecheck # tsc --noEmit
90-
pnpm lint # eslint .
91-
pnpm build # Next.js 빌드 (Windows에선 standalone 비활성)
92-
pnpm check # 위 셋 일괄
91+
pnpm typecheck # tsc --noEmit
92+
pnpm lint # eslint .
93+
pnpm test # Vitest unit/component tests
94+
pnpm test:coverage # coverage report (참고 지표)
95+
pnpm test:e2e # Playwright smoke tests (최초 1회: pnpm exec playwright install chromium)
96+
pnpm build # Next.js 빌드 (Windows에선 standalone 비활성)
97+
pnpm check # typecheck + lint + test + build
9398
```
9499

100+
테스트 전략:
101+
- 순수 함수/검증 로직은 Vitest로 빠르게 고정한다.
102+
- props in → UI out 컴포넌트는 React Testing Library로 label/role/text 기준 검증한다.
103+
- Playwright는 느리므로 로그인 화면/오류 안내처럼 깨지면 치명적인 smoke만 둔다.
104+
95105
### 자체 서버 (N100) 배포
96106

97107
[`deploy/README.md`](./deploy/README.md) 참고. 요약:

app/login/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
5050
</div>
5151

5252
{errorMessage ? (
53-
<div className="w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
53+
<div
54+
role="alert"
55+
className="w-full rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
56+
>
5457
{errorMessage}
5558
</div>
5659
) : null}

app/sessions/[id]/_components/MyParticipationForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function MyParticipationForm({ sessionId, existing }: Props) {
6060
type="number"
6161
inputMode="decimal"
6262
step="0.1"
63-
min="0"
63+
min="0.01"
6464
max="1000"
6565
placeholder="예: 5.0"
6666
defaultValue={distanceDefault}

app/sessions/[id]/actions.ts

Lines changed: 6 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,13 @@ import { z } from "zod";
55

66
import { db } from "@/lib/db";
77
import { requireApproved } from "@/lib/guard";
8-
import { parseOptionalNumber } from "@/lib/pace";
8+
import { parseParticipationForm } from "@/lib/participation-form";
99

1010
export type ParticipationResult =
1111
| { ok: true }
1212
| { ok: false; error: string };
1313

1414
const sessionIdSchema = z.coerce.number().int().positive();
15-
const noteSchema = z
16-
.string()
17-
.max(500, "메모는 500자 이내로 입력하세요.")
18-
.optional()
19-
.or(z.literal(""));
2015

2116
function parseSessionIdField(formData: FormData): ParticipationResult & {
2217
value?: number;
@@ -51,57 +46,9 @@ export async function upsertParticipation(
5146
return { ok: false, error: "세션을 찾을 수 없습니다." };
5247
}
5348

54-
// 거리: 0 보다 커야 함, 1000 이하
55-
const distance = parseOptionalNumber(formData.get("distanceKm"), {
56-
min: 0.01,
57-
max: 1000,
58-
field: "거리(km)",
59-
});
60-
if (distance.error) return { ok: false, error: distance.error };
61-
62-
// 분: 0 ~ 1440
63-
const minutes = parseOptionalNumber(formData.get("durationMin"), {
64-
min: 0,
65-
max: 1440,
66-
field: "분",
67-
integer: true,
68-
});
69-
if (minutes.error) return { ok: false, error: minutes.error };
70-
71-
// 초: 0 ~ 59 (60 이상은 분으로 표현하라는 의미)
72-
const seconds = parseOptionalNumber(formData.get("durationSec"), {
73-
min: 0,
74-
max: 59,
75-
field: "초",
76-
integer: true,
77-
});
78-
if (seconds.error) return { ok: false, error: seconds.error };
79-
80-
let durationSec: number | null;
81-
if (minutes.value == null && seconds.value == null) {
82-
durationSec = null;
83-
} else {
84-
durationSec = (minutes.value ?? 0) * 60 + (seconds.value ?? 0);
85-
if (durationSec <= 0) durationSec = null;
86-
}
87-
88-
const noteParsed = noteSchema.safeParse(formData.get("note") ?? "");
89-
if (!noteParsed.success) {
90-
return {
91-
ok: false,
92-
error:
93-
noteParsed.error.issues[0]?.message ?? "메모가 올바르지 않습니다.",
94-
};
95-
}
96-
const noteValue = (noteParsed.data ?? "").trim();
97-
const note = noteValue ? noteValue : null;
98-
99-
if (distance.value == null && durationSec == null && !note) {
100-
return {
101-
ok: false,
102-
error: "거리 / 기록 / 메모 중 최소 한 가지를 입력하세요.",
103-
};
104-
}
49+
const parsedParticipation = parseParticipationForm(formData);
50+
if (!parsedParticipation.ok) return parsedParticipation;
51+
const { distanceKm, durationSec, note } = parsedParticipation.value;
10552

10653
await db.participation.upsert({
10754
where: {
@@ -110,12 +57,12 @@ export async function upsertParticipation(
11057
create: {
11158
sessionId,
11259
userId: user.id,
113-
distanceKm: distance.value,
60+
distanceKm,
11461
durationSec,
11562
note,
11663
},
11764
update: {
118-
distanceKm: distance.value,
65+
distanceKm,
11966
durationSec,
12067
note,
12168
},

app/sessions/page.tsx

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,20 @@ import Link from "next/link";
22

33
import { Button } from "@/components/ui/button";
44
import { requireApproved } from "@/lib/guard";
5+
import { listApprovedMembers, listSessions } from "@/lib/sessions";
56
import {
6-
listApprovedMembers,
7-
listSessions,
8-
type SessionFilters,
9-
} from "@/lib/sessions";
7+
buildSessionArchiveHref,
8+
parseSessionArchiveParams,
9+
type SessionArchiveSearchParams,
10+
} from "@/lib/session-filters";
1011

1112
import { EmptyState } from "./_components/EmptyState";
1213
import { FilterBar } from "./_components/FilterBar";
1314
import { SessionCard } from "./_components/SessionCard";
1415

1516
export const dynamic = "force-dynamic";
1617

17-
type SearchParams = {
18-
cursor?: string;
19-
q?: string;
20-
member?: string;
21-
month?: string;
22-
pmin?: string;
23-
pmax?: string;
24-
};
18+
type SearchParams = SessionArchiveSearchParams;
2519

2620
type PageProps = {
2721
searchParams: Promise<SearchParams>;
@@ -31,21 +25,7 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
3125
await requireApproved();
3226

3327
const sp = await searchParams;
34-
const cursorId = parseCursor(sp.cursor);
35-
const filters: SessionFilters = {
36-
q: sp.q?.trim() || undefined,
37-
memberId: sp.member?.trim() || undefined,
38-
month: sp.month?.trim() || undefined,
39-
pmin: parsePositiveInt(sp.pmin),
40-
pmax: parsePositiveInt(sp.pmax),
41-
};
42-
const hasActiveFilters = Boolean(
43-
filters.q ||
44-
filters.memberId ||
45-
filters.month ||
46-
filters.pmin != null ||
47-
filters.pmax != null,
48-
);
28+
const { cursorId, filters, hasActiveFilters } = parseSessionArchiveParams(sp);
4929

5030
const [page, members] = await Promise.all([
5131
listSessions({ cursorId, filters }),
@@ -54,7 +34,7 @@ export default async function SessionsArchivePage({ searchParams }: PageProps) {
5434

5535
const nextHref =
5636
page.nextCursorId != null
57-
? buildHref({ ...sp, cursor: String(page.nextCursorId) })
37+
? buildSessionArchiveHref({ ...sp, cursor: String(page.nextCursorId) })
5838
: null;
5939

6040
return (
@@ -127,27 +107,3 @@ function EmptyFilterResult() {
127107
);
128108
}
129109

130-
function parseCursor(raw: string | undefined): number | undefined {
131-
if (!raw) return undefined;
132-
const n = Number.parseInt(raw, 10);
133-
return Number.isFinite(n) && n > 0 ? n : undefined;
134-
}
135-
136-
function parsePositiveInt(raw: string | undefined): number | undefined {
137-
if (raw == null || raw === "") return undefined;
138-
const n = Number.parseInt(raw, 10);
139-
if (!Number.isFinite(n) || n < 0) return undefined;
140-
return n;
141-
}
142-
143-
/**
144-
* 기존 searchParams 를 유지하면서 cursor 만 갈아끼운 URL.
145-
*/
146-
function buildHref(params: Record<string, string | undefined>): string {
147-
const q = new URLSearchParams();
148-
for (const [k, v] of Object.entries(params)) {
149-
if (v != null && v !== "") q.set(k, v);
150-
}
151-
const s = q.toString();
152-
return s ? `/sessions?${s}` : "/sessions";
153-
}

0 commit comments

Comments
 (0)