Implement user profile updates and synthesize KBO game IDs.
- 사용자 프로필 수정을 위한 `PATCH` 핸들러와 `updateMe` 서비스를 구현하여 닉네임, 응원팀, 지식 수준 변경 기능을 추가했습니다. - HTML 정규식 파싱 오류로 인해 `null`이 반환되던 `gameId` 문제를 해결하기 위해 일정 메타데이터 기반의 ID 생성 로직을 도입했습니다. - `updateUser`가 `FieldValue`를 수용하도록 개선하여 응원팀 설정 해제(필드 삭제)가 가능하도록 수정했습니다. - 닉네임 중복 확인 및 변경 규칙 검증을 포함한 유저 서비스 테스트 케이스를 작성하고 `TODO.md` 이슈를 해결했습니다.
This commit is contained in:
parent
c3b62c5527
commit
a59ccf3314
6
TODO.md
6
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 전부 제거
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -63,10 +63,11 @@ export async function createUser(uid: string, input: RegisterInput): Promise<voi
|
||||
|
||||
/**
|
||||
* 유저 문서의 일부 필드를 병합 업데이트한다.
|
||||
* FieldValue.delete() 등을 전달할 수 있도록 값 타입에 FieldValue를 허용한다.
|
||||
*/
|
||||
export async function updateUser(
|
||||
uid: string,
|
||||
patch: Partial<Pick<User, "displayName" | "photoUrl" | "favoriteTeamCode" | "knowledgeLevel">>
|
||||
patch: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await firestore.collection(COLLECTION).doc(uid).set(patch, { merge: true });
|
||||
}
|
||||
|
||||
@ -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<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가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다.
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user