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" });
|
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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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