feat: user endpoint update

기존 유저 관련 엔드포인트를 /user로 전부 통합해서 관리할 수 있도록 수정 및 테스트 코드 추가
This commit is contained in:
윤정민 2026-04-13 13:21:04 +09:00
parent 7ff609022f
commit f98fa84da8
13 changed files with 426 additions and 1642 deletions

View File

@ -0,0 +1,13 @@
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/C:/Users/%ec%9c%a4%ec%a0%95%eb%af%bc/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
12:30:05.819 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
12:30:05.923 [main] INFO com.firebase.server.forge.App$ - Listening at 0.0.0.0:9000
12:30:09.083 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - mmday-panit-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 80ms, init: 0ms
12:30:09.125 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace mmday-panit-default-rtdb status Active to Active
12:30:13.051 [FirebaseWorkerPool-1-3] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested
namespace mmday-panit, but the emulator is configured for mmday-panit-default-rtdb. To
opt-out of single project mode add/set the singleProjectMode: false property in
the firebase.json emulators config.
12:30:13.054 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.NamespaceActor - mmday-panit successfully activated FBKV (SurveyIdle(0)) wait: 1ms, init: 0ms

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by io.netty.util.internal.PlatformDependent0$2 (file:/C:/Users/%ec%9c%a4%ec%a0%95%eb%af%bc/.cache/firebase/emulators/cloud-firestore-emulator-v1.20.4.jar)
WARNING: Please consider reporting this to the maintainers of class io.netty.util.internal.PlatformDependent0$2
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
Apr 13, 2026 12:30:04 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start
INFO: Started WebSocket server on ws://0.0.0.0:9150
API endpoint: http://0.0.0.0:8080
Database Edition: STANDARD
Database Mode: CLOUD_FIRESTORE
If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run:
export FIRESTORE_EMULATOR_HOST=0.0.0.0:8080
If you are running a Firestore in Datastore Mode project, run:
export DATASTORE_EMULATOR_HOST=0.0.0.0:8080
Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues.
Dev App Server is now running.
Apr 13, 2026 12:30:10 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Apr 13, 2026 12:30:11 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:398)
at java.base/sun.nio.ch.SocketChannelImpl.implRead(SocketChannelImpl.java:430)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:488)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:796)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:732)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:658)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1516)
Apr 13, 2026 12:30:11 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Apr 13, 2026 12:30:12 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:398)
at java.base/sun.nio.ch.SocketChannelImpl.implRead(SocketChannelImpl.java:430)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:488)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:796)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:732)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:658)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1516)
Apr 13, 2026 12:30:13 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Apr 13, 2026 12:30:14 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:398)
at java.base/sun.nio.ch.SocketChannelImpl.implRead(SocketChannelImpl.java:430)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:488)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:796)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:732)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:658)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1516)
Apr 13, 2026 12:30:14 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Apr 13, 2026 12:30:14 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:398)
at java.base/sun.nio.ch.SocketChannelImpl.implRead(SocketChannelImpl.java:430)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:488)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:796)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:732)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:658)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1516)
Apr 13, 2026 12:30:15 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
Apr 13, 2026 12:30:15 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:398)
at java.base/sun.nio.ch.SocketChannelImpl.implRead(SocketChannelImpl.java:430)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:488)
at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255)
at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:796)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:732)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:658)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1516)

View File

@ -1,20 +0,0 @@
import { onRequest } from "firebase-functions/https";
import { requireAuth } from "../middleware/auth.js";
import { sendError } from "../middleware/errors.js";
import { register } from "../services/authService.js";
export const auth = onRequest(async (req, res) => {
const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? "";
try {
if (path === "register" && req.method === "POST") {
const uid = await requireAuth(req);
const result = await register(uid, req.body ?? {});
res.status(result.created ? 201 : 200).json(result);
return;
}
res.status(404).json({ error: `Unknown path: /${path}` });
} catch (err) {
sendError(res, err);
}
});

View File

@ -0,0 +1,24 @@
import { onRequest } from "firebase-functions/https";
import { requireAuth, requireAuthToken } from "../middleware/auth.js";
import { sendError } from "../middleware/errors.js";
import { createMe, getMe } from "../services/userService.js";
export const user = onRequest(async (req, res) => {
try {
if (req.method === "GET") {
const uid = await requireAuth(req);
const u = await getMe(uid);
res.status(200).json({ user: { uid, ...u } });
return;
}
if (req.method === "POST") {
const token = await requireAuthToken(req);
const u = await createMe(token, req.body ?? {});
res.status(201).json({ user: { uid: token.uid, ...u } });
return;
}
res.status(405).json({ error: `method ${req.method} not allowed` });
} catch (err) {
sendError(res, err);
}
});

View File

@ -4,7 +4,7 @@ import "./firebase";
setGlobalOptions({ maxInstances: 10, region: "asia-northeast3" }); setGlobalOptions({ maxInstances: 10, region: "asia-northeast3" });
export { kbo } from "./handlers/kboHandlers"; export { kbo } from "./handlers/kboHandlers";
export { auth } from "./handlers/authHandlers"; export { user } from "./handlers/userHandlers";
export { prediction } from "./handlers/predictionHandlers"; export { prediction } from "./handlers/predictionHandlers";
export { stats } from "./handlers/statsHandlers"; export { stats } from "./handlers/statsHandlers";
export { admin } from "./handlers/adminHandlers"; export { admin } from "./handlers/adminHandlers";

View File

@ -1,8 +1,9 @@
import type { Request } from "firebase-functions/https"; import type { Request } from "firebase-functions/https";
import type { DecodedIdToken } from "firebase-admin/auth";
import { auth } from "../firebase.js"; import { auth } from "../firebase.js";
import { HttpError } from "./errors.js"; import { HttpError } from "./errors.js";
export async function requireAuth(req: Request): Promise<string> { export async function requireAuthToken(req: Request): Promise<DecodedIdToken> {
const header = req.get("authorization") ?? req.get("Authorization"); const header = req.get("authorization") ?? req.get("Authorization");
if (!header || !header.startsWith("Bearer ")) { if (!header || !header.startsWith("Bearer ")) {
throw new HttpError(401, "Missing Bearer token"); throw new HttpError(401, "Missing Bearer token");
@ -11,9 +12,12 @@ export async function requireAuth(req: Request): Promise<string> {
if (!token) throw new HttpError(401, "Empty token"); if (!token) throw new HttpError(401, "Empty token");
try { try {
const decoded = await auth.verifyIdToken(token); return await auth.verifyIdToken(token);
return decoded.uid;
} catch { } catch {
throw new HttpError(401, "Invalid token"); throw new HttpError(401, "Invalid token");
} }
} }
export async function requireAuth(req: Request): Promise<string> {
return (await requireAuthToken(req)).uid;
}

View File

@ -1,14 +1,21 @@
import type express from "express"; import type express from "express";
export class HttpError extends Error { export class HttpError extends Error {
constructor(public status: number, message: string) { constructor(
public status: number,
message: string,
public code?: string
) {
super(message); super(message);
} }
} }
export function sendError(res: express.Response, err: unknown): void { export function sendError(res: express.Response, err: unknown): void {
if (err instanceof HttpError) { if (err instanceof HttpError) {
res.status(err.status).json({ error: err.message }); const body = err.code
? { error: err.code, message: err.message }
: { error: err.message };
res.status(err.status).json(body);
return; return;
} }
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);

View File

@ -1,6 +1,6 @@
import { FieldValue } from "firebase-admin/firestore"; import { FieldValue } from "firebase-admin/firestore";
import { firestore } from "../firebase.js"; import { firestore } from "../firebase.js";
import type { User, Provider } from "../types/panit.js"; import type { KnowledgeLevel, Provider, TeamCode, User } from "../types/panit.js";
const COLLECTION = "users"; const COLLECTION = "users";
@ -9,6 +9,8 @@ export interface RegisterInput {
email: string; email: string;
photoUrl?: string; photoUrl?: string;
provider: Provider; provider: Provider;
favoriteTeamCode: TeamCode;
knowledgeLevel: KnowledgeLevel;
} }
/** /**
@ -25,31 +27,16 @@ export async function getUser(uid: string): Promise<User | null> {
/** /**
* . `createdAt` , * . `createdAt` ,
* UID가 (`merge: false`). * UID가 (`merge: false`).
*
* @param uid - Firebase Auth UID
* @param input -
*/ */
export async function createUser(uid: string, input: RegisterInput): Promise<void> { export async function createUser(uid: string, input: RegisterInput): Promise<void> {
const doc: Record<string, unknown> = { const doc: Record<string, unknown> = {
displayName: input.displayName, displayName: input.displayName,
email: input.email, email: input.email,
provider: input.provider, provider: input.provider,
favoriteTeamCode: input.favoriteTeamCode,
knowledgeLevel: input.knowledgeLevel,
createdAt: FieldValue.serverTimestamp(), createdAt: FieldValue.serverTimestamp(),
}; };
if (input.photoUrl) doc.photoUrl = input.photoUrl; if (input.photoUrl) doc.photoUrl = input.photoUrl;
await firestore.collection(COLLECTION).doc(uid).set(doc, { merge: false }); await firestore.collection(COLLECTION).doc(uid).set(doc, { merge: false });
} }
/**
* , .
*
* @param uid - Firebase Auth UID
* @param input -
* @returns `true`, `false`.
*/
export async function upsertUser(uid: string, input: RegisterInput): Promise<boolean> {
const existing = await getUser(uid);
if (existing) return false;
await createUser(uid, input);
return true;
}

View File

@ -1,27 +0,0 @@
import { HttpError } from "../middleware/errors.js";
import { upsertUser, type RegisterInput } from "../repositories/userRepository.js";
import type { Provider } from "../types/panit.js";
const VALID_PROVIDERS: readonly Provider[] = ["google", "apple"];
export async function register(
uid: string,
body: Partial<RegisterInput> & { provider?: string }
): Promise<{ created: boolean }> {
if (!body.displayName || typeof body.displayName !== "string") {
throw new HttpError(400, "displayName required");
}
if (!body.email || typeof body.email !== "string") {
throw new HttpError(400, "email required");
}
if (!body.provider || !VALID_PROVIDERS.includes(body.provider as Provider)) {
throw new HttpError(400, `provider must be one of: ${VALID_PROVIDERS.join(", ")}`);
}
const created = await upsertUser(uid, {
displayName: body.displayName,
email: body.email,
photoUrl: body.photoUrl,
provider: body.provider as Provider,
});
return { created };
}

117
src/services/userService.ts Normal file
View File

@ -0,0 +1,117 @@
import type { DecodedIdToken } from "firebase-admin/auth";
import { HttpError } from "../middleware/errors.js";
import {
createUser,
getUser,
} from "../repositories/userRepository.js";
import {
KnowledgeLevel,
TeamCode,
type Provider,
type User,
} from "../types/panit.js";
const TEAM_CODE_VALUES = Object.values(TeamCode);
const KNOWLEDGE_LEVEL_VALUES = Object.values(KnowledgeLevel);
function parseEnum<T extends string>(
field: string,
value: unknown,
allowed: readonly T[]
): T {
if (typeof value !== "string" || !allowed.includes(value as T)) {
throw new HttpError(
400,
`${field} must be one of: ${allowed.join(", ")}`,
"INVALID_INPUT"
);
}
return value as T;
}
function parseDisplayName(value: unknown): string {
if (typeof value !== "string") {
throw new HttpError(400, "displayName must be a string", "INVALID_INPUT");
}
if (value.length < 1 || value.length > 10) {
throw new HttpError(
400,
"displayName length must be 1~10",
"INVALID_INPUT"
);
}
return value;
}
function providerFromToken(token: DecodedIdToken): Provider {
const raw = token.firebase?.sign_in_provider;
if (raw === "google.com") return "google";
if (raw === "apple.com") return "apple";
throw new HttpError(
400,
`unsupported sign-in provider: ${raw}`,
"INVALID_INPUT"
);
}
/**
* . 404 + `USER_NOT_FOUND`.
*/
export async function getMe(uid: string): Promise<User> {
const user = await getUser(uid);
if (!user) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND");
}
return user;
}
export interface CreateMeBody {
displayName?: unknown;
favoriteTeamCode?: unknown;
knowledgeLevel?: unknown;
}
/**
* . 409 + `USER_ALREADY_EXISTS`.
* email/photoUrl/provider는 Firebase ID Token에서 .
*/
export async function createMe(
token: DecodedIdToken,
body: CreateMeBody
): Promise<User> {
const existing = await getUser(token.uid);
if (existing) {
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
}
const displayName = parseDisplayName(body.displayName);
const favoriteTeamCode = parseEnum<TeamCode>(
"favoriteTeamCode",
body.favoriteTeamCode,
TEAM_CODE_VALUES
);
const knowledgeLevel = parseEnum<KnowledgeLevel>(
"knowledgeLevel",
body.knowledgeLevel,
KNOWLEDGE_LEVEL_VALUES
);
if (!token.email) {
throw new HttpError(400, "token has no email", "INVALID_INPUT");
}
await createUser(token.uid, {
displayName,
email: token.email,
photoUrl: token.picture,
provider: providerFromToken(token),
favoriteTeamCode,
knowledgeLevel,
});
const created = await getUser(token.uid);
if (!created) {
throw new HttpError(500, "failed to read created user");
}
return created;
}

View File

@ -3,11 +3,32 @@ import type { Timestamp } from "firebase-admin/firestore";
export type Provider = "google" | "apple"; export type Provider = "google" | "apple";
export type GameStatus = "waiting" | "live" | "ended" | "canceled"; export type GameStatus = "waiting" | "live" | "ended" | "canceled";
export enum TeamCode {
KT = "KT",
NC = "NC",
SK = "SK",
LG = "LG",
HT = "HT",
LT = "LT",
HH = "HH",
OB = "OB",
SS = "SS",
WO = "WO",
}
export enum KnowledgeLevel {
Beginner = "beginner",
Casual = "casual",
Expert = "expert",
}
export interface User { export interface User {
displayName: string; displayName: string;
email: string; email: string;
photoUrl?: string; photoUrl?: string;
provider: Provider; provider: Provider;
favoriteTeamCode: TeamCode;
knowledgeLevel: KnowledgeLevel;
createdAt: Timestamp; createdAt: Timestamp;
} }

View File

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { DecodedIdToken } from "firebase-admin/auth";
import { firestore } from "../../src/firebase.js";
import { createMe, getMe } from "../../src/services/userService.js";
import { HttpError } from "../../src/middleware/errors.js";
const uid = "user-1";
function fakeToken(overrides: Partial<DecodedIdToken> = {}): DecodedIdToken {
return {
uid,
email: "tester@example.com",
picture: "https://cdn.example.com/p.png",
firebase: {
identities: {},
sign_in_provider: "google.com",
},
aud: "test",
auth_time: 0,
exp: 0,
iat: 0,
iss: "test",
sub: uid,
...overrides,
} as DecodedIdToken;
}
const validBody = {
displayName: "유저1",
favoriteTeamCode: "LG",
knowledgeLevel: "casual",
};
describe("userService", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("users"));
});
describe("getMe", () => {
it("미존재 유저는 404 + USER_NOT_FOUND", async () => {
await expect(getMe(uid)).rejects.toMatchObject({
status: 404,
code: "USER_NOT_FOUND",
});
});
it("존재하는 유저를 반환한다", async () => {
await createMe(fakeToken(), validBody);
const u = await getMe(uid);
expect(u.displayName).toBe("유저1");
expect(u.favoriteTeamCode).toBe("LG");
expect(u.knowledgeLevel).toBe("casual");
});
});
describe("createMe", () => {
it("정상 생성 시 토큰의 email/photo/provider를 사용한다", async () => {
const u = await createMe(fakeToken(), validBody);
expect(u.email).toBe("tester@example.com");
expect(u.photoUrl).toBe("https://cdn.example.com/p.png");
expect(u.provider).toBe("google");
expect(u.favoriteTeamCode).toBe("LG");
expect(u.knowledgeLevel).toBe("casual");
});
it("apple.com 프로바이더 매핑", async () => {
const t = fakeToken({
firebase: { identities: {}, sign_in_provider: "apple.com" },
});
const u = await createMe(t, validBody);
expect(u.provider).toBe("apple");
});
it("중복 생성 시 409 + USER_ALREADY_EXISTS", async () => {
await createMe(fakeToken(), validBody);
await expect(createMe(fakeToken(), validBody)).rejects.toMatchObject({
status: 409,
code: "USER_ALREADY_EXISTS",
});
});
it.each([
["빈 displayName", { ...validBody, displayName: "" }],
["11자 displayName", { ...validBody, displayName: "1234567890X" }],
["숫자 displayName", { ...validBody, displayName: 123 }],
["잘못된 팀코드", { ...validBody, favoriteTeamCode: "XX" }],
["잘못된 레벨", { ...validBody, knowledgeLevel: "master" }],
])("%s → 400 INVALID_INPUT", async (_label, body) => {
await expect(createMe(fakeToken(), body as never)).rejects.toMatchObject({
status: 400,
code: "INVALID_INPUT",
});
});
it("지원하지 않는 provider → 400", async () => {
const t = fakeToken({
firebase: { identities: {}, sign_in_provider: "password" },
});
await expect(createMe(t, validBody)).rejects.toBeInstanceOf(HttpError);
});
});
});