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으로 보이면 → 프런트 렌더링 로직 점검
|
- 값이 들어오는데 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 전부 제거
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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가 이전에 다른 닉네임을 예약했다면 해제 후 이전 이름을 반환한다.
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user