Skip to content

Commit aa3313d

Browse files
soobingclaude
andcommitted
test: subrequest 예산 회귀 테스트 및 AI 핸들러 분리 문서화
- tests/subrequest-budget.test.js: 5파일 PR 시나리오에서 tagPatterns(22회), postLearningStatus(15회) fetch 호출 수 검증 (Cloudflare 50 한도 하회) - handlers/internal-dispatch.test.js: 내부 디스패치 엔드포인트 인증·라우팅·에러 처리 테스트 - handlers/webhooks.test.js: AI 핸들러 self-fetch 디스패치 통합 테스트 추가 - AGENTS.md, README.md: AI 핸들러 Worker 분리 아키텍처 다이어그램 및 테스트 실행 가이드 - integration.yaml: vi.mock 전역 누출 회피를 위해 bun test handlers/, tests/ 를 별도 스텝으로 실행 - handlers/webhooks.js, wrangler.jsonc: WORKER_URL 을 명시적 env 로 요구하고 기본값은 wrangler vars 로 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba017d9 commit aa3313d

File tree

8 files changed

+654
-11
lines changed

8 files changed

+654
-11
lines changed

.github/workflows/integration.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@v6
1616
- uses: oven-sh/setup-bun@v2
17-
- run: bun test
17+
- run: bun test handlers/
18+
- run: bun test tests/

AGENTS.md

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,27 @@ GitHub Organization webhook 수신용 엔드포인트
244244
4. Week 없음 → 경고 댓글 작성 (중복 방지: Bot이 작성한 경고 댓글이 이미 있으면 스킵)
245245
5. Week 있음 → 기존 경고 댓글 삭제 (Bot이 작성한 Week 경고 댓글만)
246246

247+
### 4. AI 핸들러 Worker 분리 아키텍처
248+
249+
PR 이벤트를 받으면 webhook 핸들러가 두 AI 핸들러(`tagPatterns`, `postLearningStatus`)를 **별도 Worker invocation**으로 분리 디스패치한다. 각 invocation은 독립적인 Cloudflare subrequest 예산(50)을 가지므로 파일이 많은 PR에서도 예산 초과를 방지한다.
250+
251+
```
252+
GitHub webhook
253+
254+
255+
[Invocation #1] webhook 핸들러 ← 50 subrequest 예산
256+
│ ctx.waitUntil(fetch("/internal/tag-patterns")) ─┐ self-fetch는
257+
│ ctx.waitUntil(fetch("/internal/learning-status")) ─┤ 외부 요청이라
258+
│ │ 새 invocation 트리거
259+
├──────────────▶ [Invocation #2] tagPatterns ◀─┘ ← 독립 50 예산
260+
261+
└──────────────▶ [Invocation #3] postLearningStatus ← 독립 50 예산
262+
```
263+
264+
- `INTERNAL_SECRET``WORKER_URL`이 모두 설정되어야 활성화된다. 둘 중 하나라도 없으면 기존처럼 같은 invocation에서 순차 실행(subrequest 예산 공유)되어 파일이 많은 PR에서 예산을 초과할 수 있다.
265+
- 내부 엔드포인트는 `/internal/tag-patterns`, `/internal/learning-status`이며 `X-Internal-Secret` 헤더로 인증한다.
266+
- 참고: `tests/subrequest-budget.test.js`가 5개 파일 변경 시나리오에서 각 핸들러의 fetch 호출 수(각각 22, 15회)를 회귀 테스트로 박아둔다.
267+
247268
## 보안 및 권한
248269

249270
### DaleStudy Organization 전용
@@ -330,11 +351,14 @@ wrangler secret put OPENAI_API_KEY
330351
wrangler secret put WEBHOOK_SECRET
331352

332353
# Internal Dispatch Secret (AI 핸들러 Worker 분리용, 권장)
333-
# 설정하면 tagPatterns, learningStatus, complexityAnalysis가
334-
# 별도 Worker 호출로 디스패치되어 각각 독립적인 subrequest 예산을 가짐
354+
# 설정하면 tagPatterns, learningStatus가 별도 Worker 호출로
355+
# 디스패치되어 각각 독립적인 subrequest 예산을 가짐.
356+
# WORKER_URL과 함께 설정되어야 활성화된다.
335357
wrangler secret put INTERNAL_SECRET
336358
```
337359

360+
`WORKER_URL``wrangler.jsonc``vars`에 정의되어 있어 기본 배포에는 추가 설정이 필요 없다. 스테이징/다른 계정 등으로 배포할 때만 덮어쓰면 된다.
361+
338362
### 5. GitHub App 설치
339363

340364
저장소에 App이 설치되어 있는지 확인:
@@ -364,15 +388,79 @@ curl -X POST https://github.dalestudy.com/check-weeks \
364388
- ❌ npm 패키지 대부분 호환 안 됨 (@octokit/app 등)
365389
- ✅ 순수 JavaScript + Web APIs로 구현
366390

391+
## 테스트
392+
393+
이 프로젝트는 [Bun](https://bun.sh)의 내장 테스트 러너를 사용합니다. 별도의 `package.json`이나 의존성 설치 없이 테스트를 작성하고 실행할 수 있습니다.
394+
395+
### 테스트 실행
396+
397+
테스트는 `handlers/`(핸들러별 단위 테스트)와 `tests/`(프로세스 격리가 필요한 테스트)로 나뉘어 있다. Bun의 `vi.mock()`은 프로세스 전역 레지스트리에 등록되어 같은 실행 내에서 다른 파일로 누출되므로, 같은 모듈을 모킹하는 테스트와 실제 구현을 호출하는 테스트는 **별도 `bun test` 프로세스로 실행**해야 한다.
398+
399+
```bash
400+
# 전체 테스트 실행 (두 디렉토리를 별도 프로세스로)
401+
bun test handlers/ && bun test tests/
402+
403+
# 특정 파일만 실행
404+
bun test handlers/webhooks.test.js
405+
406+
# 감시 모드 (파일 변경 시 자동 재실행)
407+
bun test handlers/ --watch
408+
```
409+
410+
Bun 설치: https://bun.sh/docs/installation
411+
412+
### 테스트 파일 작성 규칙
413+
414+
- 테스트 파일은 대상 파일과 같은 디렉토리에 `*.test.js` 이름으로 배치합니다.
415+
- 예: `handlers/webhooks.js``handlers/webhooks.test.js`
416+
- `bun:test`에서 제공하는 API(`describe`, `it`, `expect`, `vi`)를 사용합니다.
417+
- 외부 의존성(`utils/github.js` 등)은 `vi.mock()`으로 대체하고, `fetch``globalThis.fetch = vi.fn()...`로 스텁합니다.
418+
419+
```javascript
420+
import { describe, it, expect, vi, beforeEach } from "bun:test";
421+
422+
vi.mock("../utils/github.js", () => ({
423+
generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"),
424+
}));
425+
426+
import { checkWeeks } from "./check-weeks.js";
427+
428+
describe("checkWeeks", () => {
429+
beforeEach(() => {
430+
vi.clearAllMocks();
431+
globalThis.fetch = vi.fn().mockResolvedValue({
432+
ok: true,
433+
json: () => Promise.resolve([]),
434+
});
435+
});
436+
437+
it("returns 403 for non-DaleStudy organization", async () => {
438+
const request = new Request("https://example.com/check-weeks", {
439+
method: "POST",
440+
headers: { "Content-Type": "application/json" },
441+
body: JSON.stringify({ repo_owner: "OtherOrg", repo_name: "leetcode-study" }),
442+
});
443+
444+
const response = await checkWeeks(request, {});
445+
expect(response.status).toBe(403);
446+
});
447+
});
448+
```
449+
450+
### CI 자동 실행
451+
452+
`.github/workflows/integration.yaml`이 모든 Pull Request와 `main` 브랜치 푸시에서 `bun test handlers/``bun test tests/`를 각각 별도 스텝으로 자동 실행합니다. 테스트가 실패하면 PR 체크가 실패하므로, 머지 전에 반드시 통과시켜야 합니다.
453+
367454
## 새 기능 추가 가이드
368455

369456
새로운 자동화 기능을 추가할 때 다음 단계를 따르세요:
370457

371458
1. **엔드포인트 추가**: `index.js``fetch()` 함수에 새로운 pathname 라우팅 추가
372459
2. **핸들러 함수 작성**: 비즈니스 로직을 별도 함수로 분리 (예: `handleCheckAllPrs`)
373460
3. **GitHub App 권한 확인**: 필요한 권한이 있는지 확인하고 없으면 추가
374-
4. **문서 업데이트**: AGENTS.md, README.md에 새 기능 문서화
375-
5. **테스트**: 로컬(`wrangler dev`)에서 먼저 테스트 후 배포
461+
4. **테스트 작성**: 핸들러 옆에 `*.test.js`를 추가하고 `bun test`로 통과 확인 (위 "테스트" 섹션 참고)
462+
5. **문서 업데이트**: AGENTS.md, README.md에 새 기능 문서화
463+
6. **로컬 실행 테스트**: `wrangler dev`로 실제 엔드포인트 동작 확인 후 배포
376464

377465
## 코드 수정 시 주의사항
378466

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,40 @@ https://github.dalestudy.com
168168
# 개발 서버 시작
169169
wrangler dev
170170

171-
# 로컬 테스트 (별도 터미널)
171+
# 로컬 엔드포인트 호출 (별도 터미널)
172172
curl -X POST http://localhost:8787/check-weeks \
173173
-H "Content-Type: application/json" \
174174
-d '{"repo_owner": "DaleStudy", "repo_name": "leetcode-study"}'
175175
```
176176

177-
### 프로덕션 테스트
177+
### 테스트 코드 실행
178+
179+
이 프로젝트는 **[Bun](https://bun.sh)의 내장 테스트 러너**를 사용합니다. `package.json`이나 `node_modules`가 없는 이유는 Bun이 런타임·테스트 러너·모킹 API(`vi.mock`, `vi.fn`)를 모두 내장하고 있어서 별도 설치 없이 바로 실행되기 때문입니다.
180+
181+
```bash
182+
# Bun 설치 (최초 1회) — https://bun.sh/docs/installation
183+
curl -fsSL https://bun.sh/install | bash
184+
185+
# 전체 테스트 실행 (두 디렉토리를 별도 프로세스로)
186+
bun test handlers/ && bun test tests/
187+
188+
# 특정 파일만 실행
189+
bun test handlers/webhooks.test.js
190+
191+
# 감시 모드 (파일 변경 시 자동 재실행)
192+
bun test handlers/ --watch
193+
```
194+
195+
테스트는 두 디렉토리로 나뉘어 있습니다:
196+
197+
- `handlers/*.test.js`: 대상 파일 옆에 두는 단위 테스트
198+
- `tests/*.test.js`: Bun `vi.mock()`의 전역 레지스트리 누출을 피하기 위해 별도 프로세스로 실행하는 테스트 (예: `subrequest-budget.test.js`)
199+
200+
자세한 작성 규칙과 예제는 `AGENTS.md`의 "테스트" 섹션을 참고하세요.
201+
202+
모든 Pull Request와 `main` 브랜치 푸시에서 `.github/workflows/integration.yaml`이 두 디렉토리의 테스트를 자동 실행합니다.
203+
204+
### 프로덕션 엔드포인트 호출
178205

179206
```bash
180207
curl -X POST https://github.dalestudy.com/check-weeks \

handlers/internal-dispatch.test.js

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { describe, it, expect, vi, beforeEach } from "bun:test";
2+
3+
vi.mock("../utils/github.js", () => ({
4+
generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"),
5+
}));
6+
7+
vi.mock("./tag-patterns.js", () => ({
8+
tagPatterns: vi.fn().mockResolvedValue({ tagged: true }),
9+
}));
10+
11+
vi.mock("./learning-status.js", () => ({
12+
postLearningStatus: vi.fn().mockResolvedValue({ posted: true }),
13+
}));
14+
15+
import { handleInternalDispatch } from "./internal-dispatch.js";
16+
import { tagPatterns } from "./tag-patterns.js";
17+
import { postLearningStatus } from "./learning-status.js";
18+
import { generateGitHubAppToken } from "../utils/github.js";
19+
20+
const VALID_SECRET = "test-secret-123";
21+
22+
function makeRequest(pathname, { secret, body } = {}) {
23+
const headers = { "Content-Type": "application/json" };
24+
if (secret !== undefined) {
25+
headers["X-Internal-Secret"] = secret;
26+
}
27+
return new Request(`https://example.com${pathname}`, {
28+
method: "POST",
29+
headers,
30+
body: JSON.stringify(body ?? {}),
31+
});
32+
}
33+
34+
describe("handleInternalDispatch — authentication", () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
});
38+
39+
it("returns 401 when INTERNAL_SECRET is not configured in env", async () => {
40+
const request = makeRequest("/internal/tag-patterns", {
41+
secret: "anything",
42+
body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 },
43+
});
44+
45+
const response = await handleInternalDispatch(
46+
request,
47+
{},
48+
"/internal/tag-patterns"
49+
);
50+
51+
expect(response.status).toBe(401);
52+
const body = await response.json();
53+
expect(body.error).toBe("Unauthorized");
54+
expect(generateGitHubAppToken).not.toHaveBeenCalled();
55+
expect(tagPatterns).not.toHaveBeenCalled();
56+
});
57+
58+
it("returns 401 when X-Internal-Secret header is missing", async () => {
59+
const request = makeRequest("/internal/tag-patterns", {
60+
body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 },
61+
});
62+
63+
const response = await handleInternalDispatch(
64+
request,
65+
{ INTERNAL_SECRET: VALID_SECRET },
66+
"/internal/tag-patterns"
67+
);
68+
69+
expect(response.status).toBe(401);
70+
expect(tagPatterns).not.toHaveBeenCalled();
71+
});
72+
73+
it("returns 401 when X-Internal-Secret header does not match env.INTERNAL_SECRET", async () => {
74+
const request = makeRequest("/internal/tag-patterns", {
75+
secret: "wrong-secret",
76+
body: { repoOwner: "DaleStudy", repoName: "leetcode-study", prNumber: 1 },
77+
});
78+
79+
const response = await handleInternalDispatch(
80+
request,
81+
{ INTERNAL_SECRET: VALID_SECRET },
82+
"/internal/tag-patterns"
83+
);
84+
85+
expect(response.status).toBe(401);
86+
expect(tagPatterns).not.toHaveBeenCalled();
87+
});
88+
});
89+
90+
describe("handleInternalDispatch — routing", () => {
91+
const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" };
92+
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
});
96+
97+
it("routes /internal/tag-patterns to tagPatterns with payload fields", async () => {
98+
const prData = { number: 42, head: { sha: "abc123" } };
99+
const request = makeRequest("/internal/tag-patterns", {
100+
secret: VALID_SECRET,
101+
body: {
102+
repoOwner: "DaleStudy",
103+
repoName: "leetcode-study",
104+
prNumber: 42,
105+
headSha: "abc123",
106+
prData,
107+
},
108+
});
109+
110+
const response = await handleInternalDispatch(
111+
request,
112+
env,
113+
"/internal/tag-patterns"
114+
);
115+
116+
expect(response.status).toBe(200);
117+
const body = await response.json();
118+
expect(body.handler).toBe("tag-patterns");
119+
expect(tagPatterns).toHaveBeenCalledWith(
120+
"DaleStudy",
121+
"leetcode-study",
122+
42,
123+
"abc123",
124+
prData,
125+
"fake-token",
126+
"fake-openai"
127+
);
128+
expect(postLearningStatus).not.toHaveBeenCalled();
129+
});
130+
131+
it("routes /internal/learning-status to postLearningStatus with payload fields", async () => {
132+
const request = makeRequest("/internal/learning-status", {
133+
secret: VALID_SECRET,
134+
body: {
135+
repoOwner: "DaleStudy",
136+
repoName: "leetcode-study",
137+
prNumber: 42,
138+
username: "testuser",
139+
},
140+
});
141+
142+
const response = await handleInternalDispatch(
143+
request,
144+
env,
145+
"/internal/learning-status"
146+
);
147+
148+
expect(response.status).toBe(200);
149+
const body = await response.json();
150+
expect(body.handler).toBe("learning-status");
151+
expect(postLearningStatus).toHaveBeenCalledWith(
152+
"DaleStudy",
153+
"leetcode-study",
154+
42,
155+
"testuser",
156+
"fake-token",
157+
"fake-openai"
158+
);
159+
expect(tagPatterns).not.toHaveBeenCalled();
160+
});
161+
162+
it("returns 404 for an unknown /internal/* pathname", async () => {
163+
const request = makeRequest("/internal/unknown", {
164+
secret: VALID_SECRET,
165+
body: {},
166+
});
167+
168+
const response = await handleInternalDispatch(
169+
request,
170+
env,
171+
"/internal/unknown"
172+
);
173+
174+
expect(response.status).toBe(404);
175+
expect(tagPatterns).not.toHaveBeenCalled();
176+
expect(postLearningStatus).not.toHaveBeenCalled();
177+
});
178+
});
179+
180+
describe("handleInternalDispatch — error handling", () => {
181+
const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" };
182+
183+
beforeEach(() => {
184+
vi.clearAllMocks();
185+
});
186+
187+
it("returns 500 when the handler throws", async () => {
188+
tagPatterns.mockRejectedValueOnce(new Error("boom"));
189+
190+
const request = makeRequest("/internal/tag-patterns", {
191+
secret: VALID_SECRET,
192+
body: {
193+
repoOwner: "DaleStudy",
194+
repoName: "leetcode-study",
195+
prNumber: 1,
196+
headSha: "sha",
197+
prData: {},
198+
},
199+
});
200+
201+
const response = await handleInternalDispatch(
202+
request,
203+
env,
204+
"/internal/tag-patterns"
205+
);
206+
207+
expect(response.status).toBe(500);
208+
const body = await response.json();
209+
expect(body.error).toContain("boom");
210+
});
211+
});

0 commit comments

Comments
 (0)