Skip to content

feat: 에브리타임 시간표 조회 기능 구현 - #21

Merged
Siul49 merged 7 commits into
devfrom
feature/20-everytime-timetable
May 23, 2026
Merged

feat: 에브리타임 시간표 조회 기능 구현#21
Siul49 merged 7 commits into
devfrom
feature/20-everytime-timetable

Conversation

@kokkumong

Copy link
Copy Markdown
Collaborator

🚀 작업 내용 (What)

  • 에브리타임 공유 URL(https://everytime.kr/@XXXX) 또는 ICS 파일 업로드로 시간표 데이터를 가져오는 기능 구현
  • 시간표를 기반으로 모임 가능한 빈 시간대(TimeSlot[]) 자동 산출
  • POST /api/everytime/timetable API 엔드포인트 추가

📣 핵심 변경 이유 (Why)

  • 모임 일정 조율 시 구성원의 빈 시간을 자동으로 파악하기 위해 에브리타임 시간표 연동 필요
  • 에브리타임 로그인 API는 reCAPTCHA v3로 차단되어 공개 공유 URL API와 ICS 방식으로 구현

📸 스크린샷 (Visuals, 선택)

UI 변경 없음 (API 레이어만 추가)

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았나요?

🔗 관련 이슈 (Issue)

Close #20

yoohyun-1203 and others added 3 commits April 27, 2026 10:55
- 공유 URL(https://everytime.kr/@XXXX)에서 시간표 자동 추출
- api.everytime.kr XML API 연동 및 파싱
- ICS 파일 업로드 방식도 병행 지원
- 수업 시간 → 빈 시간(TimeSlot[]) 변환 로직 구현
- 단위 테스트 23개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6f97b8f1-54a9-42b8-9dd9-4379a027532e

📥 Commits

Reviewing files that changed from the base of the PR and between d81141b and 983f782.

⛔ Files ignored due to path filters (6)
  • docs/codex-work-context.md is excluded by !**/*.md
  • docs/provided-documents-summary.md is excluded by !**/*.md
  • docs/superpowers/plans/2026-04-27-moim-user-test-prototype.md is excluded by !**/*.md
  • docs/user-flow.md is excluded by !**/*.md
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (22)
  • .claude/worktrees/availability-aggregation
  • .env
  • src/app/(auth)/login/page.tsx
  • src/app/api/auth/callback/route.ts
  • src/app/api/everytime/timetable/route.ts
  • src/lib/everytime/__tests__/auth.test.ts
  • src/lib/everytime/__tests__/ics-converter.test.ts
  • src/lib/everytime/__tests__/timetable.test.ts
  • src/lib/everytime/__tests__/url-scraper.test.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/converter.ts
  • src/lib/everytime/ics-converter.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts
  • src/lib/supabase/__tests__/supabase.test.ts
  • src/lib/supabase/client.ts
  • src/lib/supabase/server.ts
  • src/middleware.ts
  • src/types/everytime.ts
  • supabase/migrations/20260413000000_init_profiles.sql
  • supabase/migrations/20260416000000_scheduling_sessions.sql
  • tsconfig.json.backup

Summary by CodeRabbit

릴리스 노트

  • New Features

    • Everytime 시간표 조회 API 엔드포인트 추가 (공유 링크 및 ICS 파일 지원)
    • OAuth 기반 소셜 로그인 시스템 구현
    • 시간표 데이터 변환 및 자유 시간 슬롯 계산 기능
    • 일정 조율 가용성 집계 시스템
  • Tests

    • Everytime 인증, 파싱, 변환 관련 단위 테스트
    • Supabase 클라이언트 테스트 추가
  • Chores

    • 사용자 프로필 및 일정 조율 DB 스키마 구성
    • Supabase 마이그레이션 및 환경 설정

Walkthrough

에브리타임 공유 URL 또는 업로드한 ICS로 시간표를 수집해 내부 타입으로 파싱하고, 후보 요일·시간 범위에서 1시간 단위로 겹치지 않는 구간을 병합해 freeSlots를 반환하는 POST 엔드포인트를 추가함.

Changes

에브리타임 시간표 통합

Layer / File(s) Summary
타입 계약 및 데이터 모형
src/types/everytime.ts
EverytimeCredentials, EverytimeSession, EverytimeLectureTime(day:0-6,startMinute,endMinute), EverytimeLecture, EverytimeTimetable을 정의.
에브리타임 인증 모듈
src/lib/everytime/auth.ts, src/lib/everytime/__tests__/auth.test.ts
GET 로그인 페이지에서 Set-Cookie를 수집해 Cookie 헤더로 POST 로그인 요청을 보내는 loginToEverytime()parseLoginResponse(), EverytimeAuthError 추가. 테스트는 성공/실패/비정형 응답 케이스를 확장.
공유 URL 스크래핑
src/lib/everytime/url-scraper.ts, src/lib/everytime/__tests__/url-scraper.test.ts
fetchTimetableFromUrl(url)@identifier를 추출해 https://api.everytime.kr/find/timetable/table/friend 호출 → XML 파싱(parseShareResponse) → starttime × 5 = minutes 변환. 타임아웃·응답 에러는 EverytimeScrapeError. 테스트에 파싱/필터링 에지케이스 추가.
ICS 파일 파싱
src/lib/everytime/ics-converter.ts, src/lib/everytime/__tests__/ics-converter.test.ts
parseTimetableFromIcs(icsText)로 VEVENT들을 파싱해 KST 오프셋을 적용하고 월요일 기준(day 0)으로 매핑, (name,day,startMinute) 중복 제거 후 lectures 반환. 전일 이벤트·빈 SUMMARY 무시, 엣지케이스 검증 테스트 추가.
서버 로그인 시간표 조회
src/lib/everytime/timetable.ts, src/lib/everytime/__tests__/timetable.test.ts
fetchCurrentTimetable()로 학기 ID 조회 → 과목 리스트 조회 → XML 파싱(parseSemesterResponse, parseSubjectListResponse) 및 시간 유효성 검사(day 범위, startMinute < endMinute). EverytimeFetchError 추가.
자유시간 변환 로직
src/lib/everytime/converter.ts, src/lib/everytime/__tests__/converter.test.ts
timetableToFreeSlots(timetable, options?)가 후보 요일·시간 범위를 순회하며 1시간 블록 겹침을 검사해 비어있는 시간 인덱스를 모은 뒤 연속 블록을 TimeSlot[]으로 병합. DAY_CODE_MAP 및 입력 유효성 검사 포함.
API 라우트 통합
src/app/api/everytime/timetable/route.ts
POST /api/everytime/timetableContent-Type에 따라 JSON(url) 또는 multipart(.ics)를 처리, 쿼리 days 필터(WHITELIST MON–SUN) 적용, 업로드/파싱/스크랩/변환 오류에 대해 각각 400/422/415/500 등으로 매핑. export const dynamic = "force-dynamic" 추가.
테스트·인프라·앱·마이그레이션
src/lib/supabase/*, src/middleware.ts, src/app/(auth)/login/*, supabase/migrations/*, .env, .claude/worktrees/availability-aggregation, tsconfig.json.backup
Supabase 브라우저/서버 클라이언트 구현 및 테스트, Next 미들웨어(세션 갱신), 로그인 페이지 및 auth callback 구현, 프로필·스케줄링 마이그레이션 추가, 캘린더 가용성 서브트리 커밋, 환경 템플릿 및 TS 설정 백업.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API
  participant URLScraper
  participant ICSParser
  participant Converter
  Client->>API: POST /api/everytime/timetable (body + ?days)
  API->>API: parseCandidateDays(query.days)
  alt application/json
    API->>URLScraper: fetchTimetableFromUrl(url)
    URLScraper-->>API: EverytimeTimetable (or EverytimeScrapeError)
  else multipart/form-data
    API->>ICSParser: parseTimetableFromIcs(fileText)
    ICSParser-->>API: EverytimeTimetable (or parse error)
  end
  API->>Converter: timetableToFreeSlots(timetable, options)
  Converter-->>API: TimeSlot[]
  API-->>Client: 200 { timetable, freeSlots } / error
Loading

Why / How + 코드 스니펫

왜: 에브리타임 공유 URL 또는 ICS 업로드로 사용자 시간표를 수집해 모임 가능한 빈 시간대를 자동 산출하기 위함.
어떻게: 1) 수집기(URL 스크래퍼 또는 ICS 파서 또는 서버 로그인)로 EverytimeTimetable 생성 → 2) timetableToFreeSlots로 후보 요일·시간 범위의 1시간 블록을 검사해 연속 자유시간 생성 → 3) API는 Content-Type으로 분기해 결과 반환.

핵심 라우트 흐름 스니펫:

export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
  const days = parseCandidateDays(new URL(req.url).searchParams.get("days"));
  const ct = req.headers.get("content-type") ?? "";
  if (ct.includes("application/json")) {
    const { url } = await req.json();
    const timetable = await fetchTimetableFromUrl(url);
    const freeSlots = timetableToFreeSlots(timetable, { candidateDays: days });
    return new Response(JSON.stringify({ timetable, freeSlots }), { status: 200 });
  }
  if (ct.includes("multipart/form-data")) {
    const form = await req.formData();
    const file = form.get("file");
    const text = await file.text();
    const timetable = parseTimetableFromIcs(text);
    const freeSlots = timetableToFreeSlots(timetable, { candidateDays: days });
    return new Response(JSON.stringify({ timetable, freeSlots }), { status: 200 });
  }
  return new Response("Unsupported Content-Type", { status: 415 });
}

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • Siul49/moim#21 — 동일한 Everytime 수집/파싱/변환 API 변경과 테스트를 포함하므로 코드 레벨 연관성이 큼.

Suggested labels

feature

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 'feat:' 접두사로 시작하며, 에브리타임 시간표 조회 기능 구현이라는 핵심 변경사항을 명확히 설명한다.
Description check ✅ Passed PR 설명이 작업 내용(What), 변경 이유(Why), API 엔드포인트를 상세히 기술하며 시간표 조회 및 빈 시간 산출 기능과 직결된다.
Linked Issues check ✅ Passed 변경사항이 이슈 #20의 모든 요구사항을 충족: URL/ICS 입력 지원, TimeSlot[] 산출, POST /api/everytime/timetable 엔드포인트 추가, SSRF 방지 화이트리스트, 1MB 파일 크기 제한, 타입 검증 및 경계값 테스트 포함.
Out of Scope Changes check ✅ Passed 모든 변경사항이 에브리타임 시간표 조회 기능 범위 내: 타입 정의, 인증/파싱/변환 모듈, API 핸들러, 엣지 케이스 테스트. 무관한 파일 수정 없음.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/20-everytime-timetable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/api/everytime/timetable/route.ts`:
- Around line 142-150: The parseCandidateDays function can return duplicate day
codes (e.g., "MON,MON,TUE"); update parseCandidateDays to filter out duplicates
after validating against VALID_DAYS by converting the filtered array into a Set
(or using a seen Set) to ensure uniqueness while preserving original order, then
return the de-duplicated array (or undefined if empty); target the
variables/function parseCandidateDays, raw, days and VALID_DAYS when applying
this change.
- Around line 118-129: The current try/catch around both parseTimetableFromIcs
and timetableToFreeSlots masks whether an error came from ICS parsing or from
conversion; split the logic so parseTimetableFromIcs is in its own try/catch
that returns a 422 with a "파싱" message on failure, then call
timetableToFreeSlots in a separate try/catch that returns a different 422 (or
another appropriate status) with a "변환" message on failure; update return paths
using NextResponse.json to include distinct error messages and ensure you
reference parseTimetableFromIcs and timetableToFreeSlots to locate the blocks to
split.
- Around line 119-122: The route currently calls file.text() which loads the
entire uploaded file into memory (risking OOM); before calling file.text() in
the route handler, enforce a maximum upload size (e.g., 1MB) by checking the
uploaded file's size/stream and reject requests that exceed the limit with an
appropriate NextResponse error; only if the size is acceptable, proceed to call
file.text() and then invoke parseTimetableFromIcs and timetableToFreeSlots as
before.
- Around line 73-86: The route currently only catches errors from
fetchTimetableFromUrl (EverytimeScrapeError) but does not isolate exceptions
thrown by timetableToFreeSlots, so transformation failures surface as 500; wrap
the call to timetableToFreeSlots(timetable, { candidateDays }) in its own
try-catch inside the existing try block (or immediately after obtaining
timetable) and on error return NextResponse.json({ error: err.message }, {
status: 422 }) while logging the error (console.error) for diagnostics; ensure
you still treat EverytimeScrapeError the same way for the initial fetch and do
not change fetchTimetableFromUrl or EverytimeScrapeError handling.
- Around line 65-71: Validate the incoming url string more strictly to prevent
SSRF: parse the value in the url variable using the URL constructor and ensure
the protocol is either "http:" or "https:", then enforce a host whitelist (or
deny localhost, 127.0.0.0/8, ::1 and private IP ranges) and reject requests
where hostname resolves to internal/private addresses or disallowed hostnames;
if validation fails, return the existing NextResponse.json({ error: "url 필드가
필요합니다." }, { status: 400 }) (or a clearer 400 error) from the same route
handler. Use the url variable and the same response path (NextResponse.json) to
locate where to add the checks, and perform DNS/resolution check if needed to
detect IPs after parsing.
- Around line 111-116: The current check using file.name.endsWith and file.type
is insecure; instead validate the uploaded ICS by reading its contents in the
route handler (where file is used) and performing content-based verification:
normalize the file bytes to text (ensure valid UTF-8), check for canonical ICS
markers like a case-insensitive "BEGIN:VCALENDAR" and "END:VCALENDAR" and basic
line-structure (presence of "BEGIN:VEVENT" or "PRODID"/"VERSION") before
accepting; keep a fallback filename extension check using
file.name.toLowerCase().endsWith(".ics") only as a weak guard, and return the
400 error if content checks fail. Ensure you reference the same variables (file,
file.name, file.type) and replace the simple extension/MIME condition in the
route.ts upload handler with this content-based validation.

In `@src/lib/everytime/__tests__/auth.test.ts`:
- Around line 4-26: Add unit tests to cover the missing edge cases for
parseLoginResponse and loginToEverytime: (1) add a test that passes data = {
status: "ok", token: "", idx: 99999 } and asserts parseLoginResponse throws
EverytimeAuthError (covers the !token branch); (2) add a test that passes data
with idx as a number e.g. { status: "ok", token: "abc123", idx: 99999 } and
asserts parseLoginResponse returns session.userIdx === "99999" (covers
String(idx) conversion); and (3) add a test that stubs global fetch to reject
(mockRejectedValue new Error("Network error")) and asserts loginToEverytime(...)
rejects with that error (covers fetch network failure propagation).

In `@src/lib/everytime/__tests__/converter.test.ts`:
- Around line 5-119: The tests and implementation of timetableToFreeSlots lack
validation and edge-case coverage: add input validation in timetableToFreeSlots
to throw for invalid options (candidateStartHour >= candidateEndHour, hours
outside 0-24, empty candidateDays if considered invalid) and ensure the function
correctly merges/handles overlapping lecture times (deduplicate/union intervals
before computing free slots); then add unit tests for candidateStartHour >=
candidateEndHour, negative/>24 hours, empty candidateDays (if intended behavior
is error), and overlapping lectures to assert the merged busy interval produces
the expected free slots — reference function timetableToFreeSlots and its
handling of lecture.times and option fields candidateDays, candidateStartHour,
candidateEndHour.

In `@src/lib/everytime/__tests__/ics-converter.test.ts`:
- Around line 34-121: Add tests covering missing/empty SUMMARY, invalid time
ranges (DTSTART >= DTEND), parseIcsToEvents throwing, and overnight events
crossing midnight for parseTimetableFromIcs: create ICS strings for an event
with SUMMARY: (empty) and assert it is ignored; create an event with DTSTART
later than or equal to DTEND and assert it's ignored (tests reference
parseTimetableFromIcs handling of the DTSTART/DTEND filter); simulate
parseIcsToEvents throwing (e.g., call parseTimetableFromIcs with malformed ICS)
and assert it either returns an empty timetable or rethrows according to current
behavior; add an event spanning 23:30–00:30 and assert how the resulting
LectureTime (from timetable.lectures[].times) represents day and start/end
minutes (ensure consistency with existing day normalization logic).

In `@src/lib/everytime/__tests__/timetable.test.ts`:
- Around line 5-15: Add two defensive tests for parseSemesterResponse: one that
passes XML with status="ok" but missing the semester id (e.g. <response
status="ok"><semester year="2025"/></response>) and expects EverytimeFetchError,
and another that passes malformed XML (e.g. truncated/invalid string) and
expects EverytimeFetchError; ensure these new tests live alongside the existing
parseSemesterResponse tests and reference EverytimeFetchError so
parseSemesterResponse is validated to throw on missing id or invalid XML.
- Around line 58-71: The tests in timetable.test.ts miss boundary cases for
parseSubjectListResponse's time-filtering logic; add tests that (1) allow day=0
and day=6 and assert timetable.lectures[0].times length is 2 and days are 0 and
6, (2) filter out entries where startMinute >= endMinute and assert length 0,
(3) filter out endMinute > 1440 and assert length 0, and (4) filter out
startMinute < 0 and assert length 0; reference parseSubjectListResponse and
assert against timetable.lectures[0].times for each case.

In `@src/lib/everytime/__tests__/url-scraper.test.ts`:
- Around line 25-68: The tests only cover happy paths; add edge-case tests and
update parseShareResponse to validate and filter malformed time entries and XML:
ensure parseShareResponse returns empty lectures for an empty <table>, throws
EverytimeScrapeError on malformed XML, and filters out times where starttime or
endtime is negative or NaN, endMinute (endtime×5) > 1440, or day is outside 0–6;
add unit tests in url-scraper.test.ts that assert (1) empty timetable yields
lectures.length===0, (2) negative starttime results in no times for that
subject, (3) endMinute >1440 is filtered out, (4) day values outside 0–6 are
ignored, and (5) invalid XML input causes parseShareResponse to throw
EverytimeScrapeError, referencing the parseShareResponse function and
EverytimeScrapeError class when locating code to change.

In `@src/lib/everytime/auth.ts`:
- Around line 67-87: Extract the complex type assertion around response.headers
into a small type guard (e.g., function hasGetSetCookie(h: Headers |
Record<string, unknown>): h is { getSetCookie: () => string[] }) and use it
inside fetchSessionCookies to decide whether to call getSetCookie or fallback to
response.headers.get("set-cookie"); replace the inline casting at the setCookies
assignment with this guard so the logic becomes: if
(hasGetSetCookie(response.headers)) use response.headers.getSetCookie(), else
use the single header fallback, then normalize the cookies as before.
- Around line 24-61: The loginToEverytime function is only used by tests and not
by production flows; either delete loginToEverytime and its related test
(auth.test.ts) to remove dead code, or retain it but add a clear JSDoc noting it
is test-only and unused in production due to reCAPTCHA v3 blocking (mentioning
the recaptchaToken: "" usage) so future maintainers know why it exists; update
exports/imports accordingly (references to loginToEverytime) to avoid orphaned
symbols.

In `@src/lib/everytime/converter.ts`:
- Around line 51-84: The function timetableToFreeSlots lacks validation of
options (TimetableConvertOptions) leading to silent incorrect results when
candidateStartHour >= candidateEndHour or hours out of 0–24 range; add explicit
input validation at the start of timetableToFreeSlots to throw a clear error for
invalid ranges (e.g., candidateStartHour < 0, candidateEndHour > 24, or
candidateStartHour >= candidateEndHour) so callers receive immediate feedback
rather than an empty result, and include the invalid values in the error message
to aid debugging.
- Around line 86-104: The function groupConsecutiveHours assumes hours is sorted
which is not obvious from its signature; make it defensive by sorting the input
array at the start (e.g., call hours.sort((a,b)=>a-b) on a shallow copy to avoid
mutating the caller) or alternatively add a clear JSDoc to groupConsecutiveHours
stating "hours must be an ascending-sorted array" and validate/throw if
unsorted; reference this change from usages such as timetableToFreeSlots to
ensure callers still behave correctly.

In `@src/lib/everytime/ics-converter.ts`:
- Line 50: The loop currently drops events silently when encountering invalid
time ranges (the line with if (startMinute >= endMinute) continue;), so change
this to emit a warning and surface the problematic event: replace the silent
continue with a console.warn or the module's warning mechanism (e.g.,
logger.warn) that includes identifying details (use the variables startMinute,
endMinute and the event's UID/summary/timing from the current event object) and
optionally collect the issue into a validationErrors array or call a provided
onWarning callback so callers can handle/report malformed ICS entries instead of
silently losing them.
- Around line 52-59: The code currently creates lecture entries even when
event.title is empty; update the handling around event.title/name so blank or
whitespace-only titles are filtered out before using lectureMap and slotKey.
Trim and validate event.title (referencing event.title and the const name) and,
if name is falsy after trimming, skip processing that event (do not call
lectureMap.set or use slotKey); keep the existing duplicate-key logic
(lectureMap, slotKey) unchanged for valid names.
- Around line 43-48: The code is applying a KST offset twice; remove the forced
KST conversion and use the UTC Date objects returned by parseIcsToEvents
directly: stop creating startKst/endKst with KST_OFFSET_MS and instead compute
day via toMondayBasedDay(event.startAt.getUTCDay()) and minutes via
event.startAt.getUTCHours()/getUTCMinutes() and
event.endAt.getUTCHours()/getUTCMinutes(); also add a short comment on
parseIcsToEvents indicating its returned Dates are already converted to UTC (or
adjust its return type/comment) so future readers won't reapply time zone
offsets.

In `@src/lib/everytime/timetable.ts`:
- Around line 35-76: The current functions fetchCurrentTimetable,
fetchCurrentSemesterId and fetchTimetableBySemester call fetch directly
(violates dependency inversion and lacks timeouts); refactor by introducing an
EverytimeHttpClient interface (e.g., post(url, body): Promise<string>) and
inject it into fetchCurrentTimetable and the helper functions so they call
client.post instead of global fetch, implement a FetchEverytimeClient adapter
that uses AbortController to enforce a timeout and converts non-ok
responses/AbortError into EverytimeFetchError, and update callers/tests to pass
a mock or the concrete client.
- Around line 91-92: The code currently forces response?.semester?.["@_id"] into
a string using String(), which can yield "undefined", "null" or "[object
Object]"; instead add an explicit type guard: check that
response?.semester?.["@_id"] is a non-empty string (e.g. typeof
response?.semester?.["@_id"] === "string" && response.semester["@_id"].trim()
!== ""), and only then assign to id; if the check fails, throw the existing
EverytimeFetchError("학기 ID를 찾을 수 없습니다."). Ensure you update the assignment and
the subsequent use of id in the surrounding scope (where id is used for the next
API call) so it only proceeds when the guarded string is valid.
- Around line 128-140: The mapping/filtering that builds EverytimeLectureTime
currently uses a type assertion (as EverytimeLectureTime["day"]) and lacks an
upper bound check for minutes; replace the assertion with a proper type guard:
parse Number(time?.["@_day"]) into a local numeric variable (e.g., dayNum) and
validate it's an integer between 0 and 6, parse startMinute/endMinute with
Number and validate they are finite numbers within 0 <= minute <= 1440 and
startMinute < endMinute; only produce the object { day: dayNum, startMinute,
endMinute } when all checks pass (or filter out otherwise) so functions like the
mapper that use time?.["@_day"], startMinute, endMinute and the surrounding
map/filter logic become type-safe without using `as` assertions.
- Line 84: Wrap the xmlParser.parse(xml) call in a try-catch so XML parse
exceptions are caught and converted into an explicit, descriptive error (e.g.,
throw a new Error that includes context and the original error.message) instead
of letting them bubble up; do the same defensive try-catch pattern for the other
parser function parseSubjectListResponse to ensure malformed XML produces a
controlled error rather than a runtime crash.

In `@src/types/everytime.ts`:
- Around line 19-24: The EverytimeLectureTime interface currently types
startMinute and endMinute as plain numbers; add JSDoc comments on the
EverytimeLectureTime interface and on the startMinute/endMinute properties that
document the valid range (0–1439) and the invariant startMinute < endMinute so
consumers know constraints (note: TS cannot enforce numeric ranges, so include
these validations in runtime code where needed). Reference the
EverytimeLectureTime interface and the startMinute and endMinute fields when
updating the comments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4f0ab59a-0d4d-48fe-8584-b1ae92ea0297

📥 Commits

Reviewing files that changed from the base of the PR and between 3a152d8 and e0d36da.

📒 Files selected for processing (12)
  • src/app/api/everytime/timetable/route.ts
  • src/lib/everytime/__tests__/auth.test.ts
  • src/lib/everytime/__tests__/converter.test.ts
  • src/lib/everytime/__tests__/ics-converter.test.ts
  • src/lib/everytime/__tests__/timetable.test.ts
  • src/lib/everytime/__tests__/url-scraper.test.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/converter.ts
  • src/lib/everytime/ics-converter.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts
  • src/types/everytime.ts

Comment thread src/app/api/everytime/timetable/route.ts
Comment thread src/app/api/everytime/timetable/route.ts
Comment thread src/app/api/everytime/timetable/route.ts Outdated
Comment thread src/app/api/everytime/timetable/route.ts
Comment thread src/app/api/everytime/timetable/route.ts Outdated
Comment on lines +35 to +76
export async function fetchCurrentTimetable(
session: EverytimeSession,
): Promise<EverytimeTimetable> {
const semesterId = await fetchCurrentSemesterId(session);
return fetchTimetableBySemester(session, semesterId);
}

async function fetchCurrentSemesterId(
session: EverytimeSession,
): Promise<string> {
const response = await fetch(SEMESTER_SUGGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: session.token }),
});

if (!response.ok) {
throw new EverytimeFetchError(`학기 조회 실패 (${response.status})`);
}

const xml = await response.text();
return parseSemesterResponse(xml);
}

async function fetchTimetableBySemester(
session: EverytimeSession,
semesterId: string,
): Promise<EverytimeTimetable> {
const url = `${BASE_URL}/find/timetable/subject/list/semester/id/${semesterId}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: session.token }),
});

if (!response.ok) {
throw new EverytimeFetchError(`시간표 조회 실패 (${response.status})`);
}

const xml = await response.text();
return parseSubjectListResponse(xml);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

비즈니스 로직에서 fetch 직접 호출 — 의존성 역전 원칙 위반.

src/lib/** 계층이 네트워크 I/O를 직접 수행하면 테스트 불가능하고 결합도 증가.
fetch timeout도 없어 무한 대기 가능.

Why: 순수 함수 유지, 테스트 용이성, 타임아웃 제어.
How: HTTP 클라이언트 의존성 주입 또는 별도 adapter 계층 분리.

🏗️ 의존성 역전 적용 예시
// 1단계: 인터페이스 정의
export interface EverytimeHttpClient {
  post(url: string, body: URLSearchParams): Promise<string>;
}

// 2단계: fetch 구현체 (adapter 계층)
export class FetchEverytimeClient implements EverytimeHttpClient {
  constructor(private timeoutMs: number = 10000) {}
  
  async post(url: string, body: URLSearchParams): Promise<string> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
    
    try {
      const response = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body,
        signal: controller.signal,
      });
      
      if (!response.ok) {
        throw new EverytimeFetchError(`HTTP ${response.status}`);
      }
      
      return await response.text();
    } catch (err) {
      if (err instanceof Error && err.name === "AbortError") {
        throw new EverytimeFetchError("요청 시간 초과");
      }
      throw err;
    } finally {
      clearTimeout(timeoutId);
    }
  }
}

// 3단계: 함수 시그니처 변경
export async function fetchCurrentTimetable(
  session: EverytimeSession,
  client: EverytimeHttpClient, // 의존성 주입
): Promise<EverytimeTimetable> {
  const semesterId = await fetchCurrentSemesterId(session, client);
  return fetchTimetableBySemester(session, semesterId, client);
}

async function fetchCurrentSemesterId(
  session: EverytimeSession,
  client: EverytimeHttpClient,
): Promise<string> {
  const xml = await client.post(
    SEMESTER_SUGGEST_URL,
    new URLSearchParams({ token: session.token }),
  );
  return parseSemesterResponse(xml);
}

// 테스트에서는 mock 주입 가능
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/timetable.ts` around lines 35 - 76, The current functions
fetchCurrentTimetable, fetchCurrentSemesterId and fetchTimetableBySemester call
fetch directly (violates dependency inversion and lacks timeouts); refactor by
introducing an EverytimeHttpClient interface (e.g., post(url, body):
Promise<string>) and inject it into fetchCurrentTimetable and the helper
functions so they call client.post instead of global fetch, implement a
FetchEverytimeClient adapter that uses AbortController to enforce a timeout and
converts non-ok responses/AbortError into EverytimeFetchError, and update
callers/tests to pass a mock or the concrete client.

Comment thread src/lib/everytime/timetable.ts Outdated
Comment thread src/lib/everytime/timetable.ts Outdated
Comment thread src/lib/everytime/timetable.ts Outdated
Comment thread src/types/everytime.ts
kokkumong and others added 2 commits May 14, 2026 13:28
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SSRF 방지: URL 도메인·프로토콜 화이트리스트 검증 추가
- ICS 파일 크기 1MB 제한 및 확장자 대소문자 처리
- URL/ICS 경로 예외 처리 분리 (스크래핑/파싱 vs 변환 오류 구분)
- XML 파싱 try-catch 추가 및 타입 검증 강화
- converter 옵션 범위 검증, ics-converter 빈 제목 필터링
- 테스트 16개 추가 (경계값, 엣지 케이스 커버리지 향상)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (4)
src/app/api/everytime/timetable/route.ts (1)

141-144: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

파일 타입 검증이 우회 가능해 비-ICS 콘텐츠를 통과시킬 수 있습니다.

Why: 현재 !endsWith(".ics") && file.type !== "text/calendar" 조건은 둘 중 하나만 맞으면 통과합니다. file.type은 클라이언트 위조가 가능해 방어가 약합니다.
How: 확장자를 강제하고, file.text() 후 ICS 시그니처를 한 번 더 검증하세요.

수정 예시 (핵심 라인만)
-  if (
-    !file.name.toLowerCase().endsWith(".ics") &&
-    file.type !== "text/calendar"
-  ) {
+  if (!file.name.toLowerCase().endsWith(".ics")) {
     return NextResponse.json(
       { error: ".ics 파일만 지원합니다." },
       { status: 400 },
     );
   }
@@
   const icsText = await file.text();
+  const normalized = icsText.trim().toUpperCase();
+  if (
+    !normalized.includes("BEGIN:VCALENDAR") ||
+    !normalized.includes("END:VCALENDAR")
+  ) {
+    return NextResponse.json(
+      { error: "올바른 ICS 파일 형식이 아닙니다." },
+      { status: 400 },
+    );
+  }

Also applies to: 160-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/everytime/timetable/route.ts` around lines 141 - 144, Current
validation lets non-ICS files pass because it only requires one of extension or
MIME to match; change the check to require the .ics extension
(file.name.toLowerCase().endsWith(".ics")) and if that passes, call file.text()
and assert the content contains an ICS signature like "BEGIN:VCALENDAR" (fail
otherwise); update both places in src/app/api/everytime/timetable/route.ts where
the file/type check occurs (the blocks that reference file.name and file.type
around the shown diff and the similar 160-170 region) so the logic first
enforces the .ics extension and then verifies the file content signature before
accepting the upload.
src/lib/everytime/__tests__/timetable.test.ts (1)

115-127: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

startMinute < 0 필터링 테스트가 빠져 있습니다.

Why: 구현은 음수 시작분을 필터링하지만, 테스트가 없어서 회귀 시 바로 탐지되지 않습니다.
How: 경계 테스트 1개만 추가해 필터 계약을 고정하세요.

테스트 추가 예시
+  it("startMinute < 0인 시간은 필터링된다", () => {
+    const xml = `
+      <response status="ok">
+        <subject id="1">
+          <name>테스트</name>
+          <time day="1" start="-10" end="100"/>
+        </subject>
+      </response>
+    `;
+    const timetable = parseSubjectListResponse(xml);
+    expect(timetable.lectures[0].times).toHaveLength(0);
+  });

As per coding guidelines, "**/__tests__/**: 경계값, 에러 케이스, 빈 입력 등 엣지 케이스 커버리지를 평가하세요."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/__tests__/timetable.test.ts` around lines 115 - 127, Add a
new unit test alongside the existing case to assert that entries with negative
start minutes are filtered: call parseSubjectListResponse with an XML
subject/time element where time has start="-10" (and a valid end), then assert
timetable.lectures[0].times has length 0; place this new test in the same suite
as the existing "endMinute > 1440인 시간은 필터링된다" test to cover the startMinute < 0
boundary for parseSubjectListResponse.
src/lib/everytime/timetable.ts (1)

140-156: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

요일/시간 값의 정수성 검증이 없어 비정상 값이 통과할 수 있습니다.

Why: 현재는 범위만 검사해서 day=1.5, startMinute=540.5 같은 값이 필터를 통과할 수 있습니다. 이는 빈 시간 계산 단계에서 예측 불가능한 결과를 만듭니다.
How: Number.isInteger를 추가하고, 단언(as) 대신 검증된 값만 객체로 생성하세요.

수정 예시 (핵심 라인만)
-      const day = Number(time?.["@_day"]) as EverytimeLectureTime["day"];
+      const day = Number(time?.["@_day"]);
       const startMinute = Number(time?.["@_start"]);
       const endMinute = Number(time?.["@_end"]);
       return { day, startMinute, endMinute };
@@
-        t.day >= 0 &&
+        Number.isInteger(t.day) &&
+        t.day >= 0 &&
         t.day <= 6 &&
-        !isNaN(t.startMinute) &&
-        !isNaN(t.endMinute) &&
+        Number.isInteger(t.startMinute) &&
+        Number.isInteger(t.endMinute) &&
         t.startMinute >= 0 &&
         t.endMinute <= 1440 &&
         t.startMinute < t.endMinute,

As per coding guidelines, "src/lib/**: 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/timetable.ts` around lines 140 - 156, The mapping/filter
pipeline allows non-integer numeric values through; change the transformation
around the map/filter so you validate integerness with Number.isInteger for day,
startMinute, and endMinute (instead of relying on the current Number(...) casts
and as assertions) and only return an object matching EverytimeLectureTime when
those integer checks and the existing range checks pass; i.e., compute raw
values in the map, perform Number.isInteger(day/startMinute/endMinute) plus the
existing bounds and startMinute < endMinute in the filter, and stop using type
assertions so only validated integers are used to construct the resulting { day,
startMinute, endMinute } objects.
src/lib/everytime/ics-converter.ts (1)

15-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

UTC 기준 Date에 KST 오프셋을 다시 더해 시간 왜곡이 발생할 수 있습니다.

Why: parseIcsToEvents가 이미 UTC 기준 Date를 반환하는 계약이라면, Line 43-44의 + KST_OFFSET_MS는 이중 변환입니다. 이 경우 요일/분 계산이 밀려 freeSlots 결과가 틀어집니다.
How: 오프셋 보정을 제거하고 UTC getter를 직접 사용하세요.

수정 예시 (핵심 라인만)
-// 에브리타임 ICS는 Asia/Seoul(UTC+9) 기준으로 작성됨
-const KST_OFFSET_MS = 9 * 60 * 60_000;
@@
-    const startKst = new Date(event.startAt.getTime() + KST_OFFSET_MS);
-    const endKst = new Date(event.endAt.getTime() + KST_OFFSET_MS);
-
-    const day = toMondayBasedDay(startKst.getUTCDay());
-    const startMinute = startKst.getUTCHours() * 60 + startKst.getUTCMinutes();
-    const endMinute = endKst.getUTCHours() * 60 + endKst.getUTCMinutes();
+    const day = toMondayBasedDay(event.startAt.getUTCDay());
+    const startMinute =
+      event.startAt.getUTCHours() * 60 + event.startAt.getUTCMinutes();
+    const endMinute =
+      event.endAt.getUTCHours() * 60 + event.endAt.getUTCMinutes();

Also applies to: 43-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/ics-converter.ts` around lines 15 - 17, The code adds a KST
offset (KST_OFFSET_MS) to Date objects returned by parseIcsToEvents, causing
double-shifting of UTC dates; remove the "+ KST_OFFSET_MS" adjustments
(references: KST_OFFSET_MS constant and the arithmetic in
parseIcsToEvents/wherever events are converted) and instead use the Date
object's UTC accessors (e.g.,
getUTCFullYear/getUTCMonth/getUTCDate/getUTCHours/getUTCMinutes) or treat the
Date as already UTC throughout so freeSlots calculations use the original UTC
Date values without manual offset correction.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/everytime/__tests__/auth.test.ts`:
- Around line 40-114: Add tests that simulate fetch promise rejections to cover
network-level errors for loginToEverytime: stub global fetch to
mockRejectedValue for the first call (to simulate DNS/timeout) and assert
loginToEverytime rejects with EverytimeAuthError and a message matching "세션 쿠키
획득 실패"; then add a second test where fetch.mockResolvedValueOnce(...) for the
initial GET and mockRejectedValue for the POST, asserting it rejects with
EverytimeAuthError and a message matching "로그인 요청 실패". Use the existing test
pattern (vi.stubGlobal, import ../auth,
expect(...).rejects.toThrow(EverytimeAuthError) and toThrow(/…/)) so the new
cases cover both network-failure paths.
- Around line 4-38: Add edge-case tests for parseLoginResponse to assert it
throws EverytimeAuthError when passed non-object inputs: add it-blocks that call
parseLoginResponse with null, undefined, primitives (e.g., "string" and 123) and
an empty array ([]) and expect(() =>
parseLoginResponse(...)).toThrow(EverytimeAuthError); reference the existing
test suite around parseLoginResponse and EverytimeAuthError to mirror naming and
style so these new cases validate the function's defensive input handling.

In `@src/lib/everytime/auth.ts`:
- Around line 94-117: parseLoginResponse currently unsafely asserts data as
Record<string, unknown>, causing runtime errors for null/primitive inputs and
masking specific failure cases; update parseLoginResponse to first validate that
data is a non-null plain object (typeof === "object" && data !== null &&
!Array.isArray(data")), then read status, token and idx with guarded checks: if
status !== "ok" throw EverytimeAuthError with a distinct message when status ===
"not_exists_user" vs a generic auth error, verify token exists and is a string
(or stringify only after checking), verify idx exists (number or string) before
converting to string, and return EverytimeSession only after these validations
to avoid unsafe property access and inaccurate error messages (refer to
parseLoginResponse, EverytimeAuthError, EverytimeSession).
- Around line 35-51: Wrap the calls to fetchSessionCookies() and the subsequent
fetch POST to LOGIN_API_URL in a try-catch block so any network-level rejections
(timeouts, DNS, connection errors) are caught; on catch, throw a new
EverytimeAuthError that includes a clear message and the original error (e.g.,
as a cause or property) to preserve diagnostics, and also ensure you still
handle non-2xx responses from the POST by converting them into
EverytimeAuthError with response details—apply these changes around the code
that invokes fetchSessionCookies() and the fetch(...) POST so all external
network failures surface as consistent EverytimeAuthError instances.

In `@src/lib/everytime/url-scraper.ts`:
- Around line 86-91: Wrap the call to xmlParser.parse(xml) in a try-catch to
handle malformed or unexpected XML and convert any thrown error into an
EverytimeScrapeError; specifically, in url-scraper.ts guard the
xmlParser.parse(xml) call (which produces parsed and then table) with try {
const parsed = xmlParser.parse(xml); } catch (err) { throw new
EverytimeScrapeError(`XML 파싱 실패: ${err?.message ?? String(err)}`); } and keep
the existing table null/undefined check to throw the same EverytimeScrapeError
if parsed?.response?.table is missing.
- Around line 130-140: The current validation block that returns null for
invalid schedule data (the conditional checking isNaN(day), day bounds,
startMinute, endMinute, and startMinute < endMinute) is missing proper endMinute
range checks; update the conditional in the validation inside url-scraper.ts
(the block using variables day, startMinute, endMinute) to also reject endMinute
< 0 and endMinute > 1440 so endMinute is constrained to 0 <= endMinute <= 1440,
preserving the existing startMinute < endMinute check.
- Around line 50-60: The fetch call that posts to API_URL in url-scraper.ts
(using identifier and new URLSearchParams) lacks a timeout; wrap the request
with an AbortController: create an AbortController before calling fetch, start a
setTimeout that calls controller.abort() after a configured TIMEOUT_MS, pass
controller.signal to fetch, and clearTimeout when fetch completes; ensure you
catch the abort error and throw/handle a clear timeout error so callers of the
scraping function know the request timed out.
- Line 110: The check "if (!name) return null;" allows whitespace-only subject
names to pass; update the validation to trim the name and reject empty or
whitespace-only values by replacing the condition with a trimmed check (e.g.,
use name = name.trim() or check name.trim().length) so that whitespace-only
strings are treated as null/invalid; locate the usage of the variable name in
the URL scraper (the conditional around "if (!name) return null;") and apply the
trim-and-check there, preserving behavior for truly empty values.

---

Duplicate comments:
In `@src/app/api/everytime/timetable/route.ts`:
- Around line 141-144: Current validation lets non-ICS files pass because it
only requires one of extension or MIME to match; change the check to require the
.ics extension (file.name.toLowerCase().endsWith(".ics")) and if that passes,
call file.text() and assert the content contains an ICS signature like
"BEGIN:VCALENDAR" (fail otherwise); update both places in
src/app/api/everytime/timetable/route.ts where the file/type check occurs (the
blocks that reference file.name and file.type around the shown diff and the
similar 160-170 region) so the logic first enforces the .ics extension and then
verifies the file content signature before accepting the upload.

In `@src/lib/everytime/__tests__/timetable.test.ts`:
- Around line 115-127: Add a new unit test alongside the existing case to assert
that entries with negative start minutes are filtered: call
parseSubjectListResponse with an XML subject/time element where time has
start="-10" (and a valid end), then assert timetable.lectures[0].times has
length 0; place this new test in the same suite as the existing "endMinute >
1440인 시간은 필터링된다" test to cover the startMinute < 0 boundary for
parseSubjectListResponse.

In `@src/lib/everytime/ics-converter.ts`:
- Around line 15-17: The code adds a KST offset (KST_OFFSET_MS) to Date objects
returned by parseIcsToEvents, causing double-shifting of UTC dates; remove the
"+ KST_OFFSET_MS" adjustments (references: KST_OFFSET_MS constant and the
arithmetic in parseIcsToEvents/wherever events are converted) and instead use
the Date object's UTC accessors (e.g.,
getUTCFullYear/getUTCMonth/getUTCDate/getUTCHours/getUTCMinutes) or treat the
Date as already UTC throughout so freeSlots calculations use the original UTC
Date values without manual offset correction.

In `@src/lib/everytime/timetable.ts`:
- Around line 140-156: The mapping/filter pipeline allows non-integer numeric
values through; change the transformation around the map/filter so you validate
integerness with Number.isInteger for day, startMinute, and endMinute (instead
of relying on the current Number(...) casts and as assertions) and only return
an object matching EverytimeLectureTime when those integer checks and the
existing range checks pass; i.e., compute raw values in the map, perform
Number.isInteger(day/startMinute/endMinute) plus the existing bounds and
startMinute < endMinute in the filter, and stop using type assertions so only
validated integers are used to construct the resulting { day, startMinute,
endMinute } objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: db7ea104-c701-4dfd-9b3e-50f6f1a79e8d

📥 Commits

Reviewing files that changed from the base of the PR and between e0d36da and d81141b.

⛔ Files ignored due to path filters (1)
  • .agents/docs/4_API_RESPONSE_FORMATS.md is excluded by !**/*.md, !.agents/**
📒 Files selected for processing (11)
  • src/app/api/everytime/timetable/route.ts
  • src/lib/everytime/__tests__/auth.test.ts
  • src/lib/everytime/__tests__/converter.test.ts
  • src/lib/everytime/__tests__/ics-converter.test.ts
  • src/lib/everytime/__tests__/timetable.test.ts
  • src/lib/everytime/__tests__/url-scraper.test.ts
  • src/lib/everytime/auth.ts
  • src/lib/everytime/converter.ts
  • src/lib/everytime/ics-converter.ts
  • src/lib/everytime/timetable.ts
  • src/lib/everytime/url-scraper.ts

Comment thread src/lib/everytime/__tests__/auth.test.ts
Comment on lines +40 to +114
describe("loginToEverytime (HTTP)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("세션 쿠키 획득 후 로그인 성공 시 세션을 반환한다", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
// 1번째 호출: 로그인 페이지 GET (쿠키 획득)
.mockResolvedValueOnce({
headers: {
getSetCookie: () => [
"etsid=session123; Path=/",
"x-et-device=device456; Path=/",
],
get: () => null,
},
redirect: "follow",
})
// 2번째 호출: 로그인 POST
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ status: "ok", token: "tok-xyz", idx: 12345 }),
}),
);

const { loginToEverytime } = await import("../auth");
const session = await loginToEverytime({ id: "user1", password: "pass1" });

expect(session.token).toBe("tok-xyz");
expect(session.userIdx).toBe("12345");
});

it("서버 오류(5xx)이면 EverytimeAuthError를 throw한다", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({
headers: { getSetCookie: () => [], get: () => null },
})
.mockResolvedValueOnce({ ok: false, status: 500 }),
);

const { loginToEverytime, EverytimeAuthError: AuthErr } =
await import("../auth");
await expect(
loginToEverytime({ id: "user1", password: "pass1" }),
).rejects.toThrow(AuthErr);
});

it("잘못된 자격증명이면 EverytimeAuthError를 throw한다", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({
headers: { getSetCookie: () => [], get: () => null },
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: "not_exists_user" }),
}),
);

const { loginToEverytime, EverytimeAuthError: AuthErr } =
await import("../auth");
await expect(
loginToEverytime({ id: "wrong", password: "wrong" }),
).rejects.toThrow(AuthErr);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

loginToEverytime 네트워크 에러 케이스 테스트 누락

현재 테스트는 HTTP 응답 레벨(ok: false, status: 500 등)만 커버하고, fetch 자체가 reject하는 네트워크 레벨 에러(타임아웃, DNS 실패, 연결 거부 등)를 검증하지 않습니다. auth.ts에서 네트워크 에러 처리를 추가한다면 해당 경로를 검증하는 테스트가 필수입니다.

Why: fetch는 네트워크 레벨에서 실패 시 promise rejection을 발생시키는데, 이를 테스트하지 않으면 에러 처리 로직의 정확성을 검증할 수 없습니다.

How: 다음 테스트 케이스를 추가하세요.

✅ 추가 권장 테스트
it("첫 번째 fetch 네트워크 에러 시 EverytimeAuthError를 throw한다", async () => {
  vi.stubGlobal(
    "fetch",
    vi.fn().mockRejectedValue(new Error("Network error: DNS lookup failed"))
  );

  const { loginToEverytime, EverytimeAuthError: AuthErr } =
    await import("../auth");
  
  await expect(
    loginToEverytime({ id: "user1", password: "pass1" })
  ).rejects.toThrow(AuthErr);
  await expect(
    loginToEverytime({ id: "user1", password: "pass1" })
  ).rejects.toThrow(/   /);
});

it("두 번째 fetch 네트워크 에러 시 EverytimeAuthError를 throw한다", async () => {
  vi.stubGlobal(
    "fetch",
    vi
      .fn()
      .mockResolvedValueOnce({
        headers: { getSetCookie: () => [], get: () => null },
      })
      .mockRejectedValue(new Error("Network error: Connection refused"))
  );

  const { loginToEverytime, EverytimeAuthError: AuthErr } =
    await import("../auth");
  
  await expect(
    loginToEverytime({ id: "user1", password: "pass1" })
  ).rejects.toThrow(AuthErr);
  await expect(
    loginToEverytime({ id: "user1", password: "pass1" })
  ).rejects.toThrow(/  /);
});

코딩 가이드라인: 경계값, 에러 케이스, 빈 입력 등 엣지 케이스 커버리지를 평가하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/__tests__/auth.test.ts` around lines 40 - 114, Add tests
that simulate fetch promise rejections to cover network-level errors for
loginToEverytime: stub global fetch to mockRejectedValue for the first call (to
simulate DNS/timeout) and assert loginToEverytime rejects with
EverytimeAuthError and a message matching "세션 쿠키 획득 실패"; then add a second test
where fetch.mockResolvedValueOnce(...) for the initial GET and mockRejectedValue
for the POST, asserting it rejects with EverytimeAuthError and a message
matching "로그인 요청 실패". Use the existing test pattern (vi.stubGlobal, import
../auth, expect(...).rejects.toThrow(EverytimeAuthError) and toThrow(/…/)) so
the new cases cover both network-failure paths.

Comment thread src/lib/everytime/auth.ts
Comment on lines +35 to +51
const cookies = await fetchSessionCookies();

const response = await fetch(LOGIN_API_URL, {
method: "POST",
headers: {
...BASE_HEADERS,
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
Cookie: cookies,
},
body: new URLSearchParams({
id: credentials.id,
password: credentials.password,
keep: "false",
recaptchaToken: "",
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

fetch 네트워크 에러 처리 부재

fetchSessionCookies()와 두 번째 fetch() 모두 네트워크 실패(타임아웃, DNS 실패, 연결 거부 등) 시 예외 처리가 없어 unhandled rejection이 발생할 수 있습니다. 비즈니스 로직 계층에서 외부 의존성 호출 시 에러 경계를 명확히 해야 합니다.

Why: fetch는 네트워크 레벨에서 실패 시 reject하는데, 이를 catch하지 않으면 호출자가 예측 불가능한 에러를 받습니다.

How: try-catch로 감싸고 EverytimeAuthError로 변환하여 일관된 에러 인터페이스를 제공하세요.

🛡️ 제안 개선안
 export async function loginToEverytime(
   credentials: EverytimeCredentials,
 ): Promise<EverytimeSession> {
-  const cookies = await fetchSessionCookies();
+  let cookies: string;
+  try {
+    cookies = await fetchSessionCookies();
+  } catch (err) {
+    throw new EverytimeAuthError(
+      `세션 쿠키 획득 실패: ${err instanceof Error ? err.message : String(err)}`
+    );
+  }
 
-  const response = await fetch(LOGIN_API_URL, {
-    method: "POST",
-    headers: {
-      ...BASE_HEADERS,
-      "Content-Type": "application/x-www-form-urlencoded",
-      "X-Requested-With": "XMLHttpRequest",
-      Cookie: cookies,
-    },
-    body: new URLSearchParams({
-      id: credentials.id,
-      password: credentials.password,
-      keep: "false",
-      recaptchaToken: "",
-    }),
-  });
+  let response: Response;
+  try {
+    response = await fetch(LOGIN_API_URL, {
+      method: "POST",
+      headers: {
+        ...BASE_HEADERS,
+        "Content-Type": "application/x-www-form-urlencoded",
+        "X-Requested-With": "XMLHttpRequest",
+        Cookie: cookies,
+      },
+      body: new URLSearchParams({
+        id: credentials.id,
+        password: credentials.password,
+        keep: "false",
+        recaptchaToken: "",
+      }),
+    });
+  } catch (err) {
+    throw new EverytimeAuthError(
+      `로그인 요청 실패: ${err instanceof Error ? err.message : String(err)}`
+    );
+  }

코딩 가이드라인: 비즈니스 로직 계층에서 외부 의존성 호출 시 예외 처리가 부족하면 엄격히 지적하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/everytime/auth.ts` around lines 35 - 51, Wrap the calls to
fetchSessionCookies() and the subsequent fetch POST to LOGIN_API_URL in a
try-catch block so any network-level rejections (timeouts, DNS, connection
errors) are caught; on catch, throw a new EverytimeAuthError that includes a
clear message and the original error (e.g., as a cause or property) to preserve
diagnostics, and also ensure you still handle non-2xx responses from the POST by
converting them into EverytimeAuthError with response details—apply these
changes around the code that invokes fetchSessionCookies() and the fetch(...)
POST so all external network failures surface as consistent EverytimeAuthError
instances.

Comment thread src/lib/everytime/auth.ts
Comment thread src/lib/everytime/url-scraper.ts Outdated
Comment thread src/lib/everytime/url-scraper.ts Outdated
Comment thread src/lib/everytime/url-scraper.ts
Comment thread src/lib/everytime/url-scraper.ts
kokkumong and others added 2 commits May 21, 2026 17:31
- route.ts: ICS 콘텐츠 기반 검증 추가 (BEGIN/END:VCALENDAR 시그니처 확인)
- auth.ts: parseLoginResponse null/원시값 방어 처리, loginToEverytime JSDoc 추가
- timetable.ts: Number.isInteger 정수 검증 강화, as 타입 단언 제거
- url-scraper.ts: XML try/catch, endMinute 상한 검증, AbortController 타임아웃, 공백명 필터
- converter.ts/types: JSDoc 보완
- 테스트: non-object 입력, 네트워크 실패, startMinute<0, malformed XML 케이스 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Siul49
Siul49 merged commit ca8a4a3 into dev May 23, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants