feat: user endpoint update
기존 유저 관련 엔드포인트를 /user로 전부 통합해서 관리할 수 있도록 수정 및 테스트 코드 추가
This commit is contained in:
parent
7ff609022f
commit
f98fa84da8
@ -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
|
||||
1571
firebase-debug.log
1571
firebase-debug.log
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
24
src/handlers/userHandlers.ts
Normal file
24
src/handlers/userHandlers.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@ -4,7 +4,7 @@ import "./firebase";
|
||||
setGlobalOptions({ maxInstances: 10, region: "asia-northeast3" });
|
||||
|
||||
export { kbo } from "./handlers/kboHandlers";
|
||||
export { auth } from "./handlers/authHandlers";
|
||||
export { user } from "./handlers/userHandlers";
|
||||
export { prediction } from "./handlers/predictionHandlers";
|
||||
export { stats } from "./handlers/statsHandlers";
|
||||
export { admin } from "./handlers/adminHandlers";
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { Request } from "firebase-functions/https";
|
||||
import type { DecodedIdToken } from "firebase-admin/auth";
|
||||
import { auth } from "../firebase.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");
|
||||
if (!header || !header.startsWith("Bearer ")) {
|
||||
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");
|
||||
|
||||
try {
|
||||
const decoded = await auth.verifyIdToken(token);
|
||||
return decoded.uid;
|
||||
return await auth.verifyIdToken(token);
|
||||
} catch {
|
||||
throw new HttpError(401, "Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request): Promise<string> {
|
||||
return (await requireAuthToken(req)).uid;
|
||||
}
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
import type express from "express";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function sendError(res: express.Response, err: unknown): void {
|
||||
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;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FieldValue } from "firebase-admin/firestore";
|
||||
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";
|
||||
|
||||
@ -9,6 +9,8 @@ export interface RegisterInput {
|
||||
email: string;
|
||||
photoUrl?: string;
|
||||
provider: Provider;
|
||||
favoriteTeamCode: TeamCode;
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -25,31 +27,16 @@ export async function getUser(uid: string): Promise<User | null> {
|
||||
/**
|
||||
* 신규 유저 문서를 생성한다. `createdAt`은 서버 타임스탬프로 기록되며,
|
||||
* 같은 UID가 있으면 전체 덮어쓴다(`merge: false`).
|
||||
*
|
||||
* @param uid - Firebase Auth UID
|
||||
* @param input - 생성에 필요한 사용자 정보
|
||||
*/
|
||||
export async function createUser(uid: string, input: RegisterInput): Promise<void> {
|
||||
const doc: Record<string, unknown> = {
|
||||
displayName: input.displayName,
|
||||
email: input.email,
|
||||
provider: input.provider,
|
||||
favoriteTeamCode: input.favoriteTeamCode,
|
||||
knowledgeLevel: input.knowledgeLevel,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
if (input.photoUrl) doc.photoUrl = input.photoUrl;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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
117
src/services/userService.ts
Normal 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;
|
||||
}
|
||||
@ -3,11 +3,32 @@ import type { Timestamp } from "firebase-admin/firestore";
|
||||
export type Provider = "google" | "apple";
|
||||
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 {
|
||||
displayName: string;
|
||||
email: string;
|
||||
photoUrl?: string;
|
||||
provider: Provider;
|
||||
favoriteTeamCode: TeamCode;
|
||||
knowledgeLevel: KnowledgeLevel;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
|
||||
102
tests/services/userService.test.ts
Normal file
102
tests/services/userService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user