Serialize API dates as UTC ISO strings via response DTOs

- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
This commit is contained in:
윤정민 2026-07-20 16:28:45 +09:00
parent 6d7635ff4a
commit 107f75c23a
27 changed files with 1048 additions and 83 deletions

View File

@ -9,6 +9,13 @@ import {
reportMessage,
sendMessage,
} from "../services/chatService";
import type {
ChatHistoryPageDto,
ChatQuotaDto,
ChatReportDto,
ChatSendResultDto,
ChatSuggestionsDto,
} from "../types/dto/chatDto";
/**
* AI () (§3). `/chat/*` API다.
@ -43,14 +50,14 @@ export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => {
if (segs[0] === "messages" && segs.length === 1) {
const uid = await requireChatAuth(req);
if (req.method === "POST") {
const result = await sendMessage(uid, req.body ?? {});
const result: ChatSendResultDto = await sendMessage(uid, req.body ?? {});
res.status(200).json(result);
return;
}
if (req.method === "GET") {
const cursor = req.query.cursor != null ? String(req.query.cursor) : undefined;
const limit = req.query.limit != null ? String(req.query.limit) : undefined;
const result = await getMessages(uid, cursor, limit);
const result: ChatHistoryPageDto = await getMessages(uid, cursor, limit);
res.status(200).json(result);
return;
}
@ -58,21 +65,21 @@ export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => {
if (segs[0] === "messages" && segs.length === 3 && segs[2] === "report" && req.method === "POST") {
const uid = await requireChatAuth(req);
const result = await reportMessage(uid, segs[1], req.body ?? {});
const result: ChatReportDto = await reportMessage(uid, segs[1], req.body ?? {});
res.status(200).json(result);
return;
}
if (segs[0] === "quota" && req.method === "GET") {
const uid = await requireChatAuth(req);
const result = await getQuota(uid);
const result: ChatQuotaDto = await getQuota(uid);
res.status(200).json(result);
return;
}
if (segs[0] === "suggestions" && req.method === "GET") {
const uid = await requireChatAuth(req);
const result = await getSuggestions(uid);
const result: ChatSuggestionsDto = await getSuggestions(uid);
res.status(200).json(result);
return;
}

View File

@ -6,6 +6,13 @@ import { getGameList } from "../services/gameListService";
import { getGameDetail } from "../services/gameDetailService";
import { TeamCode } from "../types/panit";
import { kboTodayKst } from "../types/dateString";
import type {
KboGameDetailDto,
KboGamesDto,
KboPlayerDto,
KboRankDto,
KboScheduleDto,
} from "../types/dto/kboDto";
enum KboPath {
Rank = "rank",
@ -51,7 +58,7 @@ export const kbo = onRequest(async (req, res) => {
}
}
const result = await getRank(years);
const result: KboRankDto = await getRank(years);
res.status(200).json(result);
return;
}
@ -94,7 +101,7 @@ export const kbo = onRequest(async (req, res) => {
team = raw as TeamCode;
}
const result = await getSchedule(year, month, team, series, day);
const result: KboScheduleDto = await getSchedule(year, month, team, series, day);
res.status(200).json(result);
return;
}
@ -109,7 +116,7 @@ export const kbo = onRequest(async (req, res) => {
return;
}
const result = await getGameList(date, series, league);
const result: KboGamesDto = await getGameList(date, series, league);
res.status(200).json(result);
return;
}
@ -133,7 +140,7 @@ export const kbo = onRequest(async (req, res) => {
return;
}
const result = await getGameDetail({ gameId, series, league, season });
const result: KboGameDetailDto = await getGameDetail({ gameId, series, league, season });
res.status(200).json(result);
return;
}
@ -170,7 +177,7 @@ export const kbo = onRequest(async (req, res) => {
team = raw as TeamCode;
}
const result = await getPlayerStats({
const result: KboPlayerDto = await getPlayerStats({
type,
year,
team,

View File

@ -10,6 +10,13 @@ import {
} from "../services/predictionService";
import { getScoreboard } from "../services/scoreboardService";
import { kboTodayKst } from "../types/dateString";
import type {
GamesResponseDto,
MyVotesDto,
PredictionMutationDto,
ScoreboardDto,
VoteSummaryDto,
} from "../types/dto/predictionDto";
/**
* `date` .
@ -28,7 +35,8 @@ export const prediction = onRequest(async (req, res) => {
if (tail === "games" && req.method === "GET") {
const date = resolveDateParam(req.query.date);
const games = await listGamesByDate(date);
res.status(200).json({ date, games });
const dto: GamesResponseDto = { date, games };
res.status(200).json(dto);
return;
}
@ -38,7 +46,7 @@ export const prediction = onRequest(async (req, res) => {
if (type !== "team" && type !== "overall") {
throw new HttpError(400, `invalid type: ${type}`);
}
const result = await getScoreboard(uid, type);
const result: ScoreboardDto = await getScoreboard(uid, type);
res.set("Cache-Control", "private, max-age=60");
res.status(200).json(result);
return;
@ -46,7 +54,7 @@ export const prediction = onRequest(async (req, res) => {
if (tail === "summary" && req.method === "GET") {
const gameId = String(req.query.gameId ?? "");
const result = await getSummary(gameId);
const result: VoteSummaryDto = await getSummary(gameId);
res.set("Cache-Control", "public, max-age=5");
res.status(200).json(result);
return;
@ -55,20 +63,20 @@ export const prediction = onRequest(async (req, res) => {
if (tail === "prediction" || segs.length === 1) {
if (req.method === "POST") {
const uid = await requireAuth(req);
const result = await createPrediction(uid, req.body ?? {});
const result: PredictionMutationDto = await createPrediction(uid, req.body ?? {});
res.status(201).json(result);
return;
}
if (req.method === "PUT") {
const uid = await requireAuth(req);
const result = await updatePrediction(uid, req.body ?? {});
const result: PredictionMutationDto = await updatePrediction(uid, req.body ?? {});
res.status(200).json(result);
return;
}
if (req.method === "GET") {
const uid = await requireAuth(req);
const date = resolveDateParam(req.query.date);
const result = await getMyVotes(uid, date);
const result: MyVotesDto = await getMyVotes(uid, date);
res.status(200).json(result);
return;
}

View File

@ -6,16 +6,111 @@ import { listLedger } from "../repositories/pointLedgerRepository";
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
import { computeEligibility } from "../services/eligibilityService";
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService";
import {
EMPTY_WALLET_DTO,
toLedgerEntryDto,
toOrderDto,
toWalletDto,
type CancelOrderDto,
type CreateOrderResponseDto,
type EligibilityDto,
type LedgerPageDto,
type OrderPageDto,
type ProductDetailDto,
type ProductSummaryDto,
type WalletDto,
} from "../types/dto/rewardDto";
export const reward = onRequest(async (req, res) => { try { const uid = await requireAuth(req); const path = req.path.replace(/^\/+|\/+$/g, "");
if (req.method === "GET" && path === "wallet") { res.json((await getWallet(uid)) ?? { availableBalance: 0, reservedBalance: 0, totalEarned: 0, totalSpent: 0, version: 0 }); return; }
if (req.method === "GET" && path === "ledger") { res.json(await listLedger(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
if (req.method === "GET" && path === "products") { res.json(await getCatalog()); return; }
const product = path.match(/^products\/([^/]+)$/); if (req.method === "GET" && product) { const p = await getCatalogProduct(product[1]); if (!p) { res.status(404).json({ error: "PRODUCT_NOT_FOUND" }); return; } res.json(p); return; }
if (req.method === "GET" && path === "eligibility") { res.json(await computeEligibility(uid)); return; }
if (req.method === "POST" && path === "orders") { res.status(201).json(await createOrder(uid, req.body)); return; }
if (req.method === "GET" && path === "orders") { res.json(await listOrders(uid, Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
const order = path.match(/^orders\/([^/]+)$/); if (req.method === "GET" && order) { const o = await getOrder(order[1]); if (!o || o.uid !== uid) { res.status(404).json({ error: "ORDER_NOT_FOUND" }); return; } res.json(o); return; }
const cancel = path.match(/^orders\/([^/]+)\/cancel$/); if (req.method === "POST" && cancel) { res.json(await cancelOrder(uid, cancel[1])); return; }
res.status(404).json({ error: "not found" });
} catch (err) { sendError(res, err); } });
function pageArgs(req: { query: Record<string, unknown> }): [number, string | undefined] {
return [Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined];
}
export const reward = onRequest(async (req, res) => {
try {
const uid = await requireAuth(req);
const path = req.path.replace(/^\/+|\/+$/g, "");
if (req.method === "GET" && path === "wallet") {
const wallet = await getWallet(uid);
const dto: WalletDto = wallet ? toWalletDto(wallet) : EMPTY_WALLET_DTO;
res.json(dto);
return;
}
if (req.method === "GET" && path === "ledger") {
const page = await listLedger(uid, ...pageArgs(req));
const dto: LedgerPageDto = {
items: page.items.map((entry) => toLedgerEntryDto(entry.id, entry)),
cursor: page.cursor,
};
res.json(dto);
return;
}
if (req.method === "GET" && path === "products") {
const dto: ProductSummaryDto[] = await getCatalog();
res.json(dto);
return;
}
const product = path.match(/^products\/([^/]+)$/);
if (req.method === "GET" && product) {
const dto: ProductDetailDto | null = await getCatalogProduct(product[1]);
if (!dto) {
res.status(404).json({ error: "PRODUCT_NOT_FOUND" });
return;
}
res.json(dto);
return;
}
if (req.method === "GET" && path === "eligibility") {
const dto: EligibilityDto = await computeEligibility(uid);
res.json(dto);
return;
}
if (req.method === "POST" && path === "orders") {
const created = await createOrder(uid, req.body);
const dto: CreateOrderResponseDto = {
order: toOrderDto(created.order.id, created.order),
deduplicated: created.deduplicated,
availableBalance: created.availableBalance,
};
res.status(201).json(dto);
return;
}
if (req.method === "GET" && path === "orders") {
const page = await listOrders(uid, ...pageArgs(req));
const dto: OrderPageDto = {
items: page.items.map((order) => toOrderDto(order.id, order)),
cursor: page.cursor,
};
res.json(dto);
return;
}
const order = path.match(/^orders\/([^/]+)$/);
if (req.method === "GET" && order) {
const found = await getOrder(order[1]);
if (!found || found.uid !== uid) {
res.status(404).json({ error: "ORDER_NOT_FOUND" });
return;
}
res.json(toOrderDto(found.id, found));
return;
}
const cancel = path.match(/^orders\/([^/]+)\/cancel$/);
if (req.method === "POST" && cancel) {
const dto: CancelOrderDto = await cancelOrder(uid, cancel[1]);
res.json(dto);
return;
}
res.status(404).json({ error: "not found" });
} catch (err) {
sendError(res, err);
}
});

View File

@ -2,6 +2,7 @@ import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth";
import { sendError } from "../middleware/errors";
import { getStats, getHistory } from "../services/statsService";
import type { UserStatsDto, VoteHistoryDto } from "../types/dto/statsDto";
export const stats = onRequest(async (req, res) => {
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
@ -12,14 +13,14 @@ export const stats = onRequest(async (req, res) => {
if (tail === "history" && req.method === "GET") {
const date = String(req.query.date ?? "");
const result = await getHistory(uid, date);
const result: VoteHistoryDto = await getHistory(uid, date);
res.status(200).json(result);
return;
}
if ((tail === "stats" || segs.length === 1) && req.method === "GET") {
const period = req.query.period ? String(req.query.period) : undefined;
const result = await getStats(uid, period);
const result: UserStatsDto = await getStats(uid, period);
res.status(200).json(result);
return;
}

View File

@ -9,6 +9,28 @@ import {
updateMe,
updateNotifications,
} from "../services/userService";
import type {
MeResponseDto,
NotificationsResponseDto,
UserProfileDto,
} from "../types/dto/userDto";
/** `uid` + 프로필 DTO를 `MeResponseDto`로 조립한다. 필드를 하나하나 명시해 새 필드 누락을 방지한다. */
function toMeResponse(uid: string, profile: UserProfileDto): MeResponseDto {
return {
user: {
uid,
displayName: profile.displayName,
email: profile.email,
photoUrl: profile.photoUrl,
provider: profile.provider,
favoriteTeamCode: profile.favoriteTeamCode,
knowledgeLevel: profile.knowledgeLevel,
createdAt: profile.createdAt,
lastJudgedDate: profile.lastJudgedDate,
},
};
}
export const user = onRequest(async (req, res) => {
try {
@ -21,25 +43,26 @@ export const user = onRequest(async (req, res) => {
if (req.method === "GET" && req.path === "/") {
const token = await requireAuthToken(req);
const u = await getMe(token);
res.status(200).json({ user: { uid: token.uid, ...u } });
res.status(200).json(toMeResponse(token.uid, u));
return;
}
if (req.method === "POST" && req.path === "/") {
const token = await requireAuthToken(req);
const u = await createMe(token, req.body ?? {});
res.status(201).json({ user: { uid: token.uid, ...u } });
res.status(201).json(toMeResponse(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 } });
res.status(200).json(toMeResponse(token.uid, u));
return;
}
if (req.method === "PATCH" && req.path === "/notifications") {
const token = await requireAuthToken(req);
const notifications = await updateNotifications(token, req.body ?? {});
res.status(200).json({ notifications });
const body: NotificationsResponseDto = { notifications };
res.status(200).json(body);
return;
}
if (req.method === "DELETE" && req.path === "/") {

View File

@ -212,9 +212,13 @@ export async function reserveRequestTx(params: ReserveParams): Promise<ReserveOu
});
}
/** KST 자정 리셋 시각(다음 날 00:00 KST, ISO 8601). */
/**
* 00:00 KST UTC ISO 8601 .
*
* KST , `...Z` .
*/
export function kstResetAt(date: DateString): string {
return `${addDays(date, 1)}T00:00:00+09:00`;
return new Date(`${addDays(date, 1)}T00:00:00+09:00`).toISOString();
}
// ── 차감 복원(§3.1 복원 규칙) ──

View File

@ -6,8 +6,10 @@ import { HttpError } from "../middleware/errors";
import { getMonthDoc, getMonthDocTx, getStateDocTx, monthDocRef, stateDocRef } from "../repositories/attendanceRepository";
import { getAvailableBalance, getWalletTx } from "../repositories/walletRepository";
import { applyPointChangesTx } from "./pointService";
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceMonth, type AttendanceStateDoc, type PointAward } from "../types/panit";
import { AttendanceResult, PointLedgerType, type AttendanceCheckInResult, type AttendanceStateDoc, type PointAward } from "../types/panit";
import { addDays, isMonthString, monthOf, toDateString } from "../types/dateString";
import { toIso } from "../types/dto/iso";
import type { AttendanceMonthDto, CheckInDto } from "../types/dto/attendanceDto";
const SKEW = 5 * 60 * 1000; const TZ = /(Z|[+-]\d{2}:?\d{2})$/;
export interface CheckInBody { clientAttemptedAt?: unknown; clientIdempotencyKey?: unknown }
@ -15,15 +17,34 @@ function attempted(v: unknown) { if (typeof v !== "string" || !TZ.test(v) || Num
function key(v: unknown) { if (typeof v !== "string" || v.length < 1 || v.length > 128) throw new HttpError(400, "invalid clientIdempotencyKey", "INVALID_INPUT"); return v; }
function insert(days: number[], day: number) { return [...days, day].sort((a, b) => a - b); }
export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise<AttendanceCheckInResult> {
/**
* `serverNow` UTC ISO . Firestore에 `lastResult.serverNow`
* admin Timestamp , .
*/
function serverNowIso(v: Timestamp | string): string { return typeof v === "string" ? v : toIso(v); }
/** 내부 `AttendanceCheckInResult`(Timestamp 보유)를 응답용 `CheckInDto`(ISO 문자열)로 변환한다. */
function toCheckInDto(r: AttendanceCheckInResult): CheckInDto {
return {
result: r.result,
serverNow: serverNowIso(r.serverNow),
attendedDays: r.attendedDays,
totalCount: r.totalCount,
pointsAwarded: r.pointsAwarded,
balanceAfter: r.balanceAfter,
attendanceStreak: r.attendanceStreak,
};
}
export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise<CheckInDto> {
const clientAt = attempted(body.clientAttemptedAt); const idem = key(body.clientIdempotencyKey); const nowDate = new Date();
if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: Timestamp.fromDate(nowDate) });
if (Math.abs(clientAt.getTime() - nowDate.getTime()) > SKEW) throw new HttpError(409, "client clock differs from server by more than 5 minutes", "CLOCK_SKEW", { serverNow: nowDate.toISOString() });
const today = toDateString(nowDate); const month = monthOf(today); const day = Number(today.slice(8));
return firestore.runTransaction(async (tx) => {
const monthDoc = await getMonthDocTx(tx, token.uid, month); const state = await getStateDocTx(tx, token.uid);
if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return monthDoc.lastResult;
if (monthDoc?.lastIdempotencyKey === idem && monthDoc.lastResult) return toCheckInDto(monthDoc.lastResult);
const days = monthDoc?.days ?? [];
if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; }
if (days.includes(day)) { const wallet = await getWalletTx(tx, token.uid); return { result: AttendanceResult.AlreadyCheckedIn, serverNow: nowDate.toISOString(), attendedDays: days, totalCount: days.length, pointsAwarded: [], balanceAfter: wallet?.availableBalance ?? 0, attendanceStreak: state?.currentAttendanceStreak ?? 0 }; }
const consecutive = state && addDays(state.lastAttendanceDate, 1) === today;
const streak = consecutive ? state.currentAttendanceStreak + 1 : 1; const cycle = consecutive ? state.streakCycleStart : today;
const awards: PointAward[] = [{ type: PointLedgerType.AttendanceDaily, amount: ATTENDANCE_DAILY_POINTS }];
@ -33,7 +54,7 @@ export async function checkIn(token: DecodedIdToken, body: CheckInBody): Promise
const wallet = await applyPointChangesTx(tx, token.uid, changes); const newDays = insert(days, day);
const result: AttendanceCheckInResult = { result: AttendanceResult.CheckedIn, serverNow: Timestamp.fromDate(nowDate), attendedDays: newDays, totalCount: newDays.length, pointsAwarded: awards, balanceAfter: wallet!.availableBalance, attendanceStreak: streak };
const next: AttendanceStateDoc = { lastAttendanceDate: today, currentAttendanceStreak: streak, streakCycleStart: cycle, highestAttendanceStreak: Math.max(state?.highestAttendanceStreak ?? 0, streak), updatedAt: Timestamp.fromDate(nowDate) };
tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return result;
tx.set(stateDocRef(token.uid), next); tx.set(monthDocRef(token.uid, month), { days: newDays, lastCheckedInAt: Timestamp.fromDate(nowDate), lastIdempotencyKey: idem, lastResult: result }, { merge: true }); return toCheckInDto(result);
});
}
export async function getMonth(token: DecodedIdToken, raw: unknown): Promise<AttendanceMonth> { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; }
export async function getMonth(token: DecodedIdToken, raw: unknown): Promise<AttendanceMonthDto> { if (!isMonthString(raw)) throw new HttpError(400, "month must be YYYY-MM", "INVALID_INPUT"); const [doc, balance] = await Promise.all([getMonthDoc(token.uid, raw), getAvailableBalance(token.uid)]); if (!doc) throw new HttpError(404, "month not found", "MONTH_NOT_FOUND"); return { month: raw, attendedDays: doc.days, totalCount: doc.days.length, balance }; }

View File

@ -40,6 +40,7 @@ import { CHAT_REPORT_REASONS, type ChatConfig, type ChatMessagesPage, type ChatM
type ChatSuggestionsView, type ChatToolCallInfo, type NavAction } from "../types/chat";
import type { User } from "../types/panit";
import { todayKst, type DateString } from "../types/dateString";
import { toIso } from "../types/dto/iso";
/**
* AI () `POST /chat/messages` 11 (§3.1) .
@ -47,12 +48,6 @@ import { todayKst, type DateString } from "../types/dateString";
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/** KST ISO 8601(+09:00) 포맷. */
export function toKstIso(ts: Timestamp): string {
const kst = new Date(ts.toMillis() + 9 * 3600 * 1000);
return kst.toISOString().replace(/\.\d{3}Z$/, "+09:00");
}
/** 활성 threadId 결정(§3 스레드 결정 규칙) — 클라이언트는 스레드를 지정하지 않는다. */
function resolveThreadId(user: User | null): string {
return resolveTeamCode(user?.favoriteTeamCode) ?? "default";
@ -101,7 +96,7 @@ async function buildSendResult(
crisis,
remainingCount: Math.max(0, config.dailyLimit - used),
limit: config.dailyLimit,
createdAt: toKstIso(createdAt),
createdAt: toIso(createdAt),
...(toolCalls && toolCalls.length > 0 ? { toolCalls: withToolLabels(toolCalls) } : {}),
...(actions && actions.length > 0 ? { actions } : {}),
};
@ -472,7 +467,7 @@ export async function getMessages(
role: m.role,
content: m.content,
crisis: m.crisis,
createdAt: toKstIso(m.createdAt),
createdAt: toIso(m.createdAt),
...(m.role === "user" && m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
...(m.role === "assistant" && m.toolCalls?.length ? { toolCalls: withToolLabels(m.toolCalls) } : {}),
...(m.role === "assistant" && m.actions?.length ? { actions: m.actions } : {}),

View File

@ -1,5 +1,5 @@
import { HttpError } from "../middleware/errors";
import { getGame, listByDate, type GameWithId } from "../repositories/gameRepository";
import { getGame, listByDate } from "../repositories/gameRepository";
import {
getUserVote,
submitVote,
@ -7,9 +7,16 @@ import {
getCounts,
getUserDateVotes,
} from "../repositories/voteRepository";
import { DRAW_TEAM_CODE, type Game, type VoteEntry, type VoteSide } from "../types/panit";
import { DRAW_TEAM_CODE, type Game, type VoteSide } from "../types/panit";
import { fromTimestamp, parseDateString, type DateString } from "../types/dateString";
import { MemCache } from "../lib/memCache";
import {
toGameDto,
type GameDto,
type MyVotesDto,
type PredictionMutationDto,
type VoteSummaryDto,
} from "../types/dto/predictionDto";
type SummaryCounts = { homeCount: number; awayCount: number; drawCount: number };
@ -38,7 +45,7 @@ async function loadWaitingGame(gameId: string): Promise<Game> {
export async function createPrediction(
uid: string,
body: { gameId?: string; selectedTeamCode?: string }
): Promise<{ ok: true }> {
): Promise<PredictionMutationDto> {
if (!body.gameId || !body.selectedTeamCode) {
throw new HttpError(400, "gameId and selectedTeamCode required");
}
@ -62,7 +69,7 @@ export async function createPrediction(
export async function updatePrediction(
uid: string,
body: { gameId?: string; selectedTeamCode?: string }
): Promise<{ ok: true; changed: boolean }> {
): Promise<PredictionMutationDto> {
if (!body.gameId || !body.selectedTeamCode) {
throw new HttpError(400, "gameId and selectedTeamCode required");
}
@ -90,7 +97,7 @@ export async function updatePrediction(
export async function getMyVotes(
uid: string,
date: string
): Promise<Record<string, VoteEntry>> {
): Promise<MyVotesDto> {
try {
return getUserDateVotes(uid, parseDateString(date));
} catch (err) {
@ -98,17 +105,16 @@ export async function getMyVotes(
}
}
export async function listGamesByDate(date: string): Promise<GameWithId[]> {
export async function listGamesByDate(date: string): Promise<GameDto[]> {
try {
return await listByDate(parseDateString(date));
const games = await listByDate(parseDateString(date));
return games.map(toGameDto);
} catch (err) {
throw new HttpError(400, (err as Error).message);
}
}
export async function getSummary(
gameId: string
): Promise<{ homeCount: number; awayCount: number; drawCount: number }> {
export async function getSummary(gameId: string): Promise<VoteSummaryDto> {
if (!gameId) throw new HttpError(400, "gameId required");
return summaryCache.getOrFetch(gameId, () => getCounts(gameId));
}

View File

@ -1,7 +1,10 @@
import { MemCache } from "../lib/memCache";
import { getProduct, listProducts } from "../repositories/productRepository";
import type { ProductDetailDto, ProductSummaryDto } from "../types/dto/rewardDto";
const cache = new MemCache<unknown>(60_000, 100);
// 목록과 상세는 노출 필드가 달라 캐시를 분리한다 (하나의 캐시에 담으면 값 타입이 unknown 으로 뭉개진다).
const listCache = new MemCache<ProductSummaryDto[]>(60_000, 1);
const detailCache = new MemCache<ProductDetailDto | null>(60_000, 100);
function catalogSummary<T extends {
id: string;
@ -23,16 +26,16 @@ function catalogSummary<T extends {
};
}
export async function getCatalog() {
return cache.getOrFetch("list", async () =>
export async function getCatalog(): Promise<ProductSummaryDto[]> {
return listCache.getOrFetch("list", async () =>
(await listProducts())
.sort((a, b) => a.displayOrder - b.displayOrder)
.map(catalogSummary)
);
}
export async function getCatalogProduct(id: string) {
return cache.getOrFetch(`product:${id}`, async () => {
export async function getCatalogProduct(id: string): Promise<ProductDetailDto | null> {
return detailCache.getOrFetch(`product:${id}`, async () => {
const product = await getProduct(id);
if (!product || !product.active || !product.redeemable) return null;
return {
@ -43,6 +46,6 @@ export async function getCatalogProduct(id: string) {
}
export function invalidateRewardCatalog(productId?: string) {
cache.delete("list");
if (productId) cache.delete(`product:${productId}`);
listCache.delete("list");
if (productId) detailCache.delete(`product:${productId}`);
}

View File

@ -6,6 +6,8 @@ import {getAll, getDay} from "../repositories/voteHistoryRepository";
import {getUser} from "../repositories/userRepository";
import {hasMissedGameDayBetween} from "./judgmentService";
import type {DailyJudgment, StatsResponse, VoteHistoryDoc} from "../types/panit";
import {EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto} from "../types/dto/statsDto";
import type {UserStatsDto, VoteHistoryDto} from "../types/dto/statsDto";
import {
addDays,
parseDateString,
@ -245,7 +247,7 @@ function cachePath(uid: string, key: string): string {
* @param uid - ID
* @param periodParam - (`"2026"`, `"2026-04"`, `"2026-04-23"` ). .
*/
export async function getStats(uid: string, periodParam?: string): Promise<StatsResponse> {
export async function getStats(uid: string, periodParam?: string): Promise<UserStatsDto> {
const period = parsePeriod(periodParam);
const key = periodParam && periodParam !== "current" ? periodParam : "current";
@ -288,7 +290,7 @@ export async function invalidateStats(uid: string): Promise<void> {
* @param date - (`YYYY-MM-DD`)
* @throws {HttpError} 400
*/
export async function getHistory(uid: string, date: string): Promise<VoteHistoryDoc> {
export async function getHistory(uid: string, date: string): Promise<VoteHistoryDto> {
let parsed;
try {
parsed = parseDateString(date);
@ -296,5 +298,6 @@ export async function getHistory(uid: string, date: string): Promise<VoteHistory
throw new HttpError(400, (err as Error).message);
}
const doc = await getDay(uid, parsed);
return doc ?? {data: []};
if (!doc) return EMPTY_VOTE_HISTORY_DTO;
return toVoteHistoryDto(doc);
}

View File

@ -28,15 +28,17 @@ import {
type NotificationsMap,
type Provider,
type User,
type UserProfile,
} from "../types/panit";
import { toIso } from "../types/dto/iso";
import type { CheckNicknameDto, UserProfileDto } from "../types/dto/userDto";
/**
* User .
* streak/// `/stats` .
* (user doc의 streak은 lazy .)
* `createdAt` UTC ISO .
*/
function toUserProfile(user: User): UserProfile {
function toUserProfile(user: User): UserProfileDto {
return {
displayName: user.displayName,
email: user.email,
@ -44,7 +46,7 @@ function toUserProfile(user: User): UserProfile {
provider: user.provider,
favoriteTeamCode: user.favoriteTeamCode,
knowledgeLevel: user.knowledgeLevel,
createdAt: user.createdAt,
createdAt: toIso(user.createdAt),
lastJudgedDate: user.lastJudgedDate,
};
}
@ -113,7 +115,7 @@ function samePhotoUrl(a: string | undefined, b: string | undefined): boolean {
* ( write하지 write ).
* .
*/
export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
export async function getMe(token: DecodedIdToken): Promise<UserProfileDto> {
const user = await getUser(token.uid);
// 비활성화(탈퇴) 계정은 미존재로 취급 — 잔여 토큰으로 접근해도 온보딩으로 유도.
if (!user || user.active === false) {
@ -142,7 +144,7 @@ export interface CreateMeBody {
export async function createMe(
token: DecodedIdToken,
body: CreateMeBody
): Promise<UserProfile> {
): Promise<UserProfileDto> {
const existing = await getUser(token.uid);
if (existing) {
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
@ -183,12 +185,12 @@ export async function createMe(
// 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이
// serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영.
const profile: UserProfile = {
const profile: UserProfileDto = {
displayName,
email: token.email,
provider,
knowledgeLevel,
createdAt: Timestamp.now(),
createdAt: toIso(Timestamp.now()),
};
if (token.picture) profile.photoUrl = token.picture;
if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode;
@ -297,7 +299,7 @@ export interface UpdateMeBody {
export async function updateMe(
token: DecodedIdToken,
body: UpdateMeBody
): Promise<UserProfile> {
): Promise<UserProfileDto> {
const user = await getUser(token.uid);
if (!user || user.active === false) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
@ -425,7 +427,7 @@ export async function updateNotifications(
export async function checkNickname(
token: DecodedIdToken,
displayNameRaw: unknown
): Promise<{ available: true; previousReservation: string | null }> {
): Promise<CheckNicknameDto> {
const displayName = parseDisplayName(displayNameRaw);
const existing = await getUser(token.uid);

View File

@ -0,0 +1,26 @@
import type { AttendanceResult, PointLedgerType } from "../panit";
/** 포인트 지급 항목 (응답용). */
export interface PointAwardDto {
type: PointLedgerType;
amount: number;
}
/** `POST /attendance/check-in` 응답. `serverNow`는 UTC ISO 8601 문자열. */
export interface CheckInDto {
result: AttendanceResult;
serverNow: string;
attendedDays: number[];
totalCount: number;
pointsAwarded: PointAwardDto[];
balanceAfter: number;
attendanceStreak: number;
}
/** `GET /attendance/month` 응답. */
export interface AttendanceMonthDto {
month: string;
attendedDays: number[];
totalCount: number;
balance: number;
}

35
src/types/dto/chatDto.ts Normal file
View File

@ -0,0 +1,35 @@
import type {
ChatMessageView,
ChatMessagesPage,
ChatQuotaView,
ChatSendResult,
ChatSuggestionsView,
} from "../chat";
/**
* `chat` (§3) DTO UTC ISO 8601(`...Z`) .
*
* `types/chat.ts` (
* ).
* .
*/
/** `GET /chat/messages`(§3.2) 메시지 항목. */
export type ChatMessageDto = ChatMessageView;
/** `GET /chat/messages`(§3.2) 응답. */
export type ChatHistoryPageDto = ChatMessagesPage;
/** `POST /chat/messages`(§3.1) 응답. */
export type ChatSendResultDto = ChatSendResult;
/** `GET /chat/quota`(§3.3) 응답. */
export type ChatQuotaDto = ChatQuotaView;
/** `GET /chat/suggestions`(§3.5) 응답. */
export type ChatSuggestionsDto = ChatSuggestionsView;
/** `POST /chat/messages/{messageId}/report`(§3.4) 응답. */
export interface ChatReportDto {
reported: boolean;
}

19
src/types/dto/iso.ts Normal file
View File

@ -0,0 +1,19 @@
import type { Timestamp } from "firebase-admin/firestore";
/**
* Firestore Timestamp -> UTC ISO 8601 .
*
* DTO . Firestore
* Timestamp , .
*
* admin SDK Timestamp res.json `{_seconds,_nanoseconds}`
* . UTC(`...Z`) .
*/
export function toIso(ts: Timestamp): string {
return ts.toDate().toISOString();
}
/** 값이 없을 수 있는 Timestamp 필드용. 없으면 키 자체를 생략할 수 있도록 undefined 를 돌려준다. */
export function toIsoOrUndefined(ts: Timestamp | undefined | null): string | undefined {
return ts ? toIso(ts) : undefined;
}

31
src/types/dto/kboDto.ts Normal file
View File

@ -0,0 +1,31 @@
import type {
GameDetail,
GameListResult,
PlayerStatsResult,
ScheduleResult,
TeamRankResult,
} from "../kbo";
/**
* `kbo` DTO.
*
* kbo Firestore Timestamp가
* (`YYYY-MM-DD`, `"18:30"` )
*
* .
*/
/** `GET /kbo/rank` 응답. */
export type KboRankDto = TeamRankResult[];
/** `GET /kbo/schedule` 응답. */
export type KboScheduleDto = ScheduleResult;
/** `GET /kbo/games` 응답. */
export type KboGamesDto = GameListResult;
/** `GET /kbo/gameDetail` 응답. */
export type KboGameDetailDto = GameDetail;
/** `GET /kbo/player` 응답. */
export type KboPlayerDto = PlayerStatsResult;

View File

@ -0,0 +1,57 @@
import { toIso } from "./iso";
import type { Game, GameStatus, VoteEntry } from "../panit";
import type { ScoreboardResponse } from "../scoreboard";
/**
* GET /prediction/games .
* `Game`(src/types/panit.ts) `time`(Timestamp) UTC ISO 8601 .
*/
export interface GameDto {
gameId: string;
time: string;
stadium: string;
status: GameStatus;
homeTeamCode: string;
awayTeamCode: string;
winningTeamCode?: string;
cancelReason?: string;
}
/** GET /prediction/games 응답. */
export interface GamesResponseDto {
date: string;
games: GameDto[];
}
/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */
export function toGameDto(game: Game & { gameId: string }): GameDto {
return {
gameId: game.gameId,
time: toIso(game.time),
stadium: game.stadium,
status: game.status,
homeTeamCode: game.homeTeamCode,
awayTeamCode: game.awayTeamCode,
winningTeamCode: game.winningTeamCode,
cancelReason: game.cancelReason,
};
}
/** GET /prediction/scoreboard 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */
export type ScoreboardDto = ScoreboardResponse;
/** GET /prediction/summary 응답. */
export interface VoteSummaryDto {
homeCount: number;
awayCount: number;
drawCount: number;
}
/** GET /prediction 응답 — 특정 날짜의 내 투표 목록(`gameId` -> 투표 내용). */
export type MyVotesDto = Record<string, VoteEntry>;
/** POST/PUT /prediction 응답. POST는 `changed`가 없고, PUT은 항상 포함한다. */
export interface PredictionMutationDto {
ok: true;
changed?: boolean;
}

206
src/types/dto/rewardDto.ts Normal file
View File

@ -0,0 +1,206 @@
import { toIso, toIsoOrUndefined } from "./iso";
import type { PointLedgerEntry, PointLedgerType, PointOperation, WalletDoc } from "../points";
import type { OrderDoc, OrderStatus } from "../reward";
/**
* reward DTO.
*
* Firestore res.json Timestamp `{_seconds,_nanoseconds}`
* .
* Timestamp
* .
*/
export interface WalletDto {
availableBalance: number;
reservedBalance: number;
totalEarned: number;
totalSpent: number;
version: number;
createdAt?: string;
updatedAt?: string;
}
export interface LedgerEntryDto {
id: string;
txId: string;
uid: string;
type: PointLedgerType;
op: PointOperation;
amount: number;
availableBefore: number;
availableAfter: number;
reservedBefore: number;
reservedAfter: number;
relatedDate?: string;
orderId?: string;
reversalOf?: string;
adminReason?: string;
adminActor?: string;
createdAt: string;
}
export interface LedgerPageDto {
items: LedgerEntryDto[];
cursor: string | null;
}
export interface OrderItemDto {
productId: string;
qty: number;
pointPrice: number;
name: string;
}
export interface RecipientDto {
name: string;
phone: string;
address1: string;
address2?: string;
postalCode: string;
deliveryMemo?: string;
}
export interface OrderStatusHistoryEntryDto {
status: OrderStatus;
at: string;
actor: string;
}
export interface OrderDto {
id: string;
uid: string;
items: OrderItemDto[];
totalPoints: number;
recipient: RecipientDto;
status: OrderStatus;
clientIdempotencyKey: string;
reserveLedgerTxIds: string[];
statusHistory: OrderStatusHistoryEntryDto[];
orderedAt: string;
createdAt: string;
updatedAt: string;
cancelledBy?: string;
cancelledAt?: string;
confirmedAt?: string;
refundedAt?: string;
}
export interface OrderPageDto {
items: OrderDto[];
cursor: string | null;
}
export interface CreateOrderResponseDto {
order: OrderDto;
deduplicated: boolean;
availableBalance?: number;
}
export interface CancelOrderDto {
id: string;
status: OrderStatus;
}
export interface ProductSummaryDto {
id: string;
name: string;
pointPrice: number;
active: boolean;
redeemable: boolean;
displayOrder: number;
mainImages: string[];
}
export interface ProductDetailDto extends ProductSummaryDto {
detailImages: string[];
}
export interface EligibilityDto {
eligible: boolean;
reasons: string[];
availableBalance: number;
}
/** 지갑 문서가 없는 신규 유저는 잔액 0 으로 응답한다 (기존 동작 유지). */
export const EMPTY_WALLET_DTO: WalletDto = {
availableBalance: 0,
reservedBalance: 0,
totalEarned: 0,
totalSpent: 0,
version: 0,
};
export function toWalletDto(doc: WalletDoc): WalletDto {
return {
availableBalance: doc.availableBalance,
reservedBalance: doc.reservedBalance,
totalEarned: doc.totalEarned,
totalSpent: doc.totalSpent,
version: doc.version,
createdAt: toIsoOrUndefined(doc.createdAt),
updatedAt: toIsoOrUndefined(doc.updatedAt),
};
}
export function toLedgerEntryDto(id: string, entry: PointLedgerEntry): LedgerEntryDto {
return {
id,
txId: entry.txId,
uid: entry.uid,
type: entry.type,
op: entry.op,
amount: entry.amount,
availableBefore: entry.availableBefore,
availableAfter: entry.availableAfter,
reservedBefore: entry.reservedBefore,
reservedAfter: entry.reservedAfter,
relatedDate: entry.relatedDate,
orderId: entry.orderId,
reversalOf: entry.reversalOf,
adminReason: entry.adminReason,
adminActor: entry.adminActor,
createdAt: toIso(entry.createdAt),
};
}
/**
* DTO. POST /orders, GET /orders, GET /orders/:id
* statusHistory .
*/
export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
return {
id,
uid: doc.uid,
items: doc.items.map((item) => ({
productId: item.productId,
qty: item.qty,
pointPrice: item.pointPrice,
name: item.name,
})),
totalPoints: doc.totalPoints,
recipient: {
name: doc.recipient.name,
phone: doc.recipient.phone,
address1: doc.recipient.address1,
address2: doc.recipient.address2,
postalCode: doc.recipient.postalCode,
deliveryMemo: doc.recipient.deliveryMemo,
},
status: doc.status,
clientIdempotencyKey: doc.clientIdempotencyKey,
reserveLedgerTxIds: doc.reserveLedgerTxIds,
statusHistory: doc.statusHistory.map((h) => ({
status: h.status,
at: toIso(h.at),
actor: h.actor,
})),
orderedAt: toIso(doc.orderedAt),
createdAt: toIso(doc.createdAt),
updatedAt: toIso(doc.updatedAt),
cancelledBy: doc.cancelledBy,
cancelledAt: toIsoOrUndefined(doc.cancelledAt),
confirmedAt: toIsoOrUndefined(doc.confirmedAt),
refundedAt: toIsoOrUndefined(doc.refundedAt),
};
}

49
src/types/dto/statsDto.ts Normal file
View File

@ -0,0 +1,49 @@
import { toIsoOrUndefined } from "./iso";
import type { DailyJudgment, StatsResponse, VoteHistoryDoc } from "../panit";
/** GET /stats 응답. Timestamp 유출이 없어 기존 타입을 그대로 별칭한다. */
export type UserStatsDto = StatsResponse;
/** `VoteHistoryDto.data` 원소. */
export interface VoteHistoryItemDto {
gameId: string;
team: string;
result?: boolean;
cancelled?: boolean;
}
/**
* GET /stats/history .
* `VoteHistoryDoc`(src/types/panit.ts) `rewardSettledAt`(Timestamp) UTC ISO 8601
* .
*/
export interface VoteHistoryDto {
data: VoteHistoryItemDto[];
judgment?: DailyJudgment;
correctCount?: number;
completedCount?: number;
streakAfter?: number;
rewardSettledAt?: string;
rewardTotal?: number;
}
/** 문서가 없는 날은 빈 목록으로 응답한다 (기존 동작 유지). */
export const EMPTY_VOTE_HISTORY_DTO: VoteHistoryDto = { data: [] };
/** 필드를 명시적으로 나열한다 — 스프레드를 쓰면 문서에 새로 생긴 Timestamp 가 그대로 유출된다. */
export function toVoteHistoryDto(doc: VoteHistoryDoc): VoteHistoryDto {
return {
data: doc.data.map((v) => ({
gameId: v.gameId,
team: v.team,
result: v.result,
cancelled: v.cancelled,
})),
judgment: doc.judgment,
correctCount: doc.correctCount,
completedCount: doc.completedCount,
streakAfter: doc.streakAfter,
rewardSettledAt: toIsoOrUndefined(doc.rewardSettledAt),
rewardTotal: doc.rewardTotal,
};
}

30
src/types/dto/userDto.ts Normal file
View File

@ -0,0 +1,30 @@
import type { KnowledgeLevel, NotificationsMap, Provider, TeamCode } from "../panit";
import type { DateString } from "../dateString";
/** 클라이언트에 노출되는 유저 프로필 응답. `createdAt`은 UTC ISO 8601 문자열. */
export interface UserProfileDto {
displayName: string;
email: string;
photoUrl?: string;
provider: Provider;
favoriteTeamCode?: TeamCode;
knowledgeLevel: KnowledgeLevel;
createdAt: string;
lastJudgedDate?: DateString;
}
/** `GET/POST/PATCH /user` 공통 응답. */
export interface MeResponseDto {
user: UserProfileDto & { uid: string };
}
/** `GET /user/check-nickname` 응답. */
export interface CheckNicknameDto {
available: true;
previousReservation: string | null;
}
/** `PATCH /user/notifications` 응답. */
export interface NotificationsResponseDto {
notifications: NotificationsMap;
}

View File

@ -39,3 +39,38 @@ describe("attendanceService reward wallet", () => {
await checkIn(token, at("2026-05-12T12:00:00+09:00", "m")); expect(await getMonth(token, "2026-05")).toMatchObject({ attendedDays: [12], totalCount: 1, balance: 20 });
});
});
describe("attendanceService 응답 직렬화", () => {
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
beforeEach(async () => { await firestore.recursiveDelete(firestore.doc(`users/${uid}`)); vi.useFakeTimers({ toFake: ["Date"] }); });
afterEach(() => vi.useRealTimers());
it("신규 출석·중복 출석·멱등 replay 세 경로 모두 serverNow가 UTC ISO 문자열이다", async () => {
const fresh = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1"));
const replay = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-1"));
const already = await checkIn(token, at("2026-05-12T15:00:00+09:00", "iso-2"));
expect(fresh.result).toBe(AttendanceResult.CheckedIn);
expect(already.result).toBe(AttendanceResult.AlreadyCheckedIn);
for (const res of [fresh, replay, already]) {
expect(typeof res.serverNow).toBe("string");
expect(res.serverNow).toMatch(UTC_ISO);
}
});
it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => {
const res = await checkIn(token, at("2026-05-12T14:23:11+09:00", "iso-3"));
const json = JSON.stringify(res);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
});
it("서버와 시계가 어긋나면 409 CLOCK_SKEW 바디의 serverNow도 UTC ISO 문자열이다", async () => {
vi.setSystemTime(new Date("2026-05-12T14:23:11+09:00"));
const skewed = { clientAttemptedAt: new Date("2026-05-12T15:23:11+09:00").toISOString(), clientIdempotencyKey: "skew" };
const err = await checkIn(token, skewed).then(() => null, (e) => e);
expect(err).toMatchObject({ status: 409, code: "CLOCK_SKEW" });
expect(err.details.serverNow).toMatch(UTC_ISO);
});
});

View File

@ -167,7 +167,7 @@ describe("chatService", () => {
expect(result.crisis).toBe(false);
expect(result.limit).toBe(10);
expect(result.remainingCount).toBe(9);
expect(result.createdAt).toMatch(/\+09:00$/);
expect(result.createdAt).toMatch(/Z$/);
expect(await countMessages("HH")).toBe(2);
const req = await readRequest(key);
@ -360,7 +360,8 @@ describe("chatService", () => {
.then(() => null, (e) => e);
expect(err).toMatchObject({ status: 403, code: "LIMIT_EXCEEDED" });
expect(err.details.limit).toBe(1);
expect(err.details.resetAt).toMatch(/T00:00:00\+09:00$/);
// KST 자정 기준은 그대로, 와이어 형식만 UTC(= 전날 15:00Z)
expect(err.details.resetAt).toMatch(/T15:00:00\.000Z$/);
expect((await readQuota()).used).toBe(1);
});
@ -682,7 +683,7 @@ describe("chatService", () => {
expect(quota.used).toBe(0);
expect(quota.limit).toBe(10);
expect(quota.remaining).toBe(10);
expect(quota.resetAt).toMatch(/T00:00:00\+09:00$/);
expect(quota.resetAt).toMatch(/T15:00:00\.000Z$/);
});
});

View File

@ -87,6 +87,28 @@ describe("userService", () => {
});
});
describe("응답 직렬화", () => {
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
it("createMe / getMe / updateMe 모두 createdAt이 UTC ISO 문자열이다", async () => {
const created = await createMeWithReservation();
const fetched = await getMe(fakeToken());
const updated = await updateMe(fakeToken(), { knowledgeLevel: "expert" });
for (const profile of [created, fetched, updated]) {
expect(typeof profile.createdAt).toBe("string");
expect(profile.createdAt).toMatch(UTC_ISO);
}
});
it("응답 JSON에 Firestore Timestamp가 남지 않는다", async () => {
await createMeWithReservation();
const json = JSON.stringify(await getMe(fakeToken()));
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
});
});
describe("createMe", () => {
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
const u = await createMeWithReservation();

37
tests/types/iso.test.ts Normal file
View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { toIso, toIsoOrUndefined } from "../../src/types/dto/iso";
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
describe("toIso", () => {
it("Timestamp 를 UTC ISO 문자열로 바꾼다", () => {
const ts = Timestamp.fromDate(new Date("2026-07-20T05:30:00.000Z"));
expect(toIso(ts)).toBe("2026-07-20T05:30:00.000Z");
});
it("항상 Z 로 끝나는 UTC 형식을 낸다 (KST 오프셋 없음)", () => {
const ts = Timestamp.fromDate(new Date("2026-07-20T14:30:00+09:00"));
const iso = toIso(ts);
expect(iso).toMatch(UTC_ISO);
expect(iso).not.toContain("+09:00");
expect(iso).toBe("2026-07-20T05:30:00.000Z");
});
});
describe("toIsoOrUndefined", () => {
it("값이 있으면 toIso 와 같은 결과를 낸다", () => {
const ts = Timestamp.fromDate(new Date("2026-01-02T03:04:05.678Z"));
expect(toIsoOrUndefined(ts)).toBe("2026-01-02T03:04:05.678Z");
});
it("undefined / null 이면 undefined 를 낸다", () => {
expect(toIsoOrUndefined(undefined)).toBeUndefined();
expect(toIsoOrUndefined(null)).toBeUndefined();
});
it("undefined 필드는 JSON 직렬화에서 키가 사라진다", () => {
const dto = { at: toIsoOrUndefined(undefined) };
expect(JSON.parse(JSON.stringify(dto))).toEqual({});
});
});

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import { toGameDto } from "../../src/types/dto/predictionDto";
import { EMPTY_VOTE_HISTORY_DTO, toVoteHistoryDto } from "../../src/types/dto/statsDto";
import type { Game, VoteHistoryDoc } from "../../src/types/panit";
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const ts = (iso: string) => Timestamp.fromDate(new Date(iso));
function wire(dto: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(dto));
}
function expectNoTimestampLeak(dto: unknown): void {
const json = JSON.stringify(dto);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
}
const game = {
gameId: "20260720LGHT0",
time: ts("2026-07-20T09:30:00.000Z"),
stadium: "잠실",
status: "scheduled",
homeTeamCode: "LG",
awayTeamCode: "HT",
} as Game & { gameId: string };
describe("toGameDto", () => {
it("time 을 UTC ISO 문자열로 바꾼다", () => {
const dto = toGameDto(game);
expectNoTimestampLeak(dto);
expect(dto.time).toMatch(UTC_ISO);
expect(dto.time).toBe("2026-07-20T09:30:00.000Z");
});
it("경기가 끝나지 않았으면 결과 관련 키가 응답에 없다", () => {
const json = wire(toGameDto(game));
expect(json).not.toHaveProperty("winningTeamCode");
expect(json).not.toHaveProperty("cancelReason");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...game, internalOnly: "secret" } as Game & { gameId: string };
expect(wire(toGameDto(polluted))).not.toHaveProperty("internalOnly");
});
});
describe("toVoteHistoryDto", () => {
const settled = {
data: [
{ gameId: "20260720LGHT0", team: "LG", result: true },
{ gameId: "20260720SSKT0", team: "SS", cancelled: true },
],
judgment: "success",
correctCount: 1,
completedCount: 2,
streakAfter: 3,
rewardSettledAt: ts("2026-07-21T00:15:00.000Z"),
rewardTotal: 70,
} as VoteHistoryDoc;
it("rewardSettledAt 을 UTC ISO 문자열로 바꾼다", () => {
const dto = toVoteHistoryDto(settled);
expectNoTimestampLeak(dto);
expect(dto.rewardSettledAt).toMatch(UTC_ISO);
expect(dto.rewardSettledAt).toBe("2026-07-21T00:15:00.000Z");
});
it("정산 전이면 rewardSettledAt 키가 응답에 없다", () => {
const pending = { ...settled, rewardSettledAt: undefined, rewardTotal: undefined } as VoteHistoryDoc;
const json = wire(toVoteHistoryDto(pending));
expect(json).not.toHaveProperty("rewardSettledAt");
expect(json).not.toHaveProperty("rewardTotal");
expect(json.data).toHaveLength(2);
});
it("문서가 없는 날은 빈 목록으로 응답한다", () => {
expect(wire(EMPTY_VOTE_HISTORY_DTO)).toEqual({ data: [] });
});
it("투표 항목의 미지 필드를 흘려보내지 않는다", () => {
const polluted = {
...settled,
data: [{ gameId: "g1", team: "LG", result: true, internalOnly: "secret" }],
} as VoteHistoryDoc;
expect(JSON.stringify(toVoteHistoryDto(polluted))).not.toContain("internalOnly");
});
});

View File

@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
import {
EMPTY_WALLET_DTO,
toLedgerEntryDto,
toOrderDto,
toWalletDto,
} from "../../src/types/dto/rewardDto";
import { PointLedgerType, type PointLedgerEntry, type WalletDoc } from "../../src/types/points";
import type { OrderDoc } from "../../src/types/reward";
const UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const ts = (iso: string) => Timestamp.fromDate(new Date(iso));
/** 와이어에 실제로 나가는 모양. Timestamp 유출은 여기서만 드러난다. */
function wire(dto: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(dto));
}
/** 중첩 구조 어디에도 admin SDK Timestamp 의 내부 필드가 남아 있지 않은지 확인. */
function expectNoTimestampLeak(dto: unknown): void {
const json = JSON.stringify(dto);
expect(json).not.toContain("_seconds");
expect(json).not.toContain("_nanoseconds");
}
const wallet: WalletDoc = {
availableBalance: 1200,
reservedBalance: 300,
totalEarned: 5000,
totalSpent: 3500,
version: 7,
createdAt: ts("2026-01-02T03:04:05.678Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
const ledgerEntry: PointLedgerEntry = {
txId: "uid:2026-07-20:attendance_daily",
uid: "uid",
type: PointLedgerType.AttendanceDaily,
op: "credit",
amount: 20,
availableBefore: 1180,
availableAfter: 1200,
reservedBefore: 300,
reservedAfter: 300,
relatedDate: "2026-07-20",
createdAt: ts("2026-07-20T05:30:00.000Z"),
};
const baseOrder: OrderDoc = {
uid: "uid",
items: [{ productId: "p1", qty: 2, pointPrice: 500, name: "굿즈" }],
totalPoints: 1000,
recipient: { name: "홍길동", phone: "01000000000", address1: "서울", postalCode: "00000" },
status: "reserved",
clientIdempotencyKey: "key-1",
reserveLedgerTxIds: ["uid:order:o1:reserve"],
orderedAt: ts("2026-07-20T05:30:00.000Z"),
statusHistory: [{ status: "reserved", at: ts("2026-07-20T05:30:00.000Z"), actor: "uid" }],
createdAt: ts("2026-07-20T05:30:00.000Z"),
updatedAt: ts("2026-07-20T05:30:00.000Z"),
};
describe("toWalletDto", () => {
it("날짜를 UTC ISO 문자열로 내보내고 Timestamp 를 유출하지 않는다", () => {
const dto = toWalletDto(wallet);
expectNoTimestampLeak(dto);
expect(wire(dto)).toEqual({
availableBalance: 1200,
reservedBalance: 300,
totalEarned: 5000,
totalSpent: 3500,
version: 7,
createdAt: "2026-01-02T03:04:05.678Z",
updatedAt: "2026-07-20T05:30:00.000Z",
});
});
it("지갑 문서가 없는 유저는 잔액 0 응답을 쓴다", () => {
expect(wire(EMPTY_WALLET_DTO)).toEqual({
availableBalance: 0,
reservedBalance: 0,
totalEarned: 0,
totalSpent: 0,
version: 0,
});
});
});
describe("toLedgerEntryDto", () => {
it("createdAt 이 UTC ISO 문자열이고 문서 id 가 붙는다", () => {
const dto = toLedgerEntryDto("entry-1", ledgerEntry);
expectNoTimestampLeak(dto);
expect(dto.id).toBe("entry-1");
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.createdAt).toBe("2026-07-20T05:30:00.000Z");
});
it("relatedDate 의 YYYY-MM-DD 형식은 그대로 둔다", () => {
expect(toLedgerEntryDto("entry-1", ledgerEntry).relatedDate).toBe("2026-07-20");
});
it("선택 필드가 없으면 응답에서 키가 사라진다", () => {
const minimal: PointLedgerEntry = { ...ledgerEntry, relatedDate: undefined };
const json = wire(toLedgerEntryDto("entry-1", minimal));
expect(json).not.toHaveProperty("relatedDate");
expect(json).not.toHaveProperty("orderId");
});
});
describe("toOrderDto", () => {
it("중첩된 statusHistory 까지 UTC ISO 문자열로 바꾼다", () => {
const dto = toOrderDto("o1", baseOrder);
expectNoTimestampLeak(dto);
expect(dto.orderedAt).toMatch(UTC_ISO);
expect(dto.createdAt).toMatch(UTC_ISO);
expect(dto.updatedAt).toMatch(UTC_ISO);
expect(dto.statusHistory[0].at).toMatch(UTC_ISO);
expect(dto.statusHistory[0].at).toBe("2026-07-20T05:30:00.000Z");
});
it("선택 날짜 필드가 채워지면 모두 문자열로 나간다", () => {
const settled: OrderDoc = {
...baseOrder,
status: "refunded",
cancelledBy: "admin",
cancelledAt: ts("2026-07-21T00:00:00.000Z"),
confirmedAt: ts("2026-07-22T00:00:00.000Z"),
refundedAt: ts("2026-07-23T00:00:00.000Z"),
};
const dto = toOrderDto("o1", settled);
expectNoTimestampLeak(dto);
for (const value of [dto.cancelledAt, dto.confirmedAt, dto.refundedAt]) {
expect(value).toMatch(UTC_ISO);
}
});
it("선택 날짜 필드가 비면 응답에서 키가 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder));
expect(json).not.toHaveProperty("cancelledAt");
expect(json).not.toHaveProperty("confirmedAt");
expect(json).not.toHaveProperty("refundedAt");
expect(json).not.toHaveProperty("cancelledBy");
});
it("문서에 없는 필드를 임의로 흘려보내지 않는다", () => {
const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc;
expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly");
});
});