diff --git a/TODO.md b/TODO.md index de7802e..a9e8111 100644 --- a/TODO.md +++ b/TODO.md @@ -9,12 +9,6 @@ - 값이 들어오는데 0으로 보이면 → 프런트 렌더링 로직 점검 - 값이 null/0으로 오면 → 응답 직렬화/DTO 매핑 레이어 점검 (`scheduleService.ts` 등) -- [ ] **`parseGameId` 수정** (`src/kbo/schedule.ts:151`) - - 현재 Schedule.aspx에서 모든 게임의 `gameId`가 null로 파싱됨 (로그에서 `gameId=null` 5건 확인) - - 정규식 `/gameId=([A-Za-z0-9]+)/`가 실제 relay 셀 HTML 구조와 안 맞을 가능성 - - 지금은 `(awayTeamCode, homeTeamCode, time)` 폴백으로 매칭되고 있지만 **더블헤더 발생 시 같은 카드 2경기가 섞일 위험** - - KBO 응답의 relay 셀 실제 HTML을 확인하고 파서 보정 필요 - - [ ] **`mergeLiveIntoSchedule` 진단 로그 제거** (`src/repositories/kboRepository.ts`) - 점수 이슈 해결 후 `[kbo-merge]` console.log 전부 제거 diff --git a/src/handlers/userHandlers.ts b/src/handlers/userHandlers.ts index ae03775..87dcb69 100644 --- a/src/handlers/userHandlers.ts +++ b/src/handlers/userHandlers.ts @@ -6,6 +6,7 @@ import { createMe, deleteMe, getMe, + updateMe, } from "../services/userService.js"; export const user = onRequest(async (req, res) => { @@ -28,6 +29,12 @@ export const user = onRequest(async (req, res) => { res.status(201).json({ user: { uid: token.uid, ...u } }); return; } + if (req.method === "PATCH" && req.path === "/") { + const token = await requireAuthToken(req); + const u = await updateMe(token, req.body ?? {}); + res.status(200).json({ user: { uid: token.uid, ...u } }); + return; + } if (req.method === "DELETE" && req.path === "/") { const token = await requireAuthToken(req); await deleteMe(token); diff --git a/src/kbo/schedule.ts b/src/kbo/schedule.ts index 54c8b37..03c029f 100644 --- a/src/kbo/schedule.ts +++ b/src/kbo/schedule.ts @@ -148,12 +148,21 @@ function parsePlayCell(html: string): { }; } -function parseGameId(html: string): string | null { - const m = html.match(/gameId=([A-Za-z0-9]+)/); - return m ? m[1] : null; +function synthesizeGameId( + year: number, + mmdd: string, + awayTeamCode: string, + homeTeamCode: string +): string | null { + const m = mmdd.match(/^(\d{2})\.(\d{2})$/); + if (!m || !awayTeamCode || !homeTeamCode) return null; + return `${year}${m[1]}${m[2]}${awayTeamCode}${homeTeamCode}0`; } -export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] { +export function parseScheduleResponse( + data: ScheduleResponse, + year: number +): ScheduleGame[] { const games: ScheduleGame[] = []; let currentDate = ""; let currentDayOfWeek = ""; @@ -177,7 +186,6 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] { // [4] TV, [5] radio, [6] stadium, [7] note const timeCell = cells[offset]; const playCell = cells[offset + 1]; - const relayCell = cells[offset + 2]; const tvCell = cells[offset + 4]; const stadiumCell = cells[offset + 6]; const noteCell = cells[offset + 7]; @@ -186,7 +194,12 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] { const time = parseTime(timeCell?.Text ?? ""); const play = parsePlayCell(playCell.Text); - const gameId = parseGameId(relayCell?.Text ?? ""); + const gameId = synthesizeGameId( + year, + currentDate, + play.awayTeamCode, + play.homeTeamCode + ); const broadcast = stripTags(decodeHtmlEntities(tvCell?.Text ?? "")); const stadium = stripTags(decodeHtmlEntities(stadiumCell?.Text ?? "")); const note = stripTags(decodeHtmlEntities(noteCell?.Text ?? "")); @@ -242,7 +255,7 @@ export async function fetchSchedule( }); const json: ScheduleResponse = await res.json(); - const games = parseScheduleResponse(json); + const games = parseScheduleResponse(json, filters.year); await enrichStartingPitchers(games, filters.year, filters.series); diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 8ca2066..e6ab4c8 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -63,10 +63,11 @@ export async function createUser(uid: string, input: RegisterInput): Promise> + patch: Record ): Promise { await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true }); } diff --git a/src/services/userService.ts b/src/services/userService.ts index 8c5855b..c8fabb7 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,4 +1,5 @@ import type { DecodedIdToken } from "firebase-admin/auth"; +import { FieldValue } from "firebase-admin/firestore"; import { HttpError } from "../middleware/errors.js"; import { auth } from "../firebase.js"; import { @@ -163,6 +164,91 @@ export async function deleteMe(token: DecodedIdToken): Promise { } } +// ── 이름 변경 조건 ────────────────────────────────────────────── + +/** 이름 변경 불가 사유. null이면 통과. */ +type NameChangeRule = (user: User, newName: string) => string | null; + +/** + * 이름 변경 조건 배열. 조건 추가/삭제는 이 배열만 수정하면 된다. + * 각 규칙은 불가 사유 문자열을 반환하거나, 통과 시 null을 반환한다. + */ +export const nameChangeRules: NameChangeRule[] = [ + (user, newName) => + user.displayName === newName ? "same as current name" : null, +]; + +function validateNameChange(user: User, newName: string): void { + for (const rule of nameChangeRules) { + const reason = rule(user, newName); + if (reason) { + throw new HttpError(400, reason, "NAME_CHANGE_DENIED"); + } + } +} + +// ── updateMe ──────────────────────────────────────────────────── + +export interface UpdateMeBody { + displayName?: unknown; + favoriteTeamCode?: unknown; + knowledgeLevel?: unknown; +} + +/** + * 현재 로그인한 유저의 프로필을 부분 수정한다. + * body에 포함된 필드만 변경하며, favoriteTeamCode에 null을 보내면 응원팀을 해제한다. + */ +export async function updateMe( + token: DecodedIdToken, + body: UpdateMeBody +): Promise { + const user = await getUser(token.uid); + if (!user) { + throw new HttpError(404, "user not found", "USER_NOT_FOUND"); + } + + const patch: Record = {}; + + if (body.displayName !== undefined) { + const newName = parseDisplayName(body.displayName); + validateNameChange(user, newName); + const owner = await findUidByDisplayName(newName); + if (owner && owner !== token.uid) { + throw new HttpError(409, "nickname already taken", "NICKNAME_TAKEN"); + } + patch.displayName = newName; + } + + if (body.favoriteTeamCode !== undefined) { + patch.favoriteTeamCode = + body.favoriteTeamCode === null + ? FieldValue.delete() + : parseEnum( + "favoriteTeamCode", + body.favoriteTeamCode, + TEAM_CODE_VALUES + ); + } + + if (body.knowledgeLevel !== undefined) { + patch.knowledgeLevel = parseEnum( + "knowledgeLevel", + body.knowledgeLevel, + KNOWLEDGE_LEVEL_VALUES + ); + } + + if (Object.keys(patch).length === 0) { + throw new HttpError(400, "no fields to update", "INVALID_INPUT"); + } + + await updateUser(token.uid, patch); + + const updated = await getUser(token.uid); + return updated!; +} + /** * 닉네임 중복 체크 & 예약. 성공 시 요청 uid로 해당 닉네임을 선점한다. * 같은 uid가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다. diff --git a/tests/services/userService.test.ts b/tests/services/userService.test.ts index e564e5f..c2c46c0 100644 --- a/tests/services/userService.test.ts +++ b/tests/services/userService.test.ts @@ -6,6 +6,7 @@ import { createMe, deleteMe, getMe, + updateMe, } from "../../src/services/userService.js"; import { HttpError } from "../../src/middleware/errors.js"; import { @@ -251,6 +252,103 @@ describe("userService", () => { }); }); + describe("updateMe", () => { + it("미존재 유저는 404 + USER_NOT_FOUND", async () => { + await expect( + updateMe(fakeToken(), { displayName: "새이름" }) + ).rejects.toMatchObject({ + status: 404, + code: "USER_NOT_FOUND", + }); + }); + + it("displayName만 변경 성공", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { displayName: "새이름" }); + expect(u.displayName).toBe("새이름"); + expect(u.favoriteTeamCode).toBe("LG"); + expect(u.knowledgeLevel).toBe("casual"); + }); + + it("favoriteTeamCode만 변경 성공", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { favoriteTeamCode: "KT" }); + expect(u.favoriteTeamCode).toBe("KT"); + expect(u.displayName).toBe("유저1"); + }); + + it("knowledgeLevel만 변경 성공", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { knowledgeLevel: "expert" }); + expect(u.knowledgeLevel).toBe("expert"); + }); + + it("favoriteTeamCode를 null로 보내면 필드가 삭제된다", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { favoriteTeamCode: null }); + expect(u.favoriteTeamCode).toBeUndefined(); + }); + + it("동일 이름으로 변경 시도 → 400 NAME_CHANGE_DENIED", async () => { + await createMeWithReservation(); + await expect( + updateMe(fakeToken(), { displayName: "유저1" }) + ).rejects.toMatchObject({ + status: 400, + code: "NAME_CHANGE_DENIED", + }); + }); + + it("타인이 사용 중인 닉네임 → 409 NICKNAME_TAKEN", async () => { + await createMeWithReservation(); + await createMeWithReservation( + fakeToken({ uid: "other", sub: "other" }), + { ...validBody, displayName: "타유저" } + ); + await expect( + updateMe(fakeToken(), { displayName: "타유저" }) + ).rejects.toMatchObject({ + status: 409, + code: "NICKNAME_TAKEN", + }); + }); + + it("빈 body → 400 INVALID_INPUT", async () => { + await createMeWithReservation(); + await expect(updateMe(fakeToken(), {})).rejects.toMatchObject({ + status: 400, + code: "INVALID_INPUT", + }); + }); + + it.each([ + ["빈 displayName", { displayName: "" }], + ["11자 displayName", { displayName: "1234567890X" }], + ["잘못된 팀코드", { favoriteTeamCode: "XX" }], + ["잘못된 레벨", { knowledgeLevel: "master" }], + ])("%s → 400 INVALID_INPUT", async (_label, body) => { + await createMeWithReservation(); + await expect( + updateMe(fakeToken(), body as never) + ).rejects.toMatchObject({ + status: 400, + code: "INVALID_INPUT", + }); + }); + + it("여러 필드를 동시에 변경할 수 있다", async () => { + await createMeWithReservation(); + const u = await updateMe(fakeToken(), { + displayName: "새이름", + favoriteTeamCode: "NC", + knowledgeLevel: "beginner", + }); + expect(u.displayName).toBe("새이름"); + expect(u.favoriteTeamCode).toBe("NC"); + expect(u.knowledgeLevel).toBe("beginner"); + }); + }); + describe("createMe 예약 연동", () => { it("예약 없이 createMe → 409 RESERVATION_MISSING", async () => { await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({