@mohasinac/appkit 4.34.0 → 4.34.1

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.
@@ -16,8 +16,8 @@ const PRODUCT_SLUG = "slug";
16
16
  const PRODUCT_UPDATED_AT = "updatedAt";
17
17
  /**
18
18
  * Tester-sandbox fixtures must never reach the public sitemap. They are created
19
- * and destroyed on a cycle by `testerSandboxCleanup` / `testerSandboxRefresh`,
20
- * so anything Google indexes from them 404s shortly after. 34 of 182 sitemap
19
+ * and destroyed on a cycle by `testerSandboxCleanup` and by each tester run's
20
+ * wipe/seed, so anything Google indexes from them 404s shortly after. 34 of 182 sitemap
21
21
  * URLs were disposable test fixtures before this filter existed.
22
22
  *
23
23
  * 🛑 This MUST be an in-memory filter, never a Firestore inequality on the
@@ -22,9 +22,8 @@ export declare const cleanupRtdbEvents: import("./types").ScheduledFunctionDefin
22
22
  export declare const mediaTmpCleanup: import("./types").ScheduledFunctionDefinition;
23
23
  export declare const draftPrune: import("./types").ScheduledFunctionDefinition;
24
24
  export declare const testerSandboxCleanup: import("./types").ScheduledFunctionDefinition;
25
- export declare const testerSandboxRefresh: import("./types").ScheduledFunctionDefinition;
26
25
  export declare const prizeDrawExpiryReveal: import("./types").ScheduledFunctionDefinition;
27
26
  export declare const bundleStockSync: import("./types").ScheduledFunctionDefinition;
28
27
  export declare const emiInstallmentReminder: import("./types").ScheduledFunctionDefinition;
29
28
  export declare const catalogueImageStalenessReminder: import("./types").ScheduledFunctionDefinition;
30
- export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
29
+ export declare const SCHEDULED_FUNCTIONS: readonly [import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition, import("./types").ScheduledFunctionDefinition];
@@ -1,6 +1,6 @@
1
1
  // Scheduled function definitions (Cloud Scheduler triggers).
2
2
  // Cron syntax accepts both Firebase shorthand and standard cron expressions.
3
- import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, testerSandboxRefreshHandler, } from "../jobs/handlers";
3
+ import { auctionSettlementHandler, autoPayoutEligibilityHandler, bundleStockSyncHandler, cartPruneHandler, cleanupRtdbEventsHandler, countersReconcileHandler, couponExpiryHandler, dailyDataCleanupHandler, draftPruneHandler, emiInstallmentReminderHandler, catalogueImageStalenessReminderHandler, mediaTmpCleanupHandler, notificationPruneHandler, offerExpiryHandler, payoutBatchHandler, paymentWindowTimeoutHandler, hardBanReinstatementHandler, paymentReviewAutoApproveHandler, pendingOrderTimeoutHandler, positionsReconcileHandler, prizeDrawExpiryRevealHandler, productStatsSyncHandler, revenueRollupHandler, pageViewRollupHandler, pageViewPruneHandler, dailyStatusDigestHandler, weeklyPayoutEligibilityHandler, testerSandboxCleanupHandler, } from "../jobs/handlers";
4
4
  import { defineFunction } from "./define";
5
5
  const REGION = "asia-south1";
6
6
  const EVERY_5_MIN = "every 5 minutes";
@@ -182,13 +182,13 @@ export const testerSandboxCleanup = defineFunction({
182
182
  handler: testerSandboxCleanupHandler,
183
183
  options: { region: REGION, timeoutSeconds: 300, memory: "256MiB", maxInstances: 1 },
184
184
  });
185
- export const testerSandboxRefresh = defineFunction({
186
- name: "testerSandboxRefresh",
187
- description: "Revert live tester QA sandbox fixtures to their canonical seed shape and prune tester-created extras (every 4 hours).",
188
- trigger: { kind: "schedule", cron: "0 */4 * * *", timeZone: "UTC" },
189
- handler: testerSandboxRefreshHandler,
190
- options: { region: REGION, timeoutSeconds: 300, memory: "256MiB", maxInstances: 1 },
191
- });
185
+ // testerSandboxRefresh (every 4h: revert fixtures to seed shape, prune extras) was
186
+ // REMOVED. The tester runner now owns fixture state — it seeds a known catalog at the
187
+ // start of a run and wipes at the end — so a background job independently rewriting
188
+ // those same documents was redundant and, worse, raced a run in progress: it reset
189
+ // currentBid and recomputed auctionEndDate underneath a test that was mid-assertion.
190
+ // Do not reinstate it without also deciding who owns fixture state; two owners is the
191
+ // bug it caused. See tester/scripts/lifecycle.mjs.
192
192
  export const prizeDrawExpiryReveal = defineFunction({
193
193
  name: "prizeDrawExpiryReveal",
194
194
  description: "Assign winners for outstanding paid orders on scheduled-mode prize draws past their expiry, then close them (every 5 minutes).",
@@ -242,7 +242,6 @@ export const SCHEDULED_FUNCTIONS = [
242
242
  mediaTmpCleanup,
243
243
  draftPrune,
244
244
  testerSandboxCleanup,
245
- testerSandboxRefresh,
246
245
  prizeDrawExpiryReveal,
247
246
  bundleStockSync,
248
247
  emiInstallmentReminder,
@@ -1,4 +1,19 @@
1
1
  import type { JobContext } from "../runtime/types";
2
+ /**
3
+ * 🛑 THE definition of "sandbox scope" — six collections, and nothing else.
4
+ *
5
+ * Everything OUTSIDE this list is permanent: neither this job nor anything else
6
+ * reclaims it. That is why `users`, `addresses`, `sessions` and `siteSettings`
7
+ * survive a tester run untouched, and why the tester lifecycle's cascade delete
8
+ * (tester/scripts/lib/collections.mjs) mirrors this list rather than inventing
9
+ * its own — a second definition is how the two silently disagree.
10
+ *
11
+ * Adding a collection here makes it disposable. Do not add one without also
12
+ * adding a seed fixture for it, or a run will delete rows nothing recreates.
13
+ *
14
+ * (This used to be shared with testerSandboxRefresh.ts, which was removed when
15
+ * the tester runner took over fixture state — see scheduled.ts.)
16
+ */
2
17
  export declare const SANDBOX_COLLECTIONS: readonly ["categories", "stores", "products", "blogPosts", "events", "offers"];
3
18
  export interface TesterSandboxCleanupOptions {
4
19
  /** true = wipe every isTestData:true doc regardless of testDataExpiresAt (manual force-purge). */
@@ -1,11 +1,20 @@
1
1
  import { batchDelete, getTestDataRefs } from "../handlers/_helpers";
2
2
  import { BID_FIELDS } from "../../../../constants/field-names";
3
- // Exported so testerSandboxRefresh.ts (the every-4h revert+prune job) reuses
4
- // the exact same collection scope instead of redefining it — one source of
5
- // truth for "what counts as sandbox scope."
6
- // Keep in sync with testerSandboxRefresh.ts's SEED_BY_COLLECTION — the daily
7
- // TTL cleanup and the 4-hourly revert+prune must agree on what "sandbox" means
8
- // (CLAUDE.md § Tester QA standards #3).
3
+ /**
4
+ * 🛑 THE definition of "sandbox scope" — six collections, and nothing else.
5
+ *
6
+ * Everything OUTSIDE this list is permanent: neither this job nor anything else
7
+ * reclaims it. That is why `users`, `addresses`, `sessions` and `siteSettings`
8
+ * survive a tester run untouched, and why the tester lifecycle's cascade delete
9
+ * (tester/scripts/lib/collections.mjs) mirrors this list rather than inventing
10
+ * its own — a second definition is how the two silently disagree.
11
+ *
12
+ * Adding a collection here makes it disposable. Do not add one without also
13
+ * adding a seed fixture for it, or a run will delete rows nothing recreates.
14
+ *
15
+ * (This used to be shared with testerSandboxRefresh.ts, which was removed when
16
+ * the tester runner took over fixture state — see scheduled.ts.)
17
+ */
9
18
  export const SANDBOX_COLLECTIONS = ["categories", "stores", "products", "blogPosts", "events", "offers"];
10
19
  const BID_CHUNK_SIZE = 30; // Firestore `in` query cap
11
20
  export async function runTesterSandboxCleanup(ctx, opts = {}) {
@@ -54,7 +54,6 @@ export { onScamReportCreateHandler } from "./onScamReportCreate";
54
54
  export { onScamReportUpdateHandler } from "./onScamReportUpdate";
55
55
  export { draftPruneHandler } from "./draftPrune";
56
56
  export { testerSandboxCleanupHandler } from "./testerSandboxCleanup";
57
- export { testerSandboxRefreshHandler } from "./testerSandboxRefresh";
58
57
  export { emiInstallmentReminderHandler } from "./emiInstallmentReminder";
59
58
  export { onShipmentItemWriteHandler } from "./onShipmentItemWrite";
60
59
  export { onShipmentLotWriteHandler, onShipmentHeaderWriteHandler } from "./onShipmentAllocationSync";
@@ -67,7 +67,6 @@ export { draftPruneHandler } from "./draftPrune";
67
67
  // Tester program — 7-day auto-expiry sweep for the shared admin-seeded test sandbox
68
68
  export { testerSandboxCleanupHandler } from "./testerSandboxCleanup";
69
69
  // Tester program — every-4h revert-live-edits + prune-extras refresh (faster than the 7-day sweep above)
70
- export { testerSandboxRefreshHandler } from "./testerSandboxRefresh";
71
70
  // EMI — installment reminder + overdue-flagging sweep
72
71
  export { emiInstallmentReminderHandler } from "./emiInstallmentReminder";
73
72
  // Procurement Shipments — 3-function recompute cascade (item→lot, lot/header→shipment allocation, cascade-delete)
@@ -69,6 +69,7 @@ export interface UserDocument extends BaseDocument {
69
69
  storeStatus?: "pending" | "approved" | "rejected";
70
70
  isTester?: boolean;
71
71
  canTestAdmin?: boolean;
72
+ isBot?: boolean;
72
73
  publicProfile?: {
73
74
  isPublic: boolean;
74
75
  showEmail: boolean;
@@ -15,9 +15,24 @@ export declare class TesterChecklistItemRepository extends BaseRepository<Tester
15
15
  /** Reopens a fixed, bug-confirmed case as a new, active version for retest.
16
16
  * The old item stays disabled forever with its bug-hunter credit intact. */
17
17
  reopenAsNewVersion(oldItemId: string): Promise<TesterChecklistItemDocument>;
18
+ /**
19
+ * Ids of automated (non-human) accounts, excluded from the PUBLIC leaderboard.
20
+ *
21
+ * Read as a bare collection query rather than through `userRepository` on purpose:
22
+ * no repository in this codebase imports another, and this needs a key set, not
23
+ * user documents. `.select()` with no fields returns refs only, so the read is as
24
+ * cheap as Firestore allows, and `isBot == true` is a single-field equality served
25
+ * by the automatic index — no composite index to declare.
26
+ */
27
+ private botHunterIds;
18
28
  /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
19
29
  * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
20
- * superseded items, since bug credit is permanent. */
30
+ * superseded items, since bug credit is permanent.
31
+ *
32
+ * Bot accounts are aggregated out (not merely ranked last): the board exists to
33
+ * credit people, and a runner working all 943 cases would otherwise dominate it.
34
+ * The credit still lives on the item, so admin triage and the item's own
35
+ * `bugHunterName` are unaffected. */
21
36
  getBugHunterLeaderboard(limit?: number): Promise<BugHunterLeaderboardEntry[]>;
22
37
  }
23
38
  export declare const testerChecklistItemRepository: TesterChecklistItemRepository;
@@ -1,6 +1,7 @@
1
1
  import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
2
2
  import { buildSearchTxt } from "../../../utils/search-txt";
3
3
  import { DatabaseError } from "../../../errors";
4
+ import { USER_COLLECTION } from "../../auth/schemas/firestore";
4
5
  import { TESTER_CHECKLIST_ITEM_COLLECTION, TESTER_CHECKLIST_ITEM_FIELDS, createChecklistItemId, } from "../schemas/firestore";
5
6
  export class TesterChecklistItemRepository extends BaseRepository {
6
7
  constructor() {
@@ -86,18 +87,43 @@ export class TesterChecklistItemRepository extends BaseRepository {
86
87
  await this.update(old.id, { supersededByItemId: newId });
87
88
  return newItem;
88
89
  }
90
+ /**
91
+ * Ids of automated (non-human) accounts, excluded from the PUBLIC leaderboard.
92
+ *
93
+ * Read as a bare collection query rather than through `userRepository` on purpose:
94
+ * no repository in this codebase imports another, and this needs a key set, not
95
+ * user documents. `.select()` with no fields returns refs only, so the read is as
96
+ * cheap as Firestore allows, and `isBot == true` is a single-field equality served
97
+ * by the automatic index — no composite index to declare.
98
+ */
99
+ async botHunterIds() {
100
+ const snapshot = await this.db
101
+ .collection(USER_COLLECTION)
102
+ .where("isBot", "==", true)
103
+ .select()
104
+ .get();
105
+ return new Set(snapshot.docs.map((d) => d.id));
106
+ }
89
107
  /** Single-query, in-memory aggregation of bug credits per hunter — mirrors
90
108
  * EventEntryRepository.getLeaderboard()'s shape. Includes old/disabled/
91
- * superseded items, since bug credit is permanent. */
109
+ * superseded items, since bug credit is permanent.
110
+ *
111
+ * Bot accounts are aggregated out (not merely ranked last): the board exists to
112
+ * credit people, and a runner working all 943 cases would otherwise dominate it.
113
+ * The credit still lives on the item, so admin triage and the item's own
114
+ * `bugHunterName` are unaffected. */
92
115
  async getBugHunterLeaderboard(limit = 50) {
93
- const snapshot = await this.db
94
- .collection(this.collection)
95
- .where(TESTER_CHECKLIST_ITEM_FIELDS.BUG_CONFIRMED, "==", true)
96
- .get();
116
+ const [snapshot, botIds] = await Promise.all([
117
+ this.db
118
+ .collection(this.collection)
119
+ .where(TESTER_CHECKLIST_ITEM_FIELDS.BUG_CONFIRMED, "==", true)
120
+ .get(),
121
+ this.botHunterIds(),
122
+ ]);
97
123
  const byHunter = new Map();
98
124
  for (const doc of snapshot.docs) {
99
125
  const item = this.mapDoc(doc);
100
- if (!item.bugHunterId)
126
+ if (!item.bugHunterId || botIds.has(item.bugHunterId))
101
127
  continue;
102
128
  const entry = byHunter.get(item.bugHunterId) ?? {
103
129
  name: item.bugHunterName ?? "Unknown tester",
@@ -1,11 +1,17 @@
1
1
  /*
2
- * WHY: Shared tester sandbox — 1 disposable test event with both raffle and spin-wheel
3
- * fields populated, so testers can exercise the events/raffle/spin flow end to end.
4
- * Auto-expires after 7 days (testerSandboxCleanup).
5
- * WHAT: Exports eventsTesterSeedData — 1 Partial<EventDocument> tagged isTestData:true.
2
+ * WHY: Shared tester sandbox — disposable test events covering spin-wheel, both raffle
3
+ * variants, a coupon offer, and a lottery with a pre-booked slot, so testers can
4
+ * exercise each flow end to end.
5
+ * WHAT: Exports eventsTesterSeedData — 5 Partial<EventDocument> tagged isTestData:true.
6
+ *
7
+ * The lottery's draw window is expressed via tester-window.ts rather than a fixed
8
+ * duration, so an automated run can shorten it and still watch the window close.
9
+ * The other four stay long-lived on purpose: they are meant to read as ACTIVE
10
+ * events for a human tester who seeds today and returns tomorrow. A fixture that
11
+ * must expire mid-session should be added as its own row, not by re-timing these.
6
12
  *
7
13
  * EXPORTS:
8
- * eventsTesterSeedData — Array of 1 Partial<EventDocument> for the seed runner
14
+ * eventsTesterSeedData — Array of 5 Partial<EventDocument> for the seed runner
9
15
  *
10
16
  * @tag domain:events,tester
11
17
  * @tag layer:seed
@@ -17,6 +23,7 @@
17
23
  import { EVENT_FIELDS } from "../../../constants/field-names";
18
24
  import { seedPhoto } from "../../../seed/_helpers/media";
19
25
  import { testDataExpiresAt } from "./tester-ttl";
26
+ import { windowMinutes, windowOffset } from "./tester-window";
20
27
  const TEST_EVENT_COVER_ALT = "Test event cover";
21
28
  const TEST_RAFFLE_PRIZE = "Test prize — not a real reward";
22
29
  export const eventsTesterSeedData = [
@@ -118,4 +125,69 @@ export const eventsTesterSeedData = [
118
125
  createdAt: new Date(),
119
126
  updatedAt: new Date(),
120
127
  },
128
+ // ── Lottery, with one slot ALREADY BOOKED ───────────────────────────────────
129
+ // This fixture exists for exactly two checklist cases, both marked 🛑:
130
+ // page-wiring/data-loss/lottery-edit-preserves-bookings
131
+ // page-wiring/data-loss/lottery-booked-slot-cannot-be-deleted
132
+ // Neither could be tested before, because no lottery fixture existed anywhere —
133
+ // lottery events could previously only come from a hand-run seed.
134
+ //
135
+ // 🛑 Slot 3's booking IS the fixture. A booked slot carries a buyer id, a display
136
+ // name and a lottery number, and the bug being guarded silently cleared all three
137
+ // while returning a success message. Do not "tidy" this into an all-unbooked
138
+ // lottery — an unbooked slot cannot demonstrate the loss, and the case would then
139
+ // pass against the very bug it exists to catch.
140
+ //
141
+ // price/weight are server-only (toClientLotterySlot strips them). Uniform pricing
142
+ // means every slot is weight 50 by definition — see computeWeight().
143
+ {
144
+ id: "event-tester-sandbox-lottery",
145
+ slug: "tester-sandbox-lottery",
146
+ type: EVENT_FIELDS.TYPE_VALUES.LOTTERY,
147
+ title: "Tester Sandbox — Lottery",
148
+ description: "Disposable test lottery for the tester QA program. Slot 3 is pre-booked: edit an unrelated slot, save, reopen, and confirm slot 3 is STILL booked to the same buyer with the same lottery number.",
149
+ status: EVENT_FIELDS.STATUS_VALUES.ACTIVE,
150
+ startsAt: new Date(),
151
+ endsAt: windowOffset(1),
152
+ coverImage: {
153
+ type: "image",
154
+ url: seedPhoto("event-cover-tester-sandbox-lottery-20260101", 1200, 600),
155
+ alt: TEST_EVENT_COVER_ALT,
156
+ },
157
+ tags: ["test"],
158
+ lotteryConfig: {
159
+ totalSlots: 6,
160
+ pricingMode: "uniform",
161
+ uniformPrice: 100,
162
+ // Must outlast the run, or late batches meet a closed lottery. windowOffset(1)
163
+ // is the run's end, so the draw window is expressed in the same units.
164
+ drawWindowDurationMinutes: Math.ceil(windowMinutes()),
165
+ maxPullsPerTransaction: 1,
166
+ maxPullsPerUser: 1,
167
+ slots: [
168
+ { slotNumber: 1, name: "Test Prize — Dragoon Storm", price: 100, weight: 50, isBooked: false, image: seedPhoto("lottery-slot-tester-1-20260101", 600, 600) },
169
+ { slotNumber: 2, name: "Test Prize — Dranzer S", price: 100, weight: 50, isBooked: false, image: seedPhoto("lottery-slot-tester-2-20260101", 600, 600) },
170
+ {
171
+ slotNumber: 3,
172
+ name: "Test Prize — Draciel Shield",
173
+ price: 100,
174
+ weight: 50,
175
+ isBooked: true,
176
+ bookedByUserId: "user-ash-trainer",
177
+ bookedByDisplayName: "Mock User 4",
178
+ bookedByUserLotteryNumber: 1,
179
+ image: seedPhoto("lottery-slot-tester-3-20260101", 600, 600),
180
+ },
181
+ { slotNumber: 4, name: "Test Prize — Driger F", price: 100, weight: 50, isBooked: false },
182
+ { slotNumber: 5, name: "Test Prize — Wolborg", price: 100, weight: 50, isBooked: false },
183
+ { slotNumber: 6, name: "Test Prize — Trygle", price: 100, weight: 50, isBooked: false },
184
+ ],
185
+ },
186
+ stats: { totalEntries: 1, approvedEntries: 1, flaggedEntries: 0 },
187
+ createdBy: "user-admin-letitrip",
188
+ isTestData: true,
189
+ testDataExpiresAt: testDataExpiresAt(),
190
+ createdAt: new Date(),
191
+ updatedAt: new Date(),
192
+ },
121
193
  ];
@@ -11,7 +11,7 @@
11
11
  * drive it straight through the Offers checkout lane.
12
12
  *
13
13
  * Every date is Date.now()-relative (per CLAUDE.md's tester-fixture standards)
14
- * so the 4-hour testerSandboxRefresh re-arms it instead of letting it go stale.
14
+ * so each seed run re-arms it instead of letting it go stale.
15
15
  *
16
16
  * EXPORTS:
17
17
  * offersTesterSeedData — Array of 1 Partial<OfferDocument> for the seed runner
@@ -25,9 +25,23 @@
25
25
  */
26
26
  import { OfferStatusValues } from "../../seller/schemas/firestore";
27
27
  import { testDataExpiresAt } from "./tester-ttl";
28
+ import { windowOffset } from "./tester-window";
28
29
  const NOW = new Date();
29
30
  const hoursAgo = (n) => new Date(NOW.getTime() - n * 3600000);
30
- const hoursFromNow = (n) => new Date(NOW.getTime() + n * 3600000);
31
+ /*
32
+ * Deadlines expressed as fractions of the testing window rather than fixed hours.
33
+ *
34
+ * "An accepted offer past its checkoutDeadline cannot be checked out" is a real
35
+ * checklist case, and a fixed 36-hour deadline makes it untestable in any session
36
+ * shorter than a day and a half. Fractions scale with TESTER_WINDOW_MINUTES, so the
37
+ * lapse actually happens while someone is watching.
38
+ *
39
+ * OUTLASTS_RUN is deliberately greater than 1: the "still live" offers must NOT
40
+ * lapse mid-run, or every other offer case starts failing for the wrong reason.
41
+ */
42
+ const OUTLASTS_RUN = 2;
43
+ const LAPSES_MID_RUN = 1 / 3;
44
+ const CHECKOUT_LAPSES_MID_RUN = 1 / 6;
31
45
  export const offersTesterSeedData = [
32
46
  {
33
47
  id: "offer-tester-sandbox-accepted",
@@ -52,11 +66,11 @@ export const offersTesterSeedData = [
52
66
  status: OfferStatusValues.ACCEPTED,
53
67
  buyerNote: "Sandbox offer — already accepted so testers can drive checkout.",
54
68
  sellerNote: "Accepted for QA sandbox testing.",
55
- expiresAt: hoursFromNow(24),
69
+ expiresAt: windowOffset(OUTLASTS_RUN),
56
70
  acceptedAt: hoursAgo(1),
57
71
  respondedAt: hoursAgo(1),
58
72
  // Well inside the 48h window so the lane is live for a whole test session.
59
- checkoutDeadline: hoursFromNow(36),
73
+ checkoutDeadline: windowOffset(OUTLASTS_RUN),
60
74
  createdAt: hoursAgo(2),
61
75
  updatedAt: hoursAgo(1),
62
76
  isTestData: true,
@@ -83,7 +97,7 @@ export const offersTesterSeedData = [
83
97
  status: OfferStatusValues.COUNTERED,
84
98
  buyerNote: "Sandbox offer — seller has countered, accept it to test the counter path.",
85
99
  sellerNote: "Counter: ₹1,150.",
86
- expiresAt: hoursFromNow(24),
100
+ expiresAt: windowOffset(OUTLASTS_RUN),
87
101
  respondedAt: hoursAgo(1),
88
102
  createdAt: hoursAgo(2),
89
103
  updatedAt: hoursAgo(1),
@@ -112,10 +126,10 @@ export const offersTesterSeedData = [
112
126
  status: OfferStatusValues.ACCEPTED,
113
127
  buyerNote: "Sandbox offer — accepted with a short checkout window.",
114
128
  sellerNote: "Accepted. Pay soon!",
115
- expiresAt: hoursFromNow(1),
129
+ expiresAt: windowOffset(LAPSES_MID_RUN),
116
130
  acceptedAt: hoursAgo(0),
117
131
  respondedAt: hoursAgo(0),
118
- checkoutDeadline: new Date(NOW.getTime() + 30 * 60000),
132
+ checkoutDeadline: windowOffset(CHECKOUT_LAPSES_MID_RUN),
119
133
  createdAt: hoursAgo(1),
120
134
  updatedAt: hoursAgo(0),
121
135
  isTestData: true,
@@ -141,7 +155,7 @@ export const offersTesterSeedData = [
141
155
  currency: "INR",
142
156
  status: OfferStatusValues.PENDING,
143
157
  buyerNote: "Sandbox offer — still pending, withdraw it to test that path.",
144
- expiresAt: hoursFromNow(24),
158
+ expiresAt: windowOffset(OUTLASTS_RUN),
145
159
  createdAt: hoursAgo(1),
146
160
  updatedAt: hoursAgo(1),
147
161
  isTestData: true,
@@ -168,7 +182,7 @@ export const offersTesterSeedData = [
168
182
  currency: "INR",
169
183
  status: OfferStatusValues.PENDING,
170
184
  buyerNote: "Sandbox inbound offer — accept, counter, or decline it from your store dashboard.",
171
- expiresAt: hoursFromNow(24),
185
+ expiresAt: windowOffset(OUTLASTS_RUN),
172
186
  createdAt: hoursAgo(2),
173
187
  updatedAt: hoursAgo(2),
174
188
  isTestData: true,
@@ -5,12 +5,16 @@
5
5
  * pre-ordering against real code paths — including navigating every entry of the store
6
6
  * detail page's listing-type dropdown (see StoreNavTabs). Auto-expires after 7 days
7
7
  * (testerSandboxCleanup cascades into any bids on the auction).
8
- * WHAT: Exports productsTesterSeedData — 2 standard + 4 auction (3 staggered 1h/2h/3h "still
9
- * open" cycling fixtures + 1 already-ended "won" fixture) + 1 pre-order + 1 prize-draw +
8
+ * WHAT: Exports productsTesterSeedData — 2 standard + 4 auction (3 staggered "still open"
9
+ * cycling fixtures + 1 already-ended "won" fixture) + 1 pre-order + 1 prize-draw +
10
10
  * 1 classified + 1 digital-code + 1 live + 1 art + 1 stickers product + 1 product-group
11
11
  * ("Set") parent (product-tester-standard-1/2 are its children, linked via groupId).
12
- * The 3 staggered auction ids/end-dates are recomputed on every seed run (including the
13
- * testerSandboxRefresh job, every 4h) so testers can watch one actually end mid-session.
12
+ *
13
+ * Every auction end date is recomputed on each seed run from tester-window.ts, as a
14
+ * FRACTION of the testing window rather than a fixed duration. At the default
15
+ * 180-minute window the staggered three land on the historical 1h/2h/3h; a shorter
16
+ * TESTER_WINDOW_MINUTES pulls all of them in together, which is what lets a run
17
+ * actually watch an auction cross its end time.
14
18
  *
15
19
  * EXPORTS:
16
20
  * productsTesterSeedData — Array of Partial<ProductDocument> for the seed runner
@@ -26,6 +30,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names";
26
30
  import { buildSearchTxt } from "../../../utils/search-txt";
27
31
  import { seedPhoto } from "../../../seed/_helpers/media";
28
32
  import { testDataExpiresAt } from "./tester-ttl";
33
+ import { windowOffset } from "./tester-window";
29
34
  const TESTER_STORE_ID = "store-tester-sandbox";
30
35
  const TESTER_STORE_NAME = "Tester Sandbox Store";
31
36
  const TESTER_GROUP_TITLE = "Tester Sandbox Bundle";
@@ -45,44 +50,56 @@ function withTokens(p) {
45
50
  searchTxt: buildSearchTxt([p.title, p.description, p.brand, p.brandSlug, p.categoryNames, p.tags]),
46
51
  };
47
52
  }
48
- // Staggered, incrementing-id auction fixtures so testers can watch a live
49
- // auction actually cross its end time within a single sandbox refresh cycle
50
- // (testerSandboxRefresh re-imports this module every 4 hours, recomputing
51
- // these Date.now()-relative offsets fresh each time) instead of the old
52
- // single fixture that always sat 5 days out. A loop (not hand-typed ids)
53
- // avoids id collisions if the stagger list is ever extended.
54
- const AUCTION_CYCLE_STAGGER_HOURS = [1, 2, 3];
55
- const stagedAuctions = AUCTION_CYCLE_STAGGER_HOURS.map((hours, i) => withTokens({
56
- id: `auction-tester-sandbox-cycle-${i + 1}`,
57
- slug: `auction-tester-sandbox-cycle-${i + 1}`,
58
- title: `Test Auction — Ends in ~${hours}h`,
59
- description: `Disposable test auction for the tester QA program, staggered to end roughly ${hours} hour(s) after the last sandbox refresh so testers can watch the end-of-auction flow live. Place a bid — it auto-expires in 7 days, cascading to any bids.`,
60
- categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
61
- categoryNames: COLLECTIBLES_CATEGORY_NAMES,
62
- brandSlug: "brand-tester-sandbox",
63
- brand: "TestBrand",
64
- startingBid: 15000,
65
- currentBid: 15000,
66
- currency: "INR",
67
- price: 150,
68
- stockQuantity: 1,
69
- availableQuantity: 1,
70
- auctionEndDate: new Date(Date.now() + hours * 60 * 60 * 1000),
71
- bidCount: 0,
72
- bidsHaveStarted: false,
73
- isSold: false,
74
- mainImage: seedPhoto(`auction-image-tester-sandbox-cycle-${i + 1}-20260101`, 900, 900),
75
- images: [seedPhoto(`auction-image-tester-sandbox-cycle-${i + 1}-20260101`, 900, 900)],
76
- status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
77
- condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
78
- listingType: "auction",
79
- customFields: [],
80
- customSections: [],
81
- isPromoted: false,
82
- isOnSale: false,
83
- createdAt: new Date(),
84
- updatedAt: new Date(),
85
- }));
53
+ // Staggered, incrementing-id auction fixtures so a tester can watch a live auction
54
+ // actually cross its end time inside one session, instead of the old single fixture
55
+ // that always sat 5 days out. A loop (not hand-typed ids) avoids id collisions if
56
+ // the stagger list is ever extended.
57
+ //
58
+ // Expressed as FRACTIONS of the testing window rather than fixed hours: with the
59
+ // default 180-minute window these resolve to the same 1h/2h/3h this fixture has
60
+ // always used, and an automated run shortens all three together by setting
61
+ // TESTER_WINDOW_MINUTES. Hard-coded hours cannot be shortened, which is what made
62
+ // "watch an auction end" untestable in any run shorter than three hours.
63
+ const AUCTION_CYCLE_STAGGER_FRACTIONS = [1 / 3, 2 / 3, 1];
64
+ /** Deliberately past the end of the run — "plenty of clock left" fixtures must NOT expire mid-test. */
65
+ const OUTLASTS_RUN = 2;
66
+ /** ~1/9 of the window: 20 minutes at the default 180, 10 at a 90-minute run. Scales with the window instead of pinning 20 minutes that a short run can never reach. */
67
+ const EXPIRES_MID_RUN = 1 / 9;
68
+ const stagedAuctions = AUCTION_CYCLE_STAGGER_FRACTIONS.map((fraction, i) => {
69
+ const endsAt = windowOffset(fraction);
70
+ const endsInMinutes = Math.max(1, Math.round((endsAt.getTime() - Date.now()) / 60000));
71
+ return withTokens({
72
+ id: `auction-tester-sandbox-cycle-${i + 1}`,
73
+ slug: `auction-tester-sandbox-cycle-${i + 1}`,
74
+ title: `Test Auction — Ends in ~${endsInMinutes}m`,
75
+ description: `Disposable test auction for the tester QA program, staggered to end roughly ${endsInMinutes} minute(s) after seeding so testers can watch the end-of-auction flow live. Place a bid — it auto-expires in 7 days, cascading to any bids.`,
76
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
77
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
78
+ brandSlug: "brand-tester-sandbox",
79
+ brand: "TestBrand",
80
+ startingBid: 15000,
81
+ currentBid: 15000,
82
+ currency: "INR",
83
+ price: 150,
84
+ stockQuantity: 1,
85
+ availableQuantity: 1,
86
+ auctionEndDate: endsAt,
87
+ bidCount: 0,
88
+ bidsHaveStarted: false,
89
+ isSold: false,
90
+ mainImage: seedPhoto(`auction-image-tester-sandbox-cycle-${i + 1}-20260101`, 900, 900),
91
+ images: [seedPhoto(`auction-image-tester-sandbox-cycle-${i + 1}-20260101`, 900, 900)],
92
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
93
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
94
+ listingType: "auction",
95
+ customFields: [],
96
+ customSections: [],
97
+ isPromoted: false,
98
+ isOnSale: false,
99
+ createdAt: new Date(),
100
+ updatedAt: new Date(),
101
+ });
102
+ });
86
103
  /**
87
104
  * Buy-Now (buyout) fixtures. Four states, because the 2026-08-24 rework made
88
105
  * buyout availability depend on `buyNowPrice > currentBid` rather than on
@@ -90,7 +107,7 @@ const stagedAuctions = AUCTION_CYCLE_STAGGER_HOURS.map((hours, i) => withTokens(
90
107
  * three of them were previously unreachable as test data.
91
108
  *
92
109
  * Every date is Date.now()-relative at module import, per CLAUDE.md's tester
93
- * fixture standard, so the 4-hour testerSandboxRefresh re-arms them.
110
+ * fixture standard, so each seed run re-arms them; durations come from tester-window.ts.
94
111
  */
95
112
  const buyoutAuctions = [
96
113
  {
@@ -101,7 +118,7 @@ const buyoutAuctions = [
101
118
  buyNowPrice: 12000,
102
119
  bidCount: 0,
103
120
  bidsHaveStarted: false,
104
- endsInHours: 6,
121
+ endsAtFraction: OUTLASTS_RUN,
105
122
  },
106
123
  {
107
124
  key: "buyout-contested",
@@ -111,7 +128,7 @@ const buyoutAuctions = [
111
128
  buyNowPrice: 12000,
112
129
  bidCount: 2,
113
130
  bidsHaveStarted: true,
114
- endsInHours: 6,
131
+ endsAtFraction: OUTLASTS_RUN,
115
132
  },
116
133
  {
117
134
  key: "buyout-passed",
@@ -121,7 +138,7 @@ const buyoutAuctions = [
121
138
  buyNowPrice: 9000,
122
139
  bidCount: 5,
123
140
  bidsHaveStarted: true,
124
- endsInHours: 6,
141
+ endsAtFraction: OUTLASTS_RUN,
125
142
  },
126
143
  {
127
144
  key: "buyout-expiring",
@@ -131,7 +148,7 @@ const buyoutAuctions = [
131
148
  buyNowPrice: 5000,
132
149
  bidCount: 1,
133
150
  bidsHaveStarted: true,
134
- endsInMinutes: 20,
151
+ endsAtFraction: EXPIRES_MID_RUN,
135
152
  },
136
153
  ].map((f) => withTokens({
137
154
  id: `auction-tester-sandbox-${f.key}`,
@@ -149,8 +166,7 @@ const buyoutAuctions = [
149
166
  price: f.currentBid,
150
167
  stockQuantity: 1,
151
168
  availableQuantity: 1,
152
- auctionEndDate: new Date(Date.now() +
153
- (f.endsInMinutes ? f.endsInMinutes * 60 * 1000 : (f.endsInHours ?? 6) * 60 * 60 * 1000)),
169
+ auctionEndDate: windowOffset(f.endsAtFraction),
154
170
  bidCount: f.bidCount,
155
171
  bidsHaveStarted: f.bidsHaveStarted,
156
172
  isSold: false,
@@ -0,0 +1,25 @@
1
+ /** Reproduces the historical 1h/2h/3h auction stagger when nothing overrides it. */
2
+ export declare const DEFAULT_TESTER_WINDOW_MINUTES = 180;
3
+ /** Below this nothing is observable — a page load alone can outlast it. */
4
+ export declare const MIN_TESTER_WINDOW_MINUTES = 5;
5
+ /** The sandbox TTL is 7 days; a window longer than that outlives its own fixtures. */
6
+ export declare const MAX_TESTER_WINDOW_MINUTES: number;
7
+ /**
8
+ * Length of the testing window, in minutes.
9
+ *
10
+ * Read at call time rather than captured at module scope so a caller that sets the
11
+ * env var before importing a seed file still wins. Anything unparseable, non-finite
12
+ * or out of range falls back to the default rather than throwing: a malformed env
13
+ * var must not be able to brick `appkit-seed load`.
14
+ */
15
+ export declare function windowMinutes(): number;
16
+ /**
17
+ * A point `fraction` of the way through the window, measured from now.
18
+ *
19
+ * `windowOffset(0)` is now, `windowOffset(1)` is the window's end. Fractions above 1
20
+ * are allowed and mean "after this run" — that is how a fixture says "still open when
21
+ * testing finishes" without hard-coding a duration.
22
+ */
23
+ export declare function windowOffset(fraction: number): Date;
24
+ /** The mirror of windowOffset, into the past — for fixtures that must already be closed. */
25
+ export declare function windowAgo(fraction: number): Date;
@@ -0,0 +1,66 @@
1
+ /*
2
+ * WHY: Every time-bound tester fixture — auction end dates, coupon validity, event
3
+ * start/end, prize-draw reveal windows, offer expiry, lottery draw windows —
4
+ * must open and close INSIDE one testing session. Hand-written offsets like
5
+ * `Date.now() + 2 * 60 * 60 * 1000` cannot do that: they bake in a duration
6
+ * nobody can shorten, so a case reading "watch the auction actually end" is
7
+ * untestable without sitting there for two hours.
8
+ *
9
+ * This file is the one knob. Fixtures express WHEN as a fraction of the run
10
+ * window; the window's real length comes from TESTER_WINDOW_MINUTES.
11
+ *
12
+ * WHAT: windowMinutes() / windowOffset(fraction) / windowAgo(fraction).
13
+ *
14
+ * 🛑 The default is 180 minutes ON PURPOSE — it reproduces the pre-existing 1h/2h/3h
15
+ * auction stagger exactly (fractions 1/3, 2/3, 1), so an ordinary `appkit-seed load`
16
+ * behaves as it always has. Only the automated runner shortens it. Changing this
17
+ * default silently re-times every fixture for human testers too.
18
+ *
19
+ * @tag domain:tester
20
+ * @tag layer:seed
21
+ * @tag pattern:none
22
+ * @tag access:server-only
23
+ * @tag consumers:seed-data/*-tester-seed-data.ts
24
+ * @tag sideEffects:none
25
+ */
26
+ /** Reproduces the historical 1h/2h/3h auction stagger when nothing overrides it. */
27
+ export const DEFAULT_TESTER_WINDOW_MINUTES = 180;
28
+ /** Below this nothing is observable — a page load alone can outlast it. */
29
+ export const MIN_TESTER_WINDOW_MINUTES = 5;
30
+ /** The sandbox TTL is 7 days; a window longer than that outlives its own fixtures. */
31
+ export const MAX_TESTER_WINDOW_MINUTES = 7 * 24 * 60;
32
+ /**
33
+ * Length of the testing window, in minutes.
34
+ *
35
+ * Read at call time rather than captured at module scope so a caller that sets the
36
+ * env var before importing a seed file still wins. Anything unparseable, non-finite
37
+ * or out of range falls back to the default rather than throwing: a malformed env
38
+ * var must not be able to brick `appkit-seed load`.
39
+ */
40
+ export function windowMinutes() {
41
+ const raw = process.env.TESTER_WINDOW_MINUTES;
42
+ if (!raw)
43
+ return DEFAULT_TESTER_WINDOW_MINUTES;
44
+ const parsed = Number(raw);
45
+ if (!Number.isFinite(parsed))
46
+ return DEFAULT_TESTER_WINDOW_MINUTES;
47
+ if (parsed < MIN_TESTER_WINDOW_MINUTES)
48
+ return MIN_TESTER_WINDOW_MINUTES;
49
+ if (parsed > MAX_TESTER_WINDOW_MINUTES)
50
+ return MAX_TESTER_WINDOW_MINUTES;
51
+ return parsed;
52
+ }
53
+ /**
54
+ * A point `fraction` of the way through the window, measured from now.
55
+ *
56
+ * `windowOffset(0)` is now, `windowOffset(1)` is the window's end. Fractions above 1
57
+ * are allowed and mean "after this run" — that is how a fixture says "still open when
58
+ * testing finishes" without hard-coding a duration.
59
+ */
60
+ export function windowOffset(fraction) {
61
+ return new Date(Date.now() + fraction * windowMinutes() * 60000);
62
+ }
63
+ /** The mirror of windowOffset, into the past — for fixtures that must already be closed. */
64
+ export function windowAgo(fraction) {
65
+ return windowOffset(-fraction);
66
+ }
@@ -1,11 +1,16 @@
1
1
  /*
2
- * WHY: Provides 18 seed user accounts covering all marketplace roles and demographics.
3
- * WHAT: Exports 18 UserDocument partials — 1 admin, 2 sellers (store owners, one of which
4
- * is also the dedicated isTester QA account), 15 buyers.
2
+ * WHY: Provides 21 seed user accounts covering all marketplace roles and demographics.
3
+ * WHAT: Exports 21 UserDocument partials — 1 admin, 2 sellers (store owners, one of which
4
+ * is also the dedicated isTester QA account), 15 buyers, 1 automated tester
5
+ * (`user-claude-tester`, isBot), and 2 employees on contrasting permission presets.
5
6
  * All passwords are TempPass123! in the Firebase Auth emulator. UIDs match slugs.
6
7
  *
8
+ * 🛑 seed-cli sets that password ONLY when it creates the Auth record. A re-seed
9
+ * never resets an existing password, so an account whose password was changed
10
+ * stays changed and must be reset explicitly.
11
+ *
7
12
  * EXPORTS:
8
- * usersSeedData — array of 18 Partial<UserDocument> objects for the seed runner
13
+ * usersSeedData — array of 21 Partial<UserDocument> objects for the seed runner
9
14
  *
10
15
  * @tag domain:users,auth
11
16
  * @tag layer:seed
@@ -15,6 +20,7 @@
15
20
  * @tag sideEffects:none
16
21
  */
17
22
  import { getDefaultPhonePrefix } from "./seed-market-config";
23
+ import { PERMISSION_GROUPS } from "../features/auth/permissions/constants";
18
24
  import { USER_FIELDS } from "../constants/field-names";
19
25
  import { seedPhoto } from "./_helpers/media";
20
26
  const _ph = getDefaultPhonePrefix();
@@ -522,4 +528,101 @@ export const usersSeedData = [
522
528
  createdAt: daysAgo(1),
523
529
  updatedAt: daysAgo(1),
524
530
  },
531
+ // ── Automated tester (the Claude-driven checklist runner answers as this account) ──
532
+ // `canTestAdmin` is REQUIRED, not decorative: 116 checklist cases are adminOnly and
533
+ // GET/PUT /api/user/tester-checklist 404s them for a caller without it, so the runner
534
+ // would silently see a shorter catalog than a human tester does.
535
+ // `isBot` keeps it off the public Bug Hunters leaderboard — see getBugHunterLeaderboard().
536
+ {
537
+ uid: "user-claude-tester",
538
+ email: "claude-tester@letitrip.in",
539
+ phoneNumber: `${_ph}9999900019`,
540
+ phoneVerified: true,
541
+ displayName: "Claude (automated)",
542
+ photoURL: seedPhoto("user-avatar-claude-tester-20260101", 400, 400),
543
+ role: USER_FIELDS.ROLE_VALUES.SELLER,
544
+ isTester: true,
545
+ canTestAdmin: true,
546
+ isBot: true,
547
+ emailVerified: true,
548
+ disabled: false,
549
+ storeId: "store-tester-qa-seller",
550
+ storeSlug: "store-tester-qa-seller",
551
+ storeStatus: "approved",
552
+ publicProfile: {
553
+ isPublic: false,
554
+ showEmail: false,
555
+ showPhone: false,
556
+ showOrders: false,
557
+ showWishlist: false,
558
+ bio: "Automated tester. Works the tester checklist against a freshly seeded catalog and records a verdict per case.",
559
+ location: "Bengaluru, Karnataka",
560
+ },
561
+ stats: { totalOrders: 0, auctionsWon: 0, itemsSold: 0, reviewsCount: 0 },
562
+ metadata: { lastSignInTime: daysAgo(1), creationTime: daysAgo(1).toISOString(), loginCount: 1 },
563
+ createdAt: daysAgo(1),
564
+ updatedAt: daysAgo(1),
565
+ },
566
+ // ── Employees: two contrasting permission presets ───────────────────────────
567
+ // These exist so the sidebar-scoping checklist cases can be tested AT ALL. Before
568
+ // this the seed had no `role: "employee"` account anywhere, so the case the
569
+ // checklist itself marks "🛑 THE BIG ONE" — an employee must see only what their
570
+ // permissions allow, not all 91 admin entries — had nothing to sign in as.
571
+ //
572
+ // `permissions` is the load-bearing field (it is what the RBAC checks read);
573
+ // `permissionGroup` is display-only. Both are set from the same preset so they
574
+ // cannot disagree. Two presets on purpose: one narrow, one broad — a scoping bug
575
+ // that happens to produce the right count for one group is caught by the other.
576
+ {
577
+ uid: "user-employee-blog",
578
+ email: "employee-blog@letitrip.in",
579
+ phoneNumber: `${_ph}9999900020`,
580
+ phoneVerified: true,
581
+ displayName: "Mock Employee 1",
582
+ photoURL: seedPhoto("user-avatar-employee-blog-20260101", 400, 400),
583
+ role: USER_FIELDS.ROLE_VALUES.EMPLOYEE,
584
+ permissionGroup: "blog_poster",
585
+ permissions: [...PERMISSION_GROUPS.blog_poster],
586
+ emailVerified: true,
587
+ disabled: false,
588
+ publicProfile: {
589
+ isPublic: false,
590
+ showEmail: false,
591
+ showPhone: false,
592
+ showOrders: false,
593
+ showWishlist: false,
594
+ bio: "Content employee — blog and media only.",
595
+ location: "Pune, Maharashtra",
596
+ },
597
+ stats: { totalOrders: 0, auctionsWon: 0, itemsSold: 0, reviewsCount: 0 },
598
+ metadata: { lastSignInTime: daysAgo(2), creationTime: daysAgo(30).toISOString(), loginCount: 12 },
599
+ createdAt: daysAgo(30),
600
+ updatedAt: daysAgo(2),
601
+ },
602
+ {
603
+ uid: "user-employee-trust",
604
+ email: "employee-trust@letitrip.in",
605
+ phoneNumber: `${_ph}9999900021`,
606
+ phoneVerified: true,
607
+ displayName: "Mock Employee 2",
608
+ photoURL: seedPhoto("user-avatar-employee-trust-20260101", 400, 400),
609
+ role: USER_FIELDS.ROLE_VALUES.EMPLOYEE,
610
+ permissionGroup: "trust_and_safety",
611
+ permissions: [...PERMISSION_GROUPS.trust_and_safety],
612
+ emailVerified: true,
613
+ disabled: false,
614
+ publicProfile: {
615
+ isPublic: false,
616
+ showEmail: false,
617
+ showPhone: false,
618
+ showOrders: false,
619
+ showWishlist: false,
620
+ bio: "Trust & Safety employee — users, bans, reviews and reports.",
621
+ location: "Chennai, Tamil Nadu",
622
+ },
623
+ stats: { totalOrders: 0, auctionsWon: 0, itemsSold: 0, reviewsCount: 0 },
624
+ metadata: { lastSignInTime: daysAgo(2), creationTime: daysAgo(60).toISOString(), loginCount: 40 },
625
+ createdAt: daysAgo(60),
626
+ updatedAt: daysAgo(2),
627
+ },
525
628
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.34.0",
3
+ "version": "4.34.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"