@mohasinac/appkit 3.0.6 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +42 -0
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +86 -0
- package/dist/_internal/client/features/lottery/LotteryAdminSlotView.d.ts +14 -0
- package/dist/_internal/client/features/lottery/LotteryAdminSlotView.js +46 -0
- package/dist/_internal/client/features/lottery/LotteryDetailView.d.ts +28 -0
- package/dist/_internal/client/features/lottery/LotteryDetailView.js +17 -0
- package/dist/_internal/client/features/lottery/LotteryEntriesView.d.ts +23 -0
- package/dist/_internal/client/features/lottery/LotteryEntriesView.js +58 -0
- package/dist/_internal/client/features/lottery/LotteryListView.d.ts +21 -0
- package/dist/_internal/client/features/lottery/LotteryListView.js +25 -0
- package/dist/_internal/client/features/lottery/LotteryPullForm.d.ts +19 -0
- package/dist/_internal/client/features/lottery/LotteryPullForm.js +65 -0
- package/dist/_internal/client/features/lottery/LotterySlotGrid.d.ts +13 -0
- package/dist/_internal/client/features/lottery/LotterySlotGrid.js +21 -0
- package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.d.ts +28 -0
- package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.js +18 -0
- package/dist/_internal/server/features/auctions/__tests__/service.test.d.ts +1 -0
- package/dist/_internal/server/features/auctions/__tests__/service.test.js +140 -0
- package/dist/_internal/server/features/checkout/__tests__/actions.test.d.ts +1 -0
- package/dist/_internal/server/features/checkout/__tests__/actions.test.js +600 -0
- package/dist/_internal/server/features/lottery/actions.d.ts +43 -0
- package/dist/_internal/server/features/lottery/actions.js +243 -0
- package/dist/_internal/server/features/lottery/adapters.d.ts +9 -0
- package/dist/_internal/server/features/lottery/adapters.js +21 -0
- package/dist/_internal/server/features/lottery/data.d.ts +20 -0
- package/dist/_internal/server/features/lottery/data.js +41 -0
- package/dist/_internal/server/features/lottery/metadata.d.ts +6 -0
- package/dist/_internal/server/features/lottery/metadata.js +31 -0
- package/dist/_internal/server/features/refunds/__tests__/actions.test.d.ts +1 -0
- package/dist/_internal/server/features/refunds/__tests__/actions.test.js +263 -0
- package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.js +249 -0
- package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.js +201 -0
- package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.js +169 -0
- package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.js +148 -0
- package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.js +210 -0
- package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.js +190 -0
- package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.js +161 -0
- package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.js +302 -0
- package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.js +252 -0
- package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.d.ts +1 -0
- package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.js +129 -0
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +46 -0
- package/dist/_internal/shared/features/products/schema.d.ts +8 -8
- package/dist/client.d.ts +9 -0
- package/dist/client.js +9 -0
- package/dist/constants/field-names.d.ts +5 -1
- package/dist/constants/field-names.js +5 -1
- package/dist/features/addresses/repository/__tests__/addresses.repository.test.d.ts +1 -0
- package/dist/features/addresses/repository/__tests__/addresses.repository.test.js +386 -0
- package/dist/features/admin/api/bids/route.d.ts +2 -2
- package/dist/features/admin/api/coupons/route.d.ts +2 -2
- package/dist/features/admin/api/products/route.d.ts +2 -2
- package/dist/features/admin/api/reviews/route.d.ts +2 -2
- package/dist/features/admin/repository/__tests__/notification.repository.test.d.ts +1 -0
- package/dist/features/admin/repository/__tests__/notification.repository.test.js +356 -0
- package/dist/features/admin/repository/__tests__/site-settings.repository.test.d.ts +1 -0
- package/dist/features/admin/repository/__tests__/site-settings.repository.test.js +298 -0
- package/dist/features/auctions/repository/__tests__/bid.repository.test.d.ts +1 -0
- package/dist/features/auctions/repository/__tests__/bid.repository.test.js +290 -0
- package/dist/features/auth/api/route.d.ts +2 -2
- package/dist/features/auth/repository/__tests__/session.repository.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/session.repository.test.js +310 -0
- package/dist/features/auth/repository/__tests__/session.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/session.test.js +146 -0
- package/dist/features/auth/repository/__tests__/sms-counter.repository.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/sms-counter.repository.test.js +171 -0
- package/dist/features/auth/repository/__tests__/sms-counter.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/sms-counter.test.js +88 -0
- package/dist/features/auth/repository/__tests__/token.repository.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/token.repository.test.js +260 -0
- package/dist/features/auth/repository/__tests__/token.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/token.test.js +140 -0
- package/dist/features/auth/repository/__tests__/user.repository.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/user.repository.test.js +352 -0
- package/dist/features/auth/repository/__tests__/user.test.d.ts +1 -0
- package/dist/features/auth/repository/__tests__/user.test.js +226 -0
- package/dist/features/auth/repository/session.repository.js +10 -1
- package/dist/features/cart/repository/__tests__/cart.repository.test.d.ts +1 -0
- package/dist/features/cart/repository/__tests__/cart.repository.test.js +371 -0
- package/dist/features/cart/repository/cart.repository.js +2 -2
- package/dist/features/categories/api/[id]/route.d.ts +4 -4
- package/dist/features/categories/api/route.d.ts +2 -2
- package/dist/features/categories/repository/__tests__/categories.repository.test.d.ts +1 -0
- package/dist/features/categories/repository/__tests__/categories.repository.test.js +813 -0
- package/dist/features/events/components/EventCard.js +1 -0
- package/dist/features/events/repository/__tests__/event.repository.test.d.ts +1 -0
- package/dist/features/events/repository/__tests__/event.repository.test.js +299 -0
- package/dist/features/events/schemas/firestore.d.ts +3 -0
- package/dist/features/events/schemas/index.d.ts +834 -14
- package/dist/features/events/schemas/index.js +80 -1
- package/dist/features/events/types/index.d.ts +4 -1
- package/dist/features/faq/repository/__tests__/faqs.repository.test.d.ts +1 -0
- package/dist/features/faq/repository/__tests__/faqs.repository.test.js +311 -0
- package/dist/features/history/repository/__tests__/user-history.repository.test.d.ts +1 -0
- package/dist/features/history/repository/__tests__/user-history.repository.test.js +180 -0
- package/dist/features/homepage/api/[id]/route.d.ts +4 -4
- package/dist/features/homepage/api/carousel/[id]/route.d.ts +4 -4
- package/dist/features/homepage/api/carousel/route.d.ts +2 -2
- package/dist/features/homepage/api/route.d.ts +2 -2
- package/dist/features/homepage/repository/__tests__/carousel.repository.test.d.ts +1 -0
- package/dist/features/homepage/repository/__tests__/carousel.repository.test.js +182 -0
- package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.d.ts +1 -0
- package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.js +185 -0
- package/dist/features/lottery/repository/lottery-entry.repository.d.ts +33 -0
- package/dist/features/lottery/repository/lottery-entry.repository.js +130 -0
- package/dist/features/lottery/schemas/firestore.d.ts +49 -0
- package/dist/features/lottery/schemas/firestore.js +25 -0
- package/dist/features/lottery/schemas/zod.d.ts +113 -0
- package/dist/features/lottery/schemas/zod.js +39 -0
- package/dist/features/lottery/types/index.d.ts +67 -0
- package/dist/features/lottery/types/index.js +55 -0
- package/dist/features/messages/repository/__tests__/conversations.repository.test.d.ts +1 -0
- package/dist/features/messages/repository/__tests__/conversations.repository.test.js +257 -0
- package/dist/features/orders/repository/__tests__/orders.repository.test.d.ts +1 -0
- package/dist/features/orders/repository/__tests__/orders.repository.test.js +211 -0
- package/dist/features/payments/repository/__tests__/payout.repository.test.d.ts +1 -0
- package/dist/features/payments/repository/__tests__/payout.repository.test.js +292 -0
- package/dist/features/products/api/[id]/route.d.ts +4 -4
- package/dist/features/products/api/route.d.ts +2 -2
- package/dist/features/products/repository/__tests__/product-features.repository.test.d.ts +1 -0
- package/dist/features/products/repository/__tests__/product-features.repository.test.js +329 -0
- package/dist/features/products/repository/__tests__/products.repository.test.d.ts +1 -0
- package/dist/features/products/repository/__tests__/products.repository.test.js +607 -0
- package/dist/features/products/schemas/firestore.d.ts +15 -1
- package/dist/features/products/schemas/index.d.ts +12 -12
- package/dist/features/products/schemas/index.js +4 -2
- package/dist/features/products/types/index.d.ts +1 -1
- package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.d.ts +1 -0
- package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.js +219 -0
- package/dist/features/promotions/repository/__tests__/coupons.repository.test.d.ts +1 -0
- package/dist/features/promotions/repository/__tests__/coupons.repository.test.js +480 -0
- package/dist/features/reviews/api/[id]/route.d.ts +4 -4
- package/dist/features/reviews/api/route.d.ts +2 -2
- package/dist/features/reviews/repository/__tests__/reviews.repository.test.d.ts +1 -0
- package/dist/features/reviews/repository/__tests__/reviews.repository.test.js +327 -0
- package/dist/features/scams/repository/__tests__/scammer.repository.test.d.ts +1 -0
- package/dist/features/scams/repository/__tests__/scammer.repository.test.js +385 -0
- package/dist/features/search/repository/__tests__/search.repository.test.d.ts +1 -0
- package/dist/features/search/repository/__tests__/search.repository.test.js +369 -0
- package/dist/features/seller/api/coupons/route.d.ts +2 -2
- package/dist/features/seller/api/offers/route.d.ts +2 -2
- package/dist/features/seller/api/products/route.d.ts +2 -2
- package/dist/features/seller/api/store/route.d.ts +2 -2
- package/dist/features/seller/repository/__tests__/offer.repository.test.d.ts +1 -0
- package/dist/features/seller/repository/__tests__/offer.repository.test.js +458 -0
- package/dist/features/stores/repository/__tests__/store.repository.test.d.ts +1 -0
- package/dist/features/stores/repository/__tests__/store.repository.test.js +261 -0
- package/dist/features/support/repository/__tests__/support.repository.test.d.ts +1 -0
- package/dist/features/support/repository/__tests__/support.repository.test.js +312 -0
- package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.d.ts +1 -0
- package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.js +139 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/next/api/routeHandler.d.ts +1 -1
- package/dist/next/routing/route-map.d.ts +13 -0
- package/dist/next/routing/route-map.js +6 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +2 -0
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +2 -0
- package/dist/security/pii-schemas.js +6 -0
- package/dist/seed/actions/demo-seed-actions.d.ts +1 -1
- package/dist/seed/events-seed-data.js +85 -2
- package/dist/seed/grouped-listings-seed-data.d.ts +3 -1
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/lottery-entries-seed-data.d.ts +2 -0
- package/dist/seed/lottery-entries-seed-data.js +108 -0
- package/dist/seed/manifest.js +1 -0
- package/dist/seed/products-preorders-seed-data.d.ts +3 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +6 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -19,8 +19,11 @@ export const eventTypeSchema = z.enum([
|
|
|
19
19
|
"poll",
|
|
20
20
|
"survey",
|
|
21
21
|
"feedback",
|
|
22
|
+
"raffle",
|
|
23
|
+
"spin_wheel",
|
|
24
|
+
"lottery",
|
|
22
25
|
]);
|
|
23
|
-
export const eventStatusSchema = z.enum(["draft", "active", "paused", "ended"]);
|
|
26
|
+
export const eventStatusSchema = z.enum(["draft", "active", "paused", "ended", "cancelled"]);
|
|
24
27
|
export const saleConfigSchema = z.object({
|
|
25
28
|
discountPercent: z.number(),
|
|
26
29
|
bannerText: z.string().optional(),
|
|
@@ -36,6 +39,60 @@ export const pollConfigSchema = z.object({
|
|
|
36
39
|
allowComment: z.boolean(),
|
|
37
40
|
options: z.array(z.object({ id: z.string(), label: z.string() })),
|
|
38
41
|
resultsVisibility: z.enum(["always", "after_vote", "after_end"]),
|
|
42
|
+
requireLogin: z.boolean().optional(),
|
|
43
|
+
});
|
|
44
|
+
export const surveyFormFieldSchema = z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
type: z.enum(["text", "textarea", "email", "phone", "number", "select", "multiselect", "checkbox", "radio", "date", "rating", "file"]),
|
|
47
|
+
label: z.string(),
|
|
48
|
+
placeholder: z.string().optional(),
|
|
49
|
+
required: z.boolean(),
|
|
50
|
+
options: z.array(z.string()).optional(),
|
|
51
|
+
validation: z.object({
|
|
52
|
+
minLength: z.number().optional(),
|
|
53
|
+
maxLength: z.number().optional(),
|
|
54
|
+
min: z.number().optional(),
|
|
55
|
+
max: z.number().optional(),
|
|
56
|
+
pattern: z.string().optional(),
|
|
57
|
+
}).optional(),
|
|
58
|
+
order: z.number(),
|
|
59
|
+
});
|
|
60
|
+
export const surveyConfigSchema = z.object({
|
|
61
|
+
requireLogin: z.boolean(),
|
|
62
|
+
maxEntriesPerUser: z.number(),
|
|
63
|
+
hasLeaderboard: z.boolean(),
|
|
64
|
+
hasPointSystem: z.boolean(),
|
|
65
|
+
pointsLabel: z.string().optional(),
|
|
66
|
+
entryReviewRequired: z.boolean(),
|
|
67
|
+
formFields: z.array(surveyFormFieldSchema),
|
|
68
|
+
});
|
|
69
|
+
export const feedbackConfigSchema = z.object({
|
|
70
|
+
formFields: z.array(surveyFormFieldSchema),
|
|
71
|
+
anonymous: z.boolean(),
|
|
72
|
+
});
|
|
73
|
+
export const spinPrizeSchema = z.object({
|
|
74
|
+
id: z.string(),
|
|
75
|
+
label: z.string(),
|
|
76
|
+
couponId: z.string().optional(),
|
|
77
|
+
weight: z.number(),
|
|
78
|
+
isActive: z.boolean(),
|
|
79
|
+
});
|
|
80
|
+
/** Lottery slot schema — client-safe shape (no priceInPaise/weight sent to clients). */
|
|
81
|
+
export const lotterySlotClientSchema = z.object({
|
|
82
|
+
slotNumber: z.number().int().min(1),
|
|
83
|
+
name: z.string(),
|
|
84
|
+
isBooked: z.boolean(),
|
|
85
|
+
bookedByUserLotteryNumber: z.number().optional(),
|
|
86
|
+
bookedByDisplayName: z.string().optional(),
|
|
87
|
+
});
|
|
88
|
+
/** Client-safe lottery config schema — price/weight fields stripped server-side. */
|
|
89
|
+
export const lotteryConfigClientSchema = z.object({
|
|
90
|
+
slots: z.array(lotterySlotClientSchema),
|
|
91
|
+
totalSlots: z.number().int().min(1).max(200),
|
|
92
|
+
pricingMode: z.enum(["uniform", "variable"]),
|
|
93
|
+
drawWindowDurationMinutes: z.number(),
|
|
94
|
+
maxPullsPerTransaction: z.number().int().min(1),
|
|
95
|
+
maxPullsPerUser: z.number().int().min(1),
|
|
39
96
|
});
|
|
40
97
|
// --- Base item schema ---------------------------------------------------------
|
|
41
98
|
/**
|
|
@@ -53,6 +110,7 @@ export const pollConfigSchema = z.object({
|
|
|
53
110
|
*/
|
|
54
111
|
export const eventItemSchema = z.object({
|
|
55
112
|
id: z.string(),
|
|
113
|
+
slug: z.string().optional(),
|
|
56
114
|
type: eventTypeSchema,
|
|
57
115
|
title: z.string(),
|
|
58
116
|
description: z.string(),
|
|
@@ -67,6 +125,27 @@ export const eventItemSchema = z.object({
|
|
|
67
125
|
saleConfig: saleConfigSchema.optional(),
|
|
68
126
|
offerConfig: offerConfigSchema.optional(),
|
|
69
127
|
pollConfig: pollConfigSchema.optional(),
|
|
128
|
+
surveyConfig: surveyConfigSchema.optional(),
|
|
129
|
+
feedbackConfig: feedbackConfigSchema.optional(),
|
|
130
|
+
// Raffle fields
|
|
131
|
+
hasRaffle: z.boolean().optional(),
|
|
132
|
+
raffleType: z.enum(["top_n_scorers", "top_n_participants", "open_raffle", "spin_wheel"]).optional(),
|
|
133
|
+
raffleTopN: z.number().optional(),
|
|
134
|
+
rafflePrize: z.string().optional(),
|
|
135
|
+
rafflePrizeCouponId: z.string().optional(),
|
|
136
|
+
rafflePrizeProductIds: z.array(z.string()).optional(),
|
|
137
|
+
raffleGithubFunctionUrl: z.string().optional(),
|
|
138
|
+
raffleWinnerUserId: z.string().optional(),
|
|
139
|
+
raffleWinnerDisplayName: z.string().optional(),
|
|
140
|
+
raffleTriggeredAt: z.string().optional(),
|
|
141
|
+
raffleEntryCount: z.number().optional(),
|
|
142
|
+
// Spin wheel fields
|
|
143
|
+
spinPrizes: z.array(spinPrizeSchema).optional(),
|
|
144
|
+
spinMaxPerUser: z.number().optional(),
|
|
145
|
+
spinWindowStart: z.string().optional(),
|
|
146
|
+
spinWindowEnd: z.string().optional(),
|
|
147
|
+
// Lottery config (new) — client-safe shape (price/weight stripped by adapter)
|
|
148
|
+
lotteryConfig: lotteryConfigClientSchema.optional(),
|
|
70
149
|
stats: z.object({
|
|
71
150
|
totalEntries: z.number(),
|
|
72
151
|
approvedEntries: z.number(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MediaField } from "../../media/types/index";
|
|
2
2
|
import type { JsonValue } from "@mohasinac/appkit";
|
|
3
|
-
|
|
3
|
+
import type { ClientLotteryConfig } from "../../lottery/types";
|
|
4
|
+
export type EventType = "sale" | "offer" | "poll" | "survey" | "feedback" | "raffle" | "spin_wheel" | "lottery";
|
|
4
5
|
export type RaffleType = "top_n_scorers" | "top_n_participants" | "open_raffle" | "spin_wheel";
|
|
5
6
|
export interface SpinPrize {
|
|
6
7
|
id: string;
|
|
@@ -96,6 +97,8 @@ export interface EventItem {
|
|
|
96
97
|
spinMaxPerUser?: number;
|
|
97
98
|
spinWindowStart?: string;
|
|
98
99
|
spinWindowEnd?: string;
|
|
100
|
+
/** Lottery config — slots have price/weight stripped for client consumption. */
|
|
101
|
+
lotteryConfig?: ClientLotteryConfig;
|
|
99
102
|
stats: {
|
|
100
103
|
totalEntries: number;
|
|
101
104
|
approvedEntries: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { makeMockDb, makeSnap, makeQuerySnap } from "../../../../../tests/helpers/mock-firestore";
|
|
3
|
+
const { db, mockDocRef, mockCollection, mockQuery } = makeMockDb();
|
|
4
|
+
vi.mock("../../../../providers/db-firebase/admin", () => ({
|
|
5
|
+
getAdminDb: () => db,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../../../../providers/db-firebase", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal();
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
prepareForFirestore: (d) => d,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
vi.mock("../../../../errors/normalize", () => ({
|
|
15
|
+
normalizeError: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
import { FirebaseFAQsRepository } from "../faqs.repository";
|
|
18
|
+
const repo = new FirebaseFAQsRepository();
|
|
19
|
+
function makeFaqDoc(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: "faq-how-does-bidding-work",
|
|
22
|
+
question: "How does bidding work?",
|
|
23
|
+
answer: { text: "You place a bid above the current price.", format: "plain" },
|
|
24
|
+
category: "Auctions",
|
|
25
|
+
tags: ["bidding", "auctions"],
|
|
26
|
+
searchTokens: ["how", "does", "bidding", "work"],
|
|
27
|
+
relatedFAQs: [],
|
|
28
|
+
seo: { slug: "how-does-bidding-work" },
|
|
29
|
+
isActive: true,
|
|
30
|
+
showOnHomepage: false,
|
|
31
|
+
showInFooter: false,
|
|
32
|
+
isPinned: false,
|
|
33
|
+
priority: 0,
|
|
34
|
+
order: 0,
|
|
35
|
+
useSiteSettings: false,
|
|
36
|
+
createdBy: "admin-1",
|
|
37
|
+
stats: { views: 0, helpful: 0, notHelpful: 0 },
|
|
38
|
+
createdAt: new Date(),
|
|
39
|
+
updatedAt: new Date(),
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
db.collection.mockReturnValue(mockCollection);
|
|
46
|
+
mockCollection.doc.mockReturnValue(mockDocRef);
|
|
47
|
+
mockCollection.where.mockReturnValue(mockQuery);
|
|
48
|
+
mockQuery.where.mockReturnValue(mockQuery);
|
|
49
|
+
mockQuery.orderBy.mockReturnValue(mockQuery);
|
|
50
|
+
mockQuery.limit.mockReturnValue(mockQuery);
|
|
51
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
52
|
+
mockDocRef.get.mockResolvedValue(makeSnap(null));
|
|
53
|
+
mockDocRef.set.mockResolvedValue(undefined);
|
|
54
|
+
mockDocRef.update.mockResolvedValue(undefined);
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// createWithSlug
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
describe("FirebaseFAQsRepository.createWithSlug", () => {
|
|
60
|
+
it("uses seo.slug from input when provided", async () => {
|
|
61
|
+
const input = {
|
|
62
|
+
question: "How does bidding work?",
|
|
63
|
+
answer: { text: "You bid above the current price.", format: "plain" },
|
|
64
|
+
category: "Auctions",
|
|
65
|
+
tags: ["bidding"],
|
|
66
|
+
seo: { slug: "custom-slug" },
|
|
67
|
+
isActive: true,
|
|
68
|
+
};
|
|
69
|
+
const result = await repo.createWithSlug(input);
|
|
70
|
+
expect(result.seo.slug).toBe("custom-slug");
|
|
71
|
+
});
|
|
72
|
+
it("generates seo.slug from question when seo.slug not provided", async () => {
|
|
73
|
+
const input = {
|
|
74
|
+
question: "How does bidding work?",
|
|
75
|
+
answer: { text: "Details here.", format: "plain" },
|
|
76
|
+
category: "Auctions",
|
|
77
|
+
tags: [],
|
|
78
|
+
isActive: true,
|
|
79
|
+
};
|
|
80
|
+
const result = await repo.createWithSlug(input);
|
|
81
|
+
expect(result.seo.slug).toMatch(/bidding/i);
|
|
82
|
+
});
|
|
83
|
+
it("builds searchTokens from question, answer, category, and tags", async () => {
|
|
84
|
+
const input = {
|
|
85
|
+
question: "What is a reserve price?",
|
|
86
|
+
answer: { text: "Reserve is the minimum.", format: "plain" },
|
|
87
|
+
category: "Auctions",
|
|
88
|
+
tags: ["reserve", "price"],
|
|
89
|
+
seo: { slug: "what-is-reserve-price" },
|
|
90
|
+
isActive: true,
|
|
91
|
+
};
|
|
92
|
+
const result = await repo.createWithSlug(input);
|
|
93
|
+
expect(result.searchTokens).toContain("reserve");
|
|
94
|
+
expect(result.searchTokens).toContain("price");
|
|
95
|
+
expect(result.searchTokens).toContain("what");
|
|
96
|
+
});
|
|
97
|
+
it("sets stats.views=0, stats.helpful=0, stats.notHelpful=0", async () => {
|
|
98
|
+
const input = {
|
|
99
|
+
question: "New question?",
|
|
100
|
+
answer: { text: "Answer.", format: "plain" },
|
|
101
|
+
category: "General",
|
|
102
|
+
tags: [],
|
|
103
|
+
isActive: true,
|
|
104
|
+
};
|
|
105
|
+
const result = await repo.createWithSlug(input);
|
|
106
|
+
expect(result.stats).toEqual({ views: 0, helpful: 0, notHelpful: 0 });
|
|
107
|
+
});
|
|
108
|
+
it("persists the FAQ to Firestore", async () => {
|
|
109
|
+
const input = {
|
|
110
|
+
question: "Question?",
|
|
111
|
+
answer: { text: "Answer.", format: "plain" },
|
|
112
|
+
category: "General",
|
|
113
|
+
tags: [],
|
|
114
|
+
seo: { slug: "question" },
|
|
115
|
+
isActive: true,
|
|
116
|
+
};
|
|
117
|
+
await repo.createWithSlug(input);
|
|
118
|
+
expect(mockDocRef.set).toHaveBeenCalledOnce();
|
|
119
|
+
});
|
|
120
|
+
it("searchTokens deduplicated and lowercased", async () => {
|
|
121
|
+
const input = {
|
|
122
|
+
question: "What what what?",
|
|
123
|
+
answer: { text: "Answer.", format: "plain" },
|
|
124
|
+
category: "General",
|
|
125
|
+
tags: [],
|
|
126
|
+
isActive: true,
|
|
127
|
+
};
|
|
128
|
+
const result = await repo.createWithSlug(input);
|
|
129
|
+
const whatCount = result.searchTokens.filter((t) => t === "what").length;
|
|
130
|
+
expect(whatCount).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// interpolateVariables
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
describe("FirebaseFAQsRepository.interpolateVariables", () => {
|
|
137
|
+
it("replaces {{variable}} placeholders with provided values", async () => {
|
|
138
|
+
const faq = makeFaqDoc({
|
|
139
|
+
answer: { text: "Your order {{orderId}} will arrive in {{days}} days.", format: "plain" },
|
|
140
|
+
});
|
|
141
|
+
const result = await repo.interpolateVariables(faq, { orderId: "ORD-123", days: 3 });
|
|
142
|
+
expect(result.answer.interpolated).toBe("Your order ORD-123 will arrive in 3 days.");
|
|
143
|
+
});
|
|
144
|
+
it("falls back to faq.variables when runtime variable not provided", async () => {
|
|
145
|
+
const faq = makeFaqDoc({
|
|
146
|
+
answer: { text: "Contact us at {{supportEmail}}.", format: "plain" },
|
|
147
|
+
variables: { supportEmail: "support@letitrip.in" },
|
|
148
|
+
});
|
|
149
|
+
const result = await repo.interpolateVariables(faq);
|
|
150
|
+
expect(result.answer.interpolated).toBe("Contact us at support@letitrip.in.");
|
|
151
|
+
});
|
|
152
|
+
it("leaves unknown placeholders as-is", async () => {
|
|
153
|
+
const faq = makeFaqDoc({
|
|
154
|
+
answer: { text: "Hello {{unknownVar}}!", format: "plain" },
|
|
155
|
+
});
|
|
156
|
+
const result = await repo.interpolateVariables(faq, {});
|
|
157
|
+
expect(result.answer.interpolated).toBe("Hello {{unknownVar}}!");
|
|
158
|
+
});
|
|
159
|
+
it("returns unchanged interpolated when no placeholders present", async () => {
|
|
160
|
+
const faq = makeFaqDoc({
|
|
161
|
+
answer: { text: "No variables here.", format: "plain" },
|
|
162
|
+
});
|
|
163
|
+
const result = await repo.interpolateVariables(faq, { irrelevant: "x" });
|
|
164
|
+
expect(result.answer.interpolated).toBe("No variables here.");
|
|
165
|
+
});
|
|
166
|
+
it("replaces multiple occurrences of the same placeholder", async () => {
|
|
167
|
+
const faq = makeFaqDoc({
|
|
168
|
+
answer: { text: "{{name}} is {{name}}.", format: "plain" },
|
|
169
|
+
});
|
|
170
|
+
const result = await repo.interpolateVariables(faq, { name: "LetItRip" });
|
|
171
|
+
expect(result.answer.interpolated).toBe("LetItRip is LetItRip.");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// markHelpful
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
describe("FirebaseFAQsRepository.markHelpful", () => {
|
|
178
|
+
it("increments stats.helpful on the FAQ document", async () => {
|
|
179
|
+
await repo.markHelpful("faq-1");
|
|
180
|
+
expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ "stats.helpful": expect.anything() }));
|
|
181
|
+
});
|
|
182
|
+
it("does not modify other stats fields", async () => {
|
|
183
|
+
await repo.markHelpful("faq-1");
|
|
184
|
+
const updateArg = mockDocRef.update.mock.calls[0][0];
|
|
185
|
+
expect(Object.keys(updateArg)).not.toContain("stats.views");
|
|
186
|
+
expect(Object.keys(updateArg)).not.toContain("stats.notHelpful");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// markNotHelpful
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
describe("FirebaseFAQsRepository.markNotHelpful", () => {
|
|
193
|
+
it("increments stats.notHelpful on the FAQ document", async () => {
|
|
194
|
+
await repo.markNotHelpful("faq-1");
|
|
195
|
+
expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ "stats.notHelpful": expect.anything() }));
|
|
196
|
+
});
|
|
197
|
+
it("does not modify stats.helpful", async () => {
|
|
198
|
+
await repo.markNotHelpful("faq-1");
|
|
199
|
+
const updateArg = mockDocRef.update.mock.calls[0][0];
|
|
200
|
+
expect(Object.keys(updateArg)).not.toContain("stats.helpful");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// incrementViews
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
describe("FirebaseFAQsRepository.incrementViews", () => {
|
|
207
|
+
it("increments stats.views by 1", async () => {
|
|
208
|
+
await repo.incrementViews("faq-1");
|
|
209
|
+
expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ "stats.views": expect.anything() }));
|
|
210
|
+
});
|
|
211
|
+
it("sets stats.lastViewed timestamp", async () => {
|
|
212
|
+
await repo.incrementViews("faq-1");
|
|
213
|
+
expect(mockDocRef.update).toHaveBeenCalledWith(expect.objectContaining({ "stats.lastViewed": expect.any(Date) }));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// getHomepageFAQs
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
describe("FirebaseFAQsRepository.getHomepageFAQs", () => {
|
|
220
|
+
it("queries showOnHomepage=true AND isActive=true", async () => {
|
|
221
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
222
|
+
await repo.getHomepageFAQs();
|
|
223
|
+
expect(mockCollection.where).toHaveBeenCalledWith(expect.stringContaining("showOnHomepage"), "==", true);
|
|
224
|
+
expect(mockQuery.where).toHaveBeenCalledWith(expect.stringContaining("isActive"), "==", true);
|
|
225
|
+
});
|
|
226
|
+
it("orders results by priority descending", async () => {
|
|
227
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
228
|
+
await repo.getHomepageFAQs();
|
|
229
|
+
expect(mockQuery.orderBy).toHaveBeenCalledWith(expect.stringContaining("priority"), "desc");
|
|
230
|
+
});
|
|
231
|
+
it("returns matching FAQs", async () => {
|
|
232
|
+
const faq = makeFaqDoc({ showOnHomepage: true });
|
|
233
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: faq.id, data: faq }]));
|
|
234
|
+
const results = await repo.getHomepageFAQs();
|
|
235
|
+
expect(results).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// searchByTag
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
describe("FirebaseFAQsRepository.searchByTag", () => {
|
|
242
|
+
it("filters by array-contains on the tags field", async () => {
|
|
243
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
244
|
+
await repo.searchByTag("grading");
|
|
245
|
+
expect(mockCollection.where).toHaveBeenCalledWith(expect.stringContaining("tags"), "array-contains", "grading");
|
|
246
|
+
});
|
|
247
|
+
it("also filters isActive=true", async () => {
|
|
248
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
249
|
+
await repo.searchByTag("returns");
|
|
250
|
+
expect(mockQuery.where).toHaveBeenCalledWith(expect.stringContaining("isActive"), "==", true);
|
|
251
|
+
});
|
|
252
|
+
it("returns matching FAQs", async () => {
|
|
253
|
+
const faq = makeFaqDoc({ tags: ["grading"] });
|
|
254
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([{ id: faq.id, data: faq }]));
|
|
255
|
+
const results = await repo.searchByTag("grading");
|
|
256
|
+
expect(results).toHaveLength(1);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// getPinnedFAQs
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
describe("FirebaseFAQsRepository.getPinnedFAQs", () => {
|
|
263
|
+
it("queries isPinned=true AND isActive=true", async () => {
|
|
264
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
265
|
+
await repo.getPinnedFAQs();
|
|
266
|
+
expect(mockCollection.where).toHaveBeenCalledWith(expect.stringContaining("isPinned"), "==", true);
|
|
267
|
+
expect(mockQuery.where).toHaveBeenCalledWith(expect.stringContaining("isActive"), "==", true);
|
|
268
|
+
});
|
|
269
|
+
it("orders by order field ascending", async () => {
|
|
270
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
271
|
+
await repo.getPinnedFAQs();
|
|
272
|
+
expect(mockQuery.orderBy).toHaveBeenCalledWith(expect.stringContaining("order"), "asc");
|
|
273
|
+
});
|
|
274
|
+
it("filters by category when provided", async () => {
|
|
275
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
276
|
+
await repo.getPinnedFAQs("Shipping");
|
|
277
|
+
expect(mockQuery.where).toHaveBeenCalledWith(expect.stringContaining("category"), "==", "Shipping");
|
|
278
|
+
});
|
|
279
|
+
it("no category filter applied when category not provided", async () => {
|
|
280
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
281
|
+
await repo.getPinnedFAQs();
|
|
282
|
+
const whereCalls = mockQuery.where.mock.calls;
|
|
283
|
+
const categoryFilter = whereCalls.find((c) => c[0].includes("category"));
|
|
284
|
+
expect(categoryFilter).toBeUndefined();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// getMostHelpful
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
describe("FirebaseFAQsRepository.getMostHelpful", () => {
|
|
291
|
+
it("filters by isActive=true", async () => {
|
|
292
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
293
|
+
await repo.getMostHelpful();
|
|
294
|
+
expect(mockCollection.where).toHaveBeenCalledWith(expect.stringContaining("isActive"), "==", true);
|
|
295
|
+
});
|
|
296
|
+
it("orders by stats.helpful descending", async () => {
|
|
297
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
298
|
+
await repo.getMostHelpful();
|
|
299
|
+
expect(mockQuery.orderBy).toHaveBeenCalledWith("stats.helpful", "desc");
|
|
300
|
+
});
|
|
301
|
+
it("applies default limit of 10", async () => {
|
|
302
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
303
|
+
await repo.getMostHelpful();
|
|
304
|
+
expect(mockQuery.limit).toHaveBeenCalledWith(10);
|
|
305
|
+
});
|
|
306
|
+
it("applies custom limit when provided", async () => {
|
|
307
|
+
mockQuery.get.mockResolvedValue(makeQuerySnap([]));
|
|
308
|
+
await repo.getMostHelpful(5);
|
|
309
|
+
expect(mockQuery.limit).toHaveBeenCalledWith(5);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { makeMockDb, makeSnap } from "../../../../../tests/helpers/mock-firestore";
|
|
3
|
+
const { db, mockDocRef, mockTxn } = makeMockDb();
|
|
4
|
+
vi.mock("../../../../providers/db-firebase", () => ({
|
|
5
|
+
getAdminDb: () => db,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../../../../monitoring", () => ({
|
|
8
|
+
serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
9
|
+
}));
|
|
10
|
+
import { UserHistoryRepository } from "../user-history.repository";
|
|
11
|
+
import { HISTORY_MAX } from "../../../../constants/limits";
|
|
12
|
+
const repo = new UserHistoryRepository();
|
|
13
|
+
function makeHistoryItems(count, baseDate = new Date("2026-01-01")) {
|
|
14
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
15
|
+
productId: `product-${i}`,
|
|
16
|
+
productType: "product",
|
|
17
|
+
viewedAt: new Date(baseDate.getTime() - i * 1000),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
db.runTransaction.mockImplementation(async (fn) => fn(mockTxn));
|
|
23
|
+
mockDocRef.get.mockResolvedValue(makeSnap(null));
|
|
24
|
+
mockTxn.get.mockResolvedValue(makeSnap(null));
|
|
25
|
+
mockTxn.set.mockReturnValue(undefined);
|
|
26
|
+
});
|
|
27
|
+
describe("UserHistoryRepository.track", () => {
|
|
28
|
+
it("empty history → items has 1 entry, returns 1", async () => {
|
|
29
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
30
|
+
const count = await repo.track("u", { productId: "product-x", productType: "product" });
|
|
31
|
+
expect(count).toBe(1);
|
|
32
|
+
expect(mockTxn.set).toHaveBeenCalledOnce();
|
|
33
|
+
});
|
|
34
|
+
it("49 items + new productId → 50 items, new at position 0", async () => {
|
|
35
|
+
const items = makeHistoryItems(49);
|
|
36
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
37
|
+
const count = await repo.track("u", { productId: "product-new", productType: "product" });
|
|
38
|
+
expect(count).toBe(50);
|
|
39
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
40
|
+
expect(setCall.items[0].productId).toBe("product-new");
|
|
41
|
+
});
|
|
42
|
+
it("50 items + new productId → evicts last item, stays at 50 (FIFO)", async () => {
|
|
43
|
+
const items = makeHistoryItems(HISTORY_MAX);
|
|
44
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
45
|
+
const count = await repo.track("u", { productId: "product-new", productType: "product" });
|
|
46
|
+
expect(count).toBe(HISTORY_MAX);
|
|
47
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
48
|
+
expect(setCall.items).toHaveLength(HISTORY_MAX);
|
|
49
|
+
expect(setCall.items[0].productId).toBe("product-new");
|
|
50
|
+
// Last item from original list is evicted
|
|
51
|
+
expect(setCall.items[HISTORY_MAX - 1].productId).not.toBe(`product-${HISTORY_MAX - 1}`);
|
|
52
|
+
});
|
|
53
|
+
it("revisit existing productId → removes old, inserts at position 0, length unchanged", async () => {
|
|
54
|
+
const items = [
|
|
55
|
+
{ productId: "product-A", productType: "product", viewedAt: new Date("2026-01-03") },
|
|
56
|
+
{ productId: "product-B", productType: "product", viewedAt: new Date("2026-01-02") },
|
|
57
|
+
{ productId: "product-C", productType: "product", viewedAt: new Date("2026-01-01") },
|
|
58
|
+
];
|
|
59
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
60
|
+
const count = await repo.track("u", { productId: "product-C", productType: "product" });
|
|
61
|
+
expect(count).toBe(3);
|
|
62
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
63
|
+
expect(setCall.items[0].productId).toBe("product-C");
|
|
64
|
+
expect(setCall.items.filter((i) => i.productId === "product-C")).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
it("uses db.runTransaction", async () => {
|
|
67
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
68
|
+
await repo.track("u", { productId: "product-x", productType: "product" });
|
|
69
|
+
expect(db.runTransaction).toHaveBeenCalledOnce();
|
|
70
|
+
});
|
|
71
|
+
it("stores viewedAt as current timestamp when not provided", async () => {
|
|
72
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
73
|
+
const before = Date.now();
|
|
74
|
+
await repo.track("u", { productId: "product-x", productType: "product" });
|
|
75
|
+
const after = Date.now();
|
|
76
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
77
|
+
const ts = setCall.items[0].viewedAt.getTime();
|
|
78
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
79
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("UserHistoryRepository.merge", () => {
|
|
83
|
+
it("guest items merged into empty history → all items present", async () => {
|
|
84
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
85
|
+
const incoming = [
|
|
86
|
+
{ productId: "product-A", productType: "product" },
|
|
87
|
+
{ productId: "product-B", productType: "product" },
|
|
88
|
+
];
|
|
89
|
+
const count = await repo.merge("u", incoming);
|
|
90
|
+
expect(count).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
it("duplicate productId: keeps newer viewedAt, discards older", async () => {
|
|
93
|
+
const older = new Date("2026-01-01");
|
|
94
|
+
const newer = new Date("2026-01-10");
|
|
95
|
+
const existingItems = [{ productId: "product-A", productType: "product", viewedAt: older }];
|
|
96
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: existingItems, updatedAt: new Date() }));
|
|
97
|
+
await repo.merge("u", [{ productId: "product-A", productType: "product", viewedAt: newer }]);
|
|
98
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
99
|
+
expect(setCall.items).toHaveLength(1);
|
|
100
|
+
expect(setCall.items[0].viewedAt.getTime()).toBe(newer.getTime());
|
|
101
|
+
});
|
|
102
|
+
it("result trimmed to HISTORY_MAX (50 items)", async () => {
|
|
103
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
104
|
+
const incoming = Array.from({ length: 60 }, (_, i) => ({
|
|
105
|
+
productId: `product-${i}`,
|
|
106
|
+
productType: "product",
|
|
107
|
+
viewedAt: new Date(Date.now() - i * 1000),
|
|
108
|
+
}));
|
|
109
|
+
const count = await repo.merge("u", incoming);
|
|
110
|
+
expect(count).toBe(HISTORY_MAX);
|
|
111
|
+
});
|
|
112
|
+
it("items sorted descending by viewedAt after merge", async () => {
|
|
113
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
114
|
+
const incoming = [
|
|
115
|
+
{ productId: "product-old", productType: "product", viewedAt: new Date("2026-01-01") },
|
|
116
|
+
{ productId: "product-new", productType: "product", viewedAt: new Date("2026-01-10") },
|
|
117
|
+
];
|
|
118
|
+
await repo.merge("u", incoming);
|
|
119
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
120
|
+
expect(setCall.items[0].productId).toBe("product-new");
|
|
121
|
+
expect(setCall.items[1].productId).toBe("product-old");
|
|
122
|
+
});
|
|
123
|
+
it("uses db.runTransaction", async () => {
|
|
124
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items: [], updatedAt: new Date() }));
|
|
125
|
+
await repo.merge("u", []);
|
|
126
|
+
expect(db.runTransaction).toHaveBeenCalledOnce();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe("UserHistoryRepository.removeOne", () => {
|
|
130
|
+
it("removes specified productId from items", async () => {
|
|
131
|
+
const items = [
|
|
132
|
+
{ productId: "product-A", productType: "product", viewedAt: new Date() },
|
|
133
|
+
{ productId: "product-B", productType: "product", viewedAt: new Date() },
|
|
134
|
+
];
|
|
135
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
136
|
+
await repo.removeOne("u", "product-A");
|
|
137
|
+
const setCall = mockTxn.set.mock.calls[0][1];
|
|
138
|
+
expect(setCall.items).toHaveLength(1);
|
|
139
|
+
expect(setCall.items[0].productId).toBe("product-B");
|
|
140
|
+
});
|
|
141
|
+
it("non-existent productId → no-op, set not called", async () => {
|
|
142
|
+
const items = [{ productId: "product-A", productType: "product", viewedAt: new Date() }];
|
|
143
|
+
mockTxn.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
144
|
+
await repo.removeOne("u", "product-xxx");
|
|
145
|
+
expect(mockTxn.set).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("UserHistoryRepository.clearForUser", () => {
|
|
149
|
+
it("sets items: [] without deleting the document", async () => {
|
|
150
|
+
await repo.clearForUser("u");
|
|
151
|
+
expect(mockDocRef.set).toHaveBeenCalledWith(expect.objectContaining({ items: [] }));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe("UserHistoryRepository.getHistory", () => {
|
|
155
|
+
it("returns items array", async () => {
|
|
156
|
+
const items = [{ productId: "product-A", productType: "product", viewedAt: new Date() }];
|
|
157
|
+
mockDocRef.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
158
|
+
const result = await repo.getHistory("u");
|
|
159
|
+
expect(result).toHaveLength(1);
|
|
160
|
+
expect(result[0].productId).toBe("product-A");
|
|
161
|
+
});
|
|
162
|
+
it("returns empty array for user with no history document", async () => {
|
|
163
|
+
mockDocRef.get.mockResolvedValue(makeSnap(null));
|
|
164
|
+
const result = await repo.getHistory("u");
|
|
165
|
+
expect(result).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe("UserHistoryRepository.countByUser", () => {
|
|
169
|
+
it("returns items.length", async () => {
|
|
170
|
+
const items = makeHistoryItems(5);
|
|
171
|
+
mockDocRef.get.mockResolvedValue(makeSnap({ userId: "u", items, updatedAt: new Date() }));
|
|
172
|
+
const count = await repo.countByUser("u");
|
|
173
|
+
expect(count).toBe(5);
|
|
174
|
+
});
|
|
175
|
+
it("returns 0 for missing document", async () => {
|
|
176
|
+
mockDocRef.get.mockResolvedValue(makeSnap(null));
|
|
177
|
+
const count = await repo.countByUser("u");
|
|
178
|
+
expect(count).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -15,9 +15,9 @@ export declare function GET(_request: Request, context: {
|
|
|
15
15
|
}>;
|
|
16
16
|
}): Promise<NextResponse>;
|
|
17
17
|
export { GET as homepageSectionItemGET };
|
|
18
|
-
export declare const homepageSectionItemPATCH: (request: Request, context
|
|
18
|
+
export declare const homepageSectionItemPATCH: (request: Request, context?: {
|
|
19
19
|
params: Promise<Record<string, string>>;
|
|
20
|
-
}) => Promise<Response>;
|
|
21
|
-
export declare const homepageSectionItemDELETE: (request: Request, context
|
|
20
|
+
} | undefined) => Promise<Response>;
|
|
21
|
+
export declare const homepageSectionItemDELETE: (request: Request, context?: {
|
|
22
22
|
params: Promise<Record<string, string>>;
|
|
23
|
-
}) => Promise<Response>;
|
|
23
|
+
} | undefined) => Promise<Response>;
|
|
@@ -15,9 +15,9 @@ export declare function GET(_request: Request, context: {
|
|
|
15
15
|
}>;
|
|
16
16
|
}): Promise<NextResponse>;
|
|
17
17
|
export { GET as carouselItemGET };
|
|
18
|
-
export declare const carouselItemPATCH: (request: Request, context
|
|
18
|
+
export declare const carouselItemPATCH: (request: Request, context?: {
|
|
19
19
|
params: Promise<Record<string, string>>;
|
|
20
|
-
}) => Promise<Response>;
|
|
21
|
-
export declare const carouselItemDELETE: (request: Request, context
|
|
20
|
+
} | undefined) => Promise<Response>;
|
|
21
|
+
export declare const carouselItemDELETE: (request: Request, context?: {
|
|
22
22
|
params: Promise<Record<string, string>>;
|
|
23
|
-
}) => Promise<Response>;
|
|
23
|
+
} | undefined) => Promise<Response>;
|
|
@@ -10,6 +10,6 @@
|
|
|
10
10
|
import { NextResponse } from "next/server.js";
|
|
11
11
|
export declare function GET(request: Request): Promise<NextResponse>;
|
|
12
12
|
export { GET as carouselGET };
|
|
13
|
-
export declare const carouselPOST: (request: Request, context
|
|
13
|
+
export declare const carouselPOST: (request: Request, context?: {
|
|
14
14
|
params: Promise<Record<string, string>>;
|
|
15
|
-
}) => Promise<Response>;
|
|
15
|
+
} | undefined) => Promise<Response>;
|