Implement user profile updates and synthesize KBO game IDs.

- 사용자 프로필 수정을 위한 `PATCH` 핸들러와 `updateMe` 서비스를 구현하여 닉네임, 응원팀, 지식 수준 변경 기능을 추가했습니다.
- HTML 정규식 파싱 오류로 인해 `null`이 반환되던 `gameId` 문제를 해결하기 위해 일정 메타데이터 기반의 ID 생성 로직을 도입했습니다.
- `updateUser`가 `FieldValue`를 수용하도록 개선하여 응원팀 설정 해제(필드 삭제)가 가능하도록 수정했습니다.
- 닉네임 중복 확인 및 변경 규칙 검증을 포함한 유저 서비스 테스트 케이스를 작성하고 `TODO.md` 이슈를 해결했습니다.
This commit is contained in:
윤정민 2026-04-18 15:53:29 +09:00
parent c3b62c5527
commit a59ccf3314
6 changed files with 213 additions and 14 deletions

View File

@ -9,12 +9,6 @@
- 값이 들어오는데 0으로 보이면 → 프런트 렌더링 로직 점검 - 값이 들어오는데 0으로 보이면 → 프런트 렌더링 로직 점검
- 값이 null/0으로 오면 → 응답 직렬화/DTO 매핑 레이어 점검 (`scheduleService.ts` 등) - 값이 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`) - [ ] **`mergeLiveIntoSchedule` 진단 로그 제거** (`src/repositories/kboRepository.ts`)
- 점수 이슈 해결 후 `[kbo-merge]` console.log 전부 제거 - 점수 이슈 해결 후 `[kbo-merge]` console.log 전부 제거

View File

@ -6,6 +6,7 @@ import {
createMe, createMe,
deleteMe, deleteMe,
getMe, getMe,
updateMe,
} from "../services/userService.js"; } from "../services/userService.js";
export const user = onRequest(async (req, res) => { 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 } }); res.status(201).json({ user: { uid: token.uid, ...u } });
return; 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 === "/") { if (req.method === "DELETE" && req.path === "/") {
const token = await requireAuthToken(req); const token = await requireAuthToken(req);
await deleteMe(token); await deleteMe(token);

View File

@ -148,12 +148,21 @@ function parsePlayCell(html: string): {
}; };
} }
function parseGameId(html: string): string | null { function synthesizeGameId(
const m = html.match(/gameId=([A-Za-z0-9]+)/); year: number,
return m ? m[1] : null; 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[] = []; const games: ScheduleGame[] = [];
let currentDate = ""; let currentDate = "";
let currentDayOfWeek = ""; let currentDayOfWeek = "";
@ -177,7 +186,6 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] {
// [4] TV, [5] radio, [6] stadium, [7] note // [4] TV, [5] radio, [6] stadium, [7] note
const timeCell = cells[offset]; const timeCell = cells[offset];
const playCell = cells[offset + 1]; const playCell = cells[offset + 1];
const relayCell = cells[offset + 2];
const tvCell = cells[offset + 4]; const tvCell = cells[offset + 4];
const stadiumCell = cells[offset + 6]; const stadiumCell = cells[offset + 6];
const noteCell = cells[offset + 7]; const noteCell = cells[offset + 7];
@ -186,7 +194,12 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] {
const time = parseTime(timeCell?.Text ?? ""); const time = parseTime(timeCell?.Text ?? "");
const play = parsePlayCell(playCell.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 broadcast = stripTags(decodeHtmlEntities(tvCell?.Text ?? ""));
const stadium = stripTags(decodeHtmlEntities(stadiumCell?.Text ?? "")); const stadium = stripTags(decodeHtmlEntities(stadiumCell?.Text ?? ""));
const note = stripTags(decodeHtmlEntities(noteCell?.Text ?? "")); const note = stripTags(decodeHtmlEntities(noteCell?.Text ?? ""));
@ -242,7 +255,7 @@ export async function fetchSchedule(
}); });
const json: ScheduleResponse = await res.json(); const json: ScheduleResponse = await res.json();
const games = parseScheduleResponse(json); const games = parseScheduleResponse(json, filters.year);
await enrichStartingPitchers(games, filters.year, filters.series); await enrichStartingPitchers(games, filters.year, filters.series);

View File

@ -63,10 +63,11 @@ export async function createUser(uid: string, input: RegisterInput): Promise<voi
/** /**
* . * .
* FieldValue.delete() FieldValue를 .
*/ */
export async function updateUser( export async function updateUser(
uid: string, uid: string,
patch: Partial<Pick<User, "displayName" | "photoUrl" | "favoriteTeamCode" | "knowledgeLevel">> patch: Record<string, unknown>
): Promise<void> { ): Promise<void> {
await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true }); await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true });
} }

View File

@ -1,4 +1,5 @@
import type { DecodedIdToken } from "firebase-admin/auth"; import type { DecodedIdToken } from "firebase-admin/auth";
import { FieldValue } from "firebase-admin/firestore";
import { HttpError } from "../middleware/errors.js"; import { HttpError } from "../middleware/errors.js";
import { auth } from "../firebase.js"; import { auth } from "../firebase.js";
import { import {
@ -163,6 +164,91 @@ export async function deleteMe(token: DecodedIdToken): Promise<void> {
} }
} }
// ── 이름 변경 조건 ──────────────────────────────────────────────
/** 이름 변경 불가 사유. 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<User> {
const user = await getUser(token.uid);
if (!user) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}
const patch: Record<string, unknown> = {};
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<TeamCode>(
"favoriteTeamCode",
body.favoriteTeamCode,
TEAM_CODE_VALUES
);
}
if (body.knowledgeLevel !== undefined) {
patch.knowledgeLevel = parseEnum<KnowledgeLevel>(
"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로 .
* uid가 . * uid가 .

View File

@ -6,6 +6,7 @@ import {
createMe, createMe,
deleteMe, deleteMe,
getMe, getMe,
updateMe,
} from "../../src/services/userService.js"; } from "../../src/services/userService.js";
import { HttpError } from "../../src/middleware/errors.js"; import { HttpError } from "../../src/middleware/errors.js";
import { 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 예약 연동", () => { describe("createMe 예약 연동", () => {
it("예약 없이 createMe → 409 RESERVATION_MISSING", async () => { it("예약 없이 createMe → 409 RESERVATION_MISSING", async () => {
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({ await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({