Add reward goods catalog and point-based order flow
- 상품 카탈로그(products/) 신설: 공개 필드(name·pointPrice·이미지)와 재고(total/reserved/safety)를 한 문서로 관리, 목록·상세는 60초 인메모리 캐시로 서빙 - 주문 생성 시 포인트 예약(order_reserve)과 재고 예약을 단일 트랜잭션으로 원자 처리, 주문 ID를 uid_클라이언트멱등키로 고정해 더블탭·재시도 중복 생성 차단 - 주문 상태 머신(reserved→confirmed→preparing→shipped→delivered / cancelled / refunded) 전이 검증과 전이별 capture·release·refund 역거래 원장 기록 - reward HTTP 함수 신설: 지갑·원장 조회, 상품 목록·상세, 교환 자격, 주문 생성·목록·상세·취소 API - 관리자 라우트 추가: 상품 등록·수정, 재고 조정(가용재고 음수 방지), 주문 상태 변경, 포인트 지급·회수 - Firestore 보안규칙(지갑 본인 read, 상품·주문은 API 전용)과 orders 복합 인덱스(uid+createdAt, status+createdAt) 추가 - 상품 4종 시드(seed:rewards)와 상세 이미지 업로드(upload:reward-assets) 스크립트 추가
This commit is contained in:
parent
c964572a9c
commit
6343a817af
@ -107,14 +107,32 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "pointLedger",
|
"collectionGroup": "orders",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
"fields": [
|
"fields": [
|
||||||
{ "fieldPath": "createdAt", "order": "DESCENDING" },
|
{ "fieldPath": "uid", "order": "ASCENDING" },
|
||||||
{ "fieldPath": "seq", "order": "DESCENDING" },
|
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||||
{ "fieldPath": "__name__", "order": "DESCENDING" }
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "orders",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||||
|
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": [
|
||||||
}
|
{
|
||||||
|
"collectionGroup": "products",
|
||||||
|
"fieldPath": "mainImages",
|
||||||
|
"indexes": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "products",
|
||||||
|
"fieldPath": "detailImages",
|
||||||
|
"indexes": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
rules_version='2'
|
rules_version='2'
|
||||||
|
|
||||||
|
// Reward data model:
|
||||||
|
// products/{id}, orders/{id}: Admin SDK API only.
|
||||||
|
// users/{uid}/wallet/current, pointLedger/{txId}:
|
||||||
|
// owner-readable and server-write-only. Order recipient PII is never client-readable.
|
||||||
|
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
match /users/{uid} {
|
match /users/{uid} {
|
||||||
allow read: if request.auth != null && request.auth.uid == uid;
|
allow read: if request.auth != null && request.auth.uid == uid;
|
||||||
allow update: if request.auth != null && request.auth.uid == uid &&
|
allow update: if request.auth != null && request.auth.uid == uid &&
|
||||||
request.resource.data.diff(resource.data)
|
request.resource.data.diff(resource.data)
|
||||||
.affectedKeys().hasOnly(['fcmToken']);
|
.affectedKeys().hasOnly(['fcmToken']) &&
|
||||||
|
(!('fcmToken' in request.resource.data) ||
|
||||||
|
(request.resource.data.fcmToken is string &&
|
||||||
|
request.resource.data.fcmToken.size() <= 4096));
|
||||||
allow create, delete: if false;
|
allow create, delete: if false;
|
||||||
|
|
||||||
match /voteHistory/{date} {
|
match /voteHistory/{date} {
|
||||||
@ -24,6 +32,11 @@ service cloud.firestore {
|
|||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /wallet/{id} {
|
||||||
|
allow read: if request.auth != null && request.auth.uid == uid;
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// AI 채팅(짹) — 전부 Admin SDK(서버) 전용. 클라이언트가 직접 읽으면
|
// AI 채팅(짹) — 전부 Admin SDK(서버) 전용. 클라이언트가 직접 읽으면
|
||||||
// 프롬프트 노출, 직접 쓰면 한도 우회·이력 위조(영속 인젝션)가 가능해진다.
|
// 프롬프트 노출, 직접 쓰면 한도 우회·이력 위조(영속 인젝션)가 가능해진다.
|
||||||
// 이력 조회도 GET /chat/messages 경유. (ai-chat-tech-design.md §4.6)
|
// 이력 조회도 GET /chat/messages 경유. (ai-chat-tech-design.md §4.6)
|
||||||
@ -43,6 +56,12 @@ service cloud.firestore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 상품·재고 및 주문 PII는 모두 API 전용이다.
|
||||||
|
match /products/{productId} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
match /orders/{orderId} { allow read, write: if false; }
|
||||||
|
|
||||||
// 시스템 프롬프트·필터 사전·한도 설정 평문 보관 — 노출 시 §7.5 무력화
|
// 시스템 프롬프트·필터 사전·한도 설정 평문 보관 — 노출 시 §7.5 무력화
|
||||||
match /config/{doc} {
|
match /config/{doc} {
|
||||||
allow read, write: if false;
|
allow read, write: if false;
|
||||||
|
|||||||
@ -11,7 +11,9 @@
|
|||||||
"logs": "firebase functions:log",
|
"logs": "firebase functions:log",
|
||||||
"test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"",
|
"test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts"
|
"tools:sheet": "npx tsx scripts/chat-tools-sheet.ts",
|
||||||
|
"seed:rewards": "npx tsx scripts/seed-reward-products.ts",
|
||||||
|
"upload:reward-assets": "npx tsx scripts/upload-reward-product-assets.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24"
|
"node": "24"
|
||||||
|
|||||||
37
scripts/seed-reward-products.ts
Normal file
37
scripts/seed-reward-products.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import "./_bootstrap";
|
||||||
|
import { Timestamp } from "firebase-admin/firestore";
|
||||||
|
import { firestore } from "../src/firebase";
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{ id: "acrylic-keyring", name: "아크릴 키링", pointPrice: 3000, displayOrder: 1 },
|
||||||
|
{ id: "magsafe-tok", name: "맥세이프 톡", pointPrice: 6500, displayOrder: 2 },
|
||||||
|
{ id: "keyring-doll", name: "키링 인형", pointPrice: 11000, displayOrder: 3 },
|
||||||
|
{ id: "crossbag", name: "크로스백", pointPrice: 15000, displayOrder: 4 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const now = Timestamp.now();
|
||||||
|
for (const p of products) {
|
||||||
|
const product = firestore.doc(`products/${p.id}`);
|
||||||
|
const old = await product.get();
|
||||||
|
const existing = old.data();
|
||||||
|
const draft = old.exists ? {} : {
|
||||||
|
active: false,
|
||||||
|
redeemable: false,
|
||||||
|
mainImages: [],
|
||||||
|
detailImages: [],
|
||||||
|
};
|
||||||
|
await product.set({
|
||||||
|
name: p.name,
|
||||||
|
pointPrice: p.pointPrice,
|
||||||
|
displayOrder: p.displayOrder,
|
||||||
|
totalStock: existing?.totalStock ?? 0,
|
||||||
|
reservedStock: existing?.reservedStock ?? 0,
|
||||||
|
safetyStock: existing?.safetyStock ?? 0,
|
||||||
|
...draft,
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
}, { merge: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().then(() => console.log(`seeded ${products.length} reward products`)).catch((e) => { console.error(e); process.exitCode = 1; });
|
||||||
217
scripts/upload-reward-product-assets.ts
Normal file
217
scripts/upload-reward-product-assets.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectId = "mmday-panit";
|
||||||
|
const storageBucket = "mmday-panit.firebasestorage.app";
|
||||||
|
const sourceRoot = process.env.REWARD_ASSET_DIR;
|
||||||
|
|
||||||
|
if (!sourceRoot) {
|
||||||
|
throw new Error("REWARD_ASSET_DIR is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = (process.platform === "win32" ?
|
||||||
|
execSync("gcloud auth print-access-token", {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsHide: true,
|
||||||
|
}) :
|
||||||
|
execFileSync("gcloud", ["auth", "print-access-token"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsHide: true,
|
||||||
|
})).trim();
|
||||||
|
|
||||||
|
const collator = new Intl.Collator("ko", { numeric: true });
|
||||||
|
const firestoreBase =
|
||||||
|
`https://firestore.googleapis.com/v1/projects/${projectId}/databases/(default)/documents`;
|
||||||
|
|
||||||
|
interface ProductAssetSpec {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pointPrice: number;
|
||||||
|
displayOrder: number;
|
||||||
|
folder?: string;
|
||||||
|
singleMain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products: ProductAssetSpec[] = [
|
||||||
|
{
|
||||||
|
id: "acrylic-keyring",
|
||||||
|
name: "아크릴 키링",
|
||||||
|
pointPrice: 3000,
|
||||||
|
displayOrder: 1,
|
||||||
|
folder: "아크릴키링 상세페이지",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "magsafe-tok",
|
||||||
|
name: "맥세이프 톡",
|
||||||
|
pointPrice: 6500,
|
||||||
|
displayOrder: 2,
|
||||||
|
folder: "맥세이프톡 상세페이지",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keyring-doll",
|
||||||
|
name: "키링 인형",
|
||||||
|
pointPrice: 11000,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crossbag",
|
||||||
|
name: "크로스백",
|
||||||
|
pointPrice: 15000,
|
||||||
|
displayOrder: 4,
|
||||||
|
folder: "크로스백 상세페이지",
|
||||||
|
singleMain: "메인.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function imageFiles(dir: string): Promise<string[]> {
|
||||||
|
const names = await readdir(dir);
|
||||||
|
return names
|
||||||
|
.filter((name) => /\.(png|jpe?g|webp)$/i.test(name))
|
||||||
|
.sort(collator.compare)
|
||||||
|
.map((name) => path.join(dir, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(objectPath: string, token: string): string {
|
||||||
|
return `https://firebasestorage.googleapis.com/v0/b/${storageBucket}/o/${encodeURIComponent(objectPath)}?alt=media&token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(localPath: string, objectPath: string): Promise<string> {
|
||||||
|
const token = randomUUID();
|
||||||
|
const boundary = `mmday-${randomUUID()}`;
|
||||||
|
const metadata = Buffer.from(JSON.stringify({
|
||||||
|
name: objectPath,
|
||||||
|
contentType: "image/png",
|
||||||
|
cacheControl: "public,max-age=31536000,immutable",
|
||||||
|
metadata: { firebaseStorageDownloadTokens: token },
|
||||||
|
}));
|
||||||
|
const image = await readFile(localPath);
|
||||||
|
const body = Buffer.concat([
|
||||||
|
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n`),
|
||||||
|
metadata,
|
||||||
|
Buffer.from(`\r\n--${boundary}\r\nContent-Type: image/png\r\n\r\n`),
|
||||||
|
image,
|
||||||
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||||
|
]);
|
||||||
|
const response = await fetch(
|
||||||
|
`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(storageBucket)}/o?uploadType=multipart`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": `multipart/related; boundary=${boundary}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Storage upload failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return downloadUrl(objectPath, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLimited(
|
||||||
|
files: string[],
|
||||||
|
destinationPrefix: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const output = new Array<string>(files.length);
|
||||||
|
let cursor = 0;
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < files.length) {
|
||||||
|
const index = cursor++;
|
||||||
|
const ext = path.extname(files[index]).toLowerCase();
|
||||||
|
const destination = `${destinationPrefix}/${String(index + 1).padStart(2, "0")}${ext}`;
|
||||||
|
output[index] = await upload(files[index], destination);
|
||||||
|
console.log(`uploaded ${destination}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertProduct(
|
||||||
|
spec: ProductAssetSpec,
|
||||||
|
mainImages: string[],
|
||||||
|
detailImages: string[]
|
||||||
|
) {
|
||||||
|
const existingProduct = await getDocument(`products/${spec.id}`);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const hasImages = mainImages.length > 0;
|
||||||
|
await patchDocument(`products/${spec.id}`, {
|
||||||
|
name: { stringValue: spec.name },
|
||||||
|
pointPrice: { integerValue: String(spec.pointPrice) },
|
||||||
|
active: { booleanValue: hasImages },
|
||||||
|
redeemable: { booleanValue: hasImages },
|
||||||
|
displayOrder: { integerValue: String(spec.displayOrder) },
|
||||||
|
mainImages: stringArray(mainImages),
|
||||||
|
detailImages: stringArray(detailImages),
|
||||||
|
totalStock: existingProduct?.fields?.totalStock ?? { integerValue: "0" },
|
||||||
|
reservedStock: existingProduct?.fields?.reservedStock ?? { integerValue: "0" },
|
||||||
|
safetyStock: existingProduct?.fields?.safetyStock ?? { integerValue: "0" },
|
||||||
|
createdAt: existingProduct?.fields?.createdAt ?? { timestampValue: now },
|
||||||
|
updatedAt: { timestampValue: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreValue = Record<string, unknown>;
|
||||||
|
interface FirestoreDocument { fields?: Record<string, FirestoreValue> }
|
||||||
|
|
||||||
|
function stringArray(values: string[]): FirestoreValue {
|
||||||
|
return { arrayValue: { values: values.map((value) => ({ stringValue: value })) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocument(documentPath: string): Promise<FirestoreDocument | null> {
|
||||||
|
const response = await fetch(`${firestoreBase}/${documentPath}`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Firestore read failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return await response.json() as FirestoreDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDocument(
|
||||||
|
documentPath: string,
|
||||||
|
fields: Record<string, FirestoreValue>
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${firestoreBase}/${documentPath}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ fields }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Firestore write failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const spec of products) {
|
||||||
|
let mainImages: string[] = [];
|
||||||
|
let detailImages: string[] = [];
|
||||||
|
if (spec.folder) {
|
||||||
|
const productDir = path.join(sourceRoot, spec.folder);
|
||||||
|
const mainFiles = spec.singleMain ?
|
||||||
|
[path.join(productDir, spec.singleMain)] :
|
||||||
|
await imageFiles(path.join(productDir, "메인"));
|
||||||
|
const detailFiles = await imageFiles(path.join(productDir, "상세페이지"));
|
||||||
|
[mainImages, detailImages] = await Promise.all([
|
||||||
|
uploadLimited(mainFiles, `reward-products/${spec.id}/main`),
|
||||||
|
uploadLimited(detailFiles, `reward-products/${spec.id}/detail`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await upsertProduct(spec, mainImages, detailImages);
|
||||||
|
console.log(
|
||||||
|
`saved ${spec.id}: main=${mainImages.length}, detail=${detailImages.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -1,14 +1,66 @@
|
|||||||
import { onRequest } from "firebase-functions/https";
|
import { onRequest } from "firebase-functions/https";
|
||||||
import { requireAdmin } from "../middleware/admin";
|
import { requireAdmin } from "../middleware/admin";
|
||||||
import { sendError } from "../middleware/errors";
|
import { HttpError, sendError } from "../middleware/errors";
|
||||||
import { markGameEnded } from "../services/gameResultService";
|
import { markGameEnded } from "../services/gameResultService";
|
||||||
|
import { adminPointChange } from "../services/adminPointService";
|
||||||
|
import { getWallet } from "../repositories/walletRepository";
|
||||||
|
import { listLedger } from "../repositories/pointLedgerRepository";
|
||||||
|
import { firestore } from "../firebase";
|
||||||
|
import { Timestamp } from "firebase-admin/firestore";
|
||||||
|
import { availableStock, productRef } from "../repositories/productRepository";
|
||||||
|
import type { OrderStatus, ProductDoc } from "../types/reward";
|
||||||
|
import { transitionOrder } from "../services/orderService";
|
||||||
|
import { invalidateRewardCatalog } from "../services/rewardCatalogService";
|
||||||
|
|
||||||
export const admin = onRequest(async (req, res) => {
|
export const admin = onRequest(async (req, res) => {
|
||||||
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/");
|
||||||
const tail = segs.slice(-2).join("/");
|
const tail = segs.slice(-2).join("/");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await requireAdmin(req);
|
const adminUid = await requireAdmin(req);
|
||||||
|
|
||||||
|
if ((tail === "points/credit" || tail === "points/debit") && req.method === "POST") { const { uid, amount, clientIdempotencyKey, reason, reversalOf } = req.body ?? {}; res.json(await adminPointChange(uid, amount, tail.endsWith("credit") ? "credit" : "debit", clientIdempotencyKey, reason, adminUid, reversalOf)); return; }
|
||||||
|
if (tail === "points/wallet" && req.method === "GET") { res.json(await getWallet(String(req.query.uid))); return; }
|
||||||
|
if (tail === "points/ledger" && req.method === "GET") { res.json(await listLedger(String(req.query.uid), Number(req.query.limit) || 20, typeof req.query.cursor === "string" ? req.query.cursor : undefined)); return; }
|
||||||
|
if (tail === "product/upsert" && req.method === "POST") {
|
||||||
|
const {
|
||||||
|
id, name, pointPrice, active, redeemable, displayOrder,
|
||||||
|
mainImages, detailImages,
|
||||||
|
} = req.body ?? {};
|
||||||
|
const validUrls = (value: unknown, max: number) =>
|
||||||
|
Array.isArray(value) && value.length <= max && value.every((url) =>
|
||||||
|
typeof url === "string" && url.length <= 2048 && /^https:\/\//.test(url)
|
||||||
|
);
|
||||||
|
const valid =
|
||||||
|
/^[A-Za-z0-9_-]{1,100}$/.test(id) &&
|
||||||
|
typeof name === "string" && name.length >= 1 && name.length <= 100 &&
|
||||||
|
Number.isSafeInteger(pointPrice) && pointPrice >= 1 &&
|
||||||
|
typeof active === "boolean" && typeof redeemable === "boolean" &&
|
||||||
|
Number.isSafeInteger(displayOrder) && displayOrder >= 0 &&
|
||||||
|
validUrls(mainImages, 10) && validUrls(detailImages, 30) &&
|
||||||
|
(!active || !redeemable || mainImages.length > 0);
|
||||||
|
if (!valid) throw new HttpError(400, "invalid product", "INVALID_INPUT");
|
||||||
|
const now = Timestamp.now();
|
||||||
|
await firestore.runTransaction(async (tx) => {
|
||||||
|
const productRef = firestore.doc(`products/${id}`);
|
||||||
|
const old = await tx.get(productRef);
|
||||||
|
const previous = old.data() as Partial<ProductDoc> | undefined;
|
||||||
|
tx.set(productRef, {
|
||||||
|
name, pointPrice, active, redeemable, displayOrder,
|
||||||
|
mainImages, detailImages,
|
||||||
|
totalStock: previous?.totalStock ?? 0,
|
||||||
|
reservedStock: previous?.reservedStock ?? 0,
|
||||||
|
safetyStock: previous?.safetyStock ?? 0,
|
||||||
|
createdAt: previous?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
invalidateRewardCatalog(id);
|
||||||
|
res.json({ id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail === "stock/adjust" && req.method === "POST") { const { productId, deltaTotal, safetyStock } = req.body ?? {}; if (!/^[A-Za-z0-9_-]{1,100}$/.test(productId) || !Number.isSafeInteger(deltaTotal ?? 0) || (safetyStock !== undefined && (!Number.isSafeInteger(safetyStock) || safetyStock < 0))) throw new HttpError(400, "invalid stock adjustment", "INVALID_INPUT"); const result = await firestore.runTransaction(async (tx) => { const ref = productRef(productId); const snap = await tx.get(ref); if (!snap.exists) throw new HttpError(404, "product not found", "PRODUCT_NOT_FOUND"); const product = snap.data() as ProductDoc; const next = { totalStock: product.totalStock + (deltaTotal ?? 0), reservedStock: product.reservedStock, safetyStock: safetyStock ?? product.safetyStock, updatedAt: Timestamp.now() }; if (next.totalStock < 0 || availableStock(next) < 0) throw new HttpError(409, "invalid stock adjustment", "INVALID_STOCK"); tx.update(ref, next); return { ...next, availableStock: availableStock(next) }; }); res.json(result); return; }
|
||||||
|
if (tail === "order/status" && req.method === "POST") { const { orderId, status, restock } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true, restock })); return; }
|
||||||
|
|
||||||
if (tail === "game/end" && req.method === "POST") {
|
if (tail === "game/end" && req.method === "POST") {
|
||||||
const { gameId, winningTeamCode } = req.body ?? {};
|
const { gameId, winningTeamCode } = req.body ?? {};
|
||||||
|
|||||||
21
src/handlers/rewardHandlers.ts
Normal file
21
src/handlers/rewardHandlers.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { onRequest } from "firebase-functions/https";
|
||||||
|
import { requireAuth } from "../middleware/auth";
|
||||||
|
import { sendError } from "../middleware/errors";
|
||||||
|
import { getWallet } from "../repositories/walletRepository";
|
||||||
|
import { listLedger } from "../repositories/pointLedgerRepository";
|
||||||
|
import { getCatalog, getCatalogProduct } from "../services/rewardCatalogService";
|
||||||
|
import { computeEligibility } from "../services/eligibilityService";
|
||||||
|
import { cancelOrder, createOrder, getOrder, listOrders } from "../services/orderService";
|
||||||
|
|
||||||
|
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); } });
|
||||||
@ -11,6 +11,7 @@ export { admin } from "./handlers/adminHandlers";
|
|||||||
export { debug } from "./handlers/debugHandlers";
|
export { debug } from "./handlers/debugHandlers";
|
||||||
export { attendance } from "./handlers/attendanceHandlers";
|
export { attendance } from "./handlers/attendanceHandlers";
|
||||||
export { chat } from "./handlers/chatHandlers";
|
export { chat } from "./handlers/chatHandlers";
|
||||||
|
export { reward } from "./handlers/rewardHandlers";
|
||||||
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
export { kboDailyRefresh } from "./scheduled/kboRefresh";
|
||||||
export { dailyArchive } from "./scheduled/dailyArchive";
|
export { dailyArchive } from "./scheduled/dailyArchive";
|
||||||
export { accountPurge } from "./scheduled/accountPurge";
|
export { accountPurge } from "./scheduled/accountPurge";
|
||||||
|
|||||||
5
src/repositories/orderRepository.ts
Normal file
5
src/repositories/orderRepository.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { firestore } from "../firebase";
|
||||||
|
import type { OrderDoc } from "../types/reward";
|
||||||
|
export function orderRef(id: string) { return firestore.doc(`orders/${id}`); }
|
||||||
|
export async function listOrders(uid: string, limit = 20, cursor?: string) { let q = firestore.collection("orders").where("uid", "==", uid).orderBy("createdAt", "desc").limit(Math.min(Math.max(limit, 1), 100)); if (cursor) { const c = await orderRef(cursor).get(); if (c.exists) q = q.startAfter(c) as typeof q; } const s = await q.get(); return { items: s.docs.map((d) => ({ id: d.id, ...(d.data() as OrderDoc) })), cursor: s.docs.length ? s.docs[s.docs.length - 1].id : null }; }
|
||||||
|
export async function getOrder(id: string) { const s = await orderRef(id).get(); return s.exists ? { id, ...(s.data() as OrderDoc) } : null; }
|
||||||
6
src/repositories/productRepository.ts
Normal file
6
src/repositories/productRepository.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { firestore } from "../firebase";
|
||||||
|
import type { ProductDoc } from "../types/reward";
|
||||||
|
export function productRef(id: string) { return firestore.doc(`products/${id}`); }
|
||||||
|
export function availableStock(product: Pick<ProductDoc, "totalStock" | "reservedStock" | "safetyStock">) { return product.totalStock - product.reservedStock - product.safetyStock; }
|
||||||
|
export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); }
|
||||||
|
export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; }
|
||||||
19
src/services/eligibilityService.ts
Normal file
19
src/services/eligibilityService.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { EligibilityResult } from "../types/reward";
|
||||||
|
import { getAvailableBalance } from "../repositories/walletRepository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 교환 자격은 가용 포인트만으로 판단한다.
|
||||||
|
* 상품을 지정하지 않은 상점 진입에서는 1P 이상, 주문에서는 주문 총액 이상이 기준이다.
|
||||||
|
*/
|
||||||
|
export async function computeEligibility(
|
||||||
|
uid: string,
|
||||||
|
requiredPoints = 1
|
||||||
|
): Promise<EligibilityResult> {
|
||||||
|
const availableBalance = await getAvailableBalance(uid);
|
||||||
|
const reasons = availableBalance >= requiredPoints ? [] : ["INSUFFICIENT_BALANCE"];
|
||||||
|
return {
|
||||||
|
eligible: reasons.length === 0,
|
||||||
|
reasons,
|
||||||
|
availableBalance,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/services/orderService.ts
Normal file
43
src/services/orderService.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Timestamp, type DocumentSnapshot } from "firebase-admin/firestore";
|
||||||
|
import { firestore } from "../firebase";
|
||||||
|
import { HttpError } from "../middleware/errors";
|
||||||
|
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
|
||||||
|
import { availableStock, productRef } from "../repositories/productRepository";
|
||||||
|
import { applyPointChangesTx } from "./pointService";
|
||||||
|
import { getWalletTx } from "../repositories/walletRepository";
|
||||||
|
import { PointLedgerType } from "../types/points";
|
||||||
|
import { ORDER_TRANSITIONS, type OrderDoc, type OrderStatus, type ProductDoc, type Recipient } from "../types/reward";
|
||||||
|
|
||||||
|
const KEY = /^[A-Za-z0-9_-]{1,100}$/;
|
||||||
|
export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number }>; recipient: Recipient }
|
||||||
|
function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100)) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); }
|
||||||
|
|
||||||
|
export async function createOrder(uid: string, input: CreateOrderInput) {
|
||||||
|
validateInput(input); const id = `${uid}_${input.clientIdempotencyKey}`; const now = Timestamp.now();
|
||||||
|
return firestore.runTransaction(async (tx) => {
|
||||||
|
const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true };
|
||||||
|
const products: DocumentSnapshot[] = []; for (const item of input.items) products.push(await tx.get(productRef(item.productId)));
|
||||||
|
const currentWallet = await getWalletTx(tx, uid);
|
||||||
|
const orderItems = input.items.map((item, i) => { const p = products[i].data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); if (availableStock(p) < item.qty) throw new HttpError(409, "out of stock", "OUT_OF_STOCK"); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name }; });
|
||||||
|
const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
|
||||||
|
const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]);
|
||||||
|
for (let i = 0; i < input.items.length; i++) tx.update(productRef(input.items[i].productId), { reservedStock: (products[i].data() as ProductDoc).reservedStock + input.items[i].qty, updatedAt: now });
|
||||||
|
const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now };
|
||||||
|
tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; restock?: boolean; system?: boolean } = {}) {
|
||||||
|
return firestore.runTransaction(async (tx) => {
|
||||||
|
const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc;
|
||||||
|
if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN");
|
||||||
|
const productSnaps: DocumentSnapshot[] = []; for (const item of order.items) productSnaps.push(await tx.get(productRef(item.productId))); const now = Timestamp.now();
|
||||||
|
let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; }
|
||||||
|
if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]);
|
||||||
|
order.items.forEach((item, i) => { const product = productSnaps[i].data() as ProductDoc; const patch: Partial<ProductDoc> = { updatedAt: now }; if (target === "cancelled") patch.reservedStock = product.reservedStock - item.qty; if (target === "confirmed") { patch.reservedStock = product.reservedStock - item.qty; patch.totalStock = product.totalStock - item.qty; } if (target === "refunded" && opts.restock !== false) patch.totalStock = product.totalStock + item.qty; tx.update(productRef(item.productId), patch); });
|
||||||
|
const patch: Record<string, unknown> = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch);
|
||||||
|
return { id: orderId, status: target };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function cancelOrder(uid: string, id: string) { const order = await getOrder(id); if (!order || order.uid !== uid) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); return transitionOrder(id, "cancelled", uid); }
|
||||||
|
export { getOrder, listOrders };
|
||||||
48
src/services/rewardCatalogService.ts
Normal file
48
src/services/rewardCatalogService.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { MemCache } from "../lib/memCache";
|
||||||
|
import { getProduct, listProducts } from "../repositories/productRepository";
|
||||||
|
|
||||||
|
const cache = new MemCache<unknown>(60_000, 100);
|
||||||
|
|
||||||
|
function catalogSummary<T extends {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
pointPrice: number;
|
||||||
|
active: boolean;
|
||||||
|
redeemable: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
mainImages: string[];
|
||||||
|
}>(product: T) {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
pointPrice: product.pointPrice,
|
||||||
|
active: product.active,
|
||||||
|
redeemable: product.redeemable,
|
||||||
|
displayOrder: product.displayOrder,
|
||||||
|
mainImages: product.mainImages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalog() {
|
||||||
|
return cache.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 () => {
|
||||||
|
const product = await getProduct(id);
|
||||||
|
if (!product || !product.active || !product.redeemable) return null;
|
||||||
|
return {
|
||||||
|
...catalogSummary(product),
|
||||||
|
detailImages: product.detailImages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateRewardCatalog(productId?: string) {
|
||||||
|
cache.delete("list");
|
||||||
|
if (productId) cache.delete(`product:${productId}`);
|
||||||
|
}
|
||||||
41
src/types/reward.ts
Normal file
41
src/types/reward.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { Timestamp } from "firebase-admin/firestore";
|
||||||
|
|
||||||
|
export interface ProductDoc {
|
||||||
|
name: string;
|
||||||
|
pointPrice: number;
|
||||||
|
active: boolean;
|
||||||
|
redeemable: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
/** 상품 갤러리. 첫 번째 URL은 상점 외부 카드의 대표 이미지로 사용한다. */
|
||||||
|
mainImages: string[];
|
||||||
|
/** 상세 페이지에서 배열 순서대로 위에서 아래로 노출한다. */
|
||||||
|
detailImages: string[];
|
||||||
|
/** 실재고. 예약 확정 시 차감한다. */
|
||||||
|
totalStock: number;
|
||||||
|
/** 주문에 예약되어 아직 확정되지 않은 재고. */
|
||||||
|
reservedStock: number;
|
||||||
|
/** 판매하지 않고 남겨 둘 안전재고. */
|
||||||
|
safetyStock: number;
|
||||||
|
createdAt: Timestamp;
|
||||||
|
updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
export type OrderStatus = "reserved" | "confirmed" | "preparing" | "shipped" | "delivered" | "cancelled" | "refunded";
|
||||||
|
export const ORDER_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||||
|
reserved: ["confirmed", "cancelled"], confirmed: ["preparing", "refunded"],
|
||||||
|
preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"],
|
||||||
|
delivered: ["refunded"], cancelled: [], refunded: [],
|
||||||
|
};
|
||||||
|
export interface OrderItem { productId: string; qty: number; pointPrice: number; name: string }
|
||||||
|
export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
|
||||||
|
export interface OrderDoc {
|
||||||
|
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus;
|
||||||
|
clientIdempotencyKey: string; reserveLedgerTxIds: string[]; orderedAt: Timestamp;
|
||||||
|
statusHistory: Array<{ status: OrderStatus; at: Timestamp; actor: string }>;
|
||||||
|
cancelledBy?: string; cancelledAt?: Timestamp; confirmedAt?: Timestamp; refundedAt?: Timestamp;
|
||||||
|
createdAt: Timestamp; updatedAt: Timestamp;
|
||||||
|
}
|
||||||
|
export interface EligibilityResult {
|
||||||
|
eligible: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
availableBalance: number;
|
||||||
|
}
|
||||||
50
tests/services/eligibilityService.test.ts
Normal file
50
tests/services/eligibilityService.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { firestore } from "../../src/firebase";
|
||||||
|
import { computeEligibility } from "../../src/services/eligibilityService";
|
||||||
|
import { applyPointChanges } from "../../src/services/pointService";
|
||||||
|
import { PointLedgerType } from "../../src/types/points";
|
||||||
|
|
||||||
|
const uid = "eligibility-points-only";
|
||||||
|
|
||||||
|
describe("computeEligibility", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await firestore.recursiveDelete(firestore.doc(`users/${uid}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("상점 진입 자격은 가용 포인트 보유 여부만 확인한다", async () => {
|
||||||
|
expect(await computeEligibility(uid)).toEqual({
|
||||||
|
eligible: false,
|
||||||
|
reasons: ["INSUFFICIENT_BALANCE"],
|
||||||
|
availableBalance: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyPointChanges(uid, [{
|
||||||
|
txId: `${uid}:admin:seed`,
|
||||||
|
type: PointLedgerType.AdminCredit,
|
||||||
|
amount: 100,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(await computeEligibility(uid)).toEqual({
|
||||||
|
eligible: true,
|
||||||
|
reasons: [],
|
||||||
|
availableBalance: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("주문 자격은 주문 총액 이상의 가용 포인트만 확인한다", async () => {
|
||||||
|
await applyPointChanges(uid, [{
|
||||||
|
txId: `${uid}:admin:seed-order`,
|
||||||
|
type: PointLedgerType.AdminCredit,
|
||||||
|
amount: 100,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(await computeEligibility(uid, 101)).toMatchObject({
|
||||||
|
eligible: false,
|
||||||
|
reasons: ["INSUFFICIENT_BALANCE"],
|
||||||
|
});
|
||||||
|
expect(await computeEligibility(uid, 100)).toMatchObject({
|
||||||
|
eligible: true,
|
||||||
|
reasons: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/services/rewardCatalogService.test.ts
Normal file
57
tests/services/rewardCatalogService.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Timestamp } from "firebase-admin/firestore";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { firestore } from "../../src/firebase";
|
||||||
|
import {
|
||||||
|
getCatalog,
|
||||||
|
getCatalogProduct,
|
||||||
|
invalidateRewardCatalog,
|
||||||
|
} from "../../src/services/rewardCatalogService";
|
||||||
|
|
||||||
|
const id = "catalog-images-test";
|
||||||
|
const mainImages = [
|
||||||
|
"https://cdn.example.com/product/cover.jpg",
|
||||||
|
"https://cdn.example.com/product/gallery-2.jpg",
|
||||||
|
];
|
||||||
|
const detailImages = [
|
||||||
|
"https://cdn.example.com/product/detail-1.jpg",
|
||||||
|
"https://cdn.example.com/product/detail-2.jpg",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("rewardCatalogService image contract", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
invalidateRewardCatalog(id);
|
||||||
|
await firestore.doc(`products/${id}`).delete();
|
||||||
|
const now = Timestamp.now();
|
||||||
|
await firestore.doc(`products/${id}`).set({
|
||||||
|
name: "이미지 계약 상품",
|
||||||
|
pointPrice: 1000,
|
||||||
|
active: true,
|
||||||
|
redeemable: true,
|
||||||
|
displayOrder: 999,
|
||||||
|
mainImages,
|
||||||
|
detailImages,
|
||||||
|
totalStock: 10,
|
||||||
|
reservedStock: 2,
|
||||||
|
safetyStock: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("목록에는 순서가 보존된 mainImages만 노출한다", async () => {
|
||||||
|
const product = (await getCatalog()).find((item) => item.id === id);
|
||||||
|
expect(product).toMatchObject({ id, mainImages });
|
||||||
|
expect(product).not.toHaveProperty("detailImages");
|
||||||
|
expect(product).not.toHaveProperty("totalStock");
|
||||||
|
expect(product).not.toHaveProperty("reservedStock");
|
||||||
|
expect(product).not.toHaveProperty("safetyStock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("상세에는 mainImages와 detailImages를 모두 순서대로 노출한다", async () => {
|
||||||
|
expect(await getCatalogProduct(id)).toMatchObject({
|
||||||
|
id,
|
||||||
|
mainImages,
|
||||||
|
detailImages,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user