@mohasinac/appkit 4.35.3 → 4.36.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/server/jobs/handlers/onShipmentAllocationSync.js +73 -6
- package/dist/features/products/hooks/useProducts.js +40 -3
- package/dist/features/tester/schemas/firestore.d.ts +29 -0
- package/dist/features/tester/seed-data/authored/_types.d.ts +24 -0
- package/dist/features/tester/seed-data/authored/account-auth__profile-settings.js +2 -0
- package/dist/features/tester/seed-data/authored/account-auth__signup-login.js +22 -0
- package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/admin__bug-hunter-rewards.js +106 -0
- package/dist/features/tester/seed-data/authored/admin__site-system.js +2 -0
- package/dist/features/tester/seed-data/authored/buying__my-orders.js +2 -0
- package/dist/features/tester/seed-data/authored/buying__offers.js +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__notifications.js +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__search.js +48 -19
- package/dist/features/tester/seed-data/authored/index.js +6 -0
- package/dist/features/tester/seed-data/authored/public-pages__auth-error-pages.js +4 -0
- package/dist/features/tester/seed-data/authored/selling__listing-a-product.js +0 -16
- package/dist/features/tester/seed-data/authored/selling__product-upload-details.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/selling__product-upload-details.js +150 -0
- package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/selling__quick-add-minimum-details.js +154 -0
- package/dist/features/tester/seed-data/authored/selling__seller-orders.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +111 -6
- package/dist/react/contexts/SessionContext.d.ts +13 -0
- package/dist/react/contexts/SessionContext.js +15 -0
- package/dist/ui/components/ImageLightbox.js +24 -1
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +784 -752
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
import { allocateShipmentCosts } from "../../../../features/shipments/utils/cost-allocation";
|
|
2
2
|
const DEFAULT_MAX_HOURS_PER_DAY = 4;
|
|
3
|
+
/*
|
|
4
|
+
* 🛑 THIS COMPARISON IS LOAD-BEARING. GETTING IT WRONG IS AN INFINITE LOOP THAT
|
|
5
|
+
* COSTS REAL MONEY, AND IT ALREADY DID.
|
|
6
|
+
*
|
|
7
|
+
* `onShipmentHeaderWrite` watches `procurementShipments/{shipmentId}` on
|
|
8
|
+
* documentWritten and writes BACK to that same document. The only thing stopping
|
|
9
|
+
* that from recurring forever is this equality check, so a false "changed" is not
|
|
10
|
+
* a wasted write — it is a permanent, self-sustaining cascade.
|
|
11
|
+
*
|
|
12
|
+
* It was `JSON.stringify(shipment.totals ?? {}) === JSON.stringify(totals)`.
|
|
13
|
+
* That is key-ORDER sensitive, and the two sides can never agree:
|
|
14
|
+
*
|
|
15
|
+
* computed — construction order from allocateShipmentCosts():
|
|
16
|
+
* lotsCost, customsTotal, shippingTotal, laborCost, …
|
|
17
|
+
* stored — Firestore returns map fields with keys sorted ALPHABETICALLY:
|
|
18
|
+
* customsTotal, estimatedProcessingDays, laborCost, lotCount, …
|
|
19
|
+
*
|
|
20
|
+
* So `totalsUnchanged` was ALWAYS false, every invocation wrote, and every write
|
|
21
|
+
* re-triggered the function. Measured 2026-09-11 in the Firebase console:
|
|
22
|
+
* `onShipmentHeaderWrite` 1,017,548 and `onShipmentDeleted` 1,017,372 invocations
|
|
23
|
+
* in 24 HOURS — 12M for the month against a 2M/month free quota, exceeded by
|
|
24
|
+
* 9.7M, while every other function in the project sat between 0 and 95.
|
|
25
|
+
*
|
|
26
|
+
* `onShipmentDeleted` was pure collateral: it is correctly guarded
|
|
27
|
+
* (`if (event.after) return`) but is bound to the same path, so it woke up a
|
|
28
|
+
* million times a day just to return.
|
|
29
|
+
*
|
|
30
|
+
* Two properties this replacement needs, and the old one had neither:
|
|
31
|
+
* 1. KEY ORDER MUST NOT MATTER — compare by key, never by serialised form.
|
|
32
|
+
* 2. FLOATS NEED TOLERANCE. `projectedMarginPercent` and `projectedRoiPercent`
|
|
33
|
+
* are unrounded divisions, and a Firestore round-trip can return a value
|
|
34
|
+
* that differs in the last bit. Exact `!==` on those is another way to make
|
|
35
|
+
* "unchanged" unreachable — the same bug with a different mechanism.
|
|
36
|
+
*/
|
|
37
|
+
const MONEY_EPSILON = 0.005; // half a paisa: below anything that can be displayed
|
|
38
|
+
function numbersEqual(a, b) {
|
|
39
|
+
const x = typeof a === "number" ? a : Number.NaN;
|
|
40
|
+
const y = typeof b === "number" ? b : Number.NaN;
|
|
41
|
+
if (Number.isNaN(x) && Number.isNaN(y))
|
|
42
|
+
return a === b; // both non-numeric: identity
|
|
43
|
+
return Math.abs(x - y) < MONEY_EPSILON;
|
|
44
|
+
}
|
|
45
|
+
/** Value equality over a flat numeric map, independent of key order. */
|
|
46
|
+
function totalsEqual(stored, computed) {
|
|
47
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored))
|
|
48
|
+
return false;
|
|
49
|
+
const a = stored;
|
|
50
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(computed)]);
|
|
51
|
+
for (const k of keys) {
|
|
52
|
+
if (!numbersEqual(a[k], computed[k]))
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
3
57
|
async function recomputeShipmentAllocation(shipmentId, ctx) {
|
|
4
58
|
const shipmentRef = ctx.db.collection("procurementShipments").doc(shipmentId);
|
|
5
59
|
const shipmentSnap = await shipmentRef.get();
|
|
@@ -34,11 +88,17 @@ async function recomputeShipmentAllocation(shipmentId, ctx) {
|
|
|
34
88
|
for (const lot of lots) {
|
|
35
89
|
const computed = perLot[lot.id];
|
|
36
90
|
const statusChanged = lot.shipmentStatus !== shipment.status;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
91
|
+
/*
|
|
92
|
+
* Same tolerance as the totals check, for the same reason: a lot write fires
|
|
93
|
+
* `onShipmentLotWrite`, which calls straight back into this function. Exact
|
|
94
|
+
* `!==` on a float that has round-tripped through Firestore is a second,
|
|
95
|
+
* independent path to the same infinite cascade.
|
|
96
|
+
*/
|
|
97
|
+
const valuesChanged = !numbersEqual(lot.customsAllocated, computed.customsAllocated) ||
|
|
98
|
+
!numbersEqual(lot.shippingAllocated, computed.shippingAllocated) ||
|
|
99
|
+
!numbersEqual(lot.totalLandedCost, computed.totalLandedCost) ||
|
|
100
|
+
!numbersEqual(lot.projectedRevenue, computed.projectedRevenue) ||
|
|
101
|
+
!numbersEqual(lot.projectedProfit, computed.projectedProfit);
|
|
42
102
|
if (statusChanged || valuesChanged) {
|
|
43
103
|
anyLotChanged = true;
|
|
44
104
|
batch.update(ctx.db.collection("shipmentLots").doc(lot.id), {
|
|
@@ -48,8 +108,15 @@ async function recomputeShipmentAllocation(shipmentId, ctx) {
|
|
|
48
108
|
});
|
|
49
109
|
}
|
|
50
110
|
}
|
|
51
|
-
const totalsUnchanged =
|
|
111
|
+
const totalsUnchanged = totalsEqual(shipment.totals, totals);
|
|
52
112
|
if (!totalsUnchanged) {
|
|
113
|
+
// trigger-self-write-ok: this writes to the collection onShipmentHeaderWrite
|
|
114
|
+
// watches, so it re-triggers itself — ONCE. The second pass recomputes the
|
|
115
|
+
// same `totals` from unchanged inputs, totalsEqual() reports them identical
|
|
116
|
+
// (by key, with a float tolerance — NOT by JSON.stringify, which is key-order
|
|
117
|
+
// sensitive and is what made this loop run 1,017,548 times in 24h), so no
|
|
118
|
+
// write happens and the cascade stops. Pinned by
|
|
119
|
+
// scripts/verify-shipment-allocation-guard.mjs.
|
|
53
120
|
batch.update(shipmentRef, { totals, totalsComputedAt: ctx.now, updatedAt: ctx.now });
|
|
54
121
|
}
|
|
55
122
|
if (anyLotChanged || !totalsUnchanged) {
|
|
@@ -3,6 +3,8 @@ import { useEffect } from "react";
|
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { apiClient } from "../../../http";
|
|
5
5
|
import { useToast } from "../../../ui";
|
|
6
|
+
import { useOptionalSession } from "../../../react/contexts/SessionContext";
|
|
7
|
+
import { isAdminUser, isTesterUser } from "../../auth/role-predicates";
|
|
6
8
|
import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
7
9
|
export function useProducts(params = {}, opts) {
|
|
8
10
|
const { showToast } = useToast();
|
|
@@ -73,11 +75,46 @@ export function useProducts(params = {}, opts) {
|
|
|
73
75
|
sp.set(key, value);
|
|
74
76
|
}
|
|
75
77
|
const qs = sp.toString();
|
|
78
|
+
/*
|
|
79
|
+
* 🛑 A TESTER MUST REFETCH; EVERYONE ELSE MUST KEEP THE CACHED SSR PAINT.
|
|
80
|
+
*
|
|
81
|
+
* Sandbox rows are correctly hidden from the public. But the SSR listing views
|
|
82
|
+
* pass no `viewer` to `listPublicProducts`, so the server-rendered grid is the
|
|
83
|
+
* anonymous one for EVERY visitor — and with `initialData` + `staleTime:
|
|
84
|
+
* Infinity` the client never refetches, so a signed-in tester saw a catalogue
|
|
85
|
+
* with no sandbox fixtures in it, on every listing page. The items were
|
|
86
|
+
* reachable by direct URL, which is what made it look like a data problem
|
|
87
|
+
* rather than a visibility one.
|
|
88
|
+
*
|
|
89
|
+
* The fix is NOT to read the session in those SSR views. `getServerSessionUser`
|
|
90
|
+
* is a dynamic API, and calling it there would make every public listing page
|
|
91
|
+
* per-request — no caching, a billed invocation per visitor, and every
|
|
92
|
+
* `revalidate` silently overridden. That is Root Cause #82, and paying it for a
|
|
93
|
+
* testing affordance would be a poor trade.
|
|
94
|
+
*
|
|
95
|
+
* `/api/products` ALREADY resolves the session and passes `viewer`. So the only
|
|
96
|
+
* thing missing was a reason for the client to ask again: the query key now
|
|
97
|
+
* carries the viewer class, giving testers their own cache entry, and their
|
|
98
|
+
* `staleTime` is not frozen. Anonymous visitors keep the cached SSR data
|
|
99
|
+
* untouched and issue no extra request.
|
|
100
|
+
*
|
|
101
|
+
* `useOptionalSession` rather than `useAuth()` on purpose — `useSession`
|
|
102
|
+
* THROWS outside a provider, and making a public appkit hook require one would
|
|
103
|
+
* be a breaking contract change (Root Cause #20). Absent provider degrades to
|
|
104
|
+
* "not a tester", which is the fail-closed answer.
|
|
105
|
+
*/
|
|
106
|
+
const session = useOptionalSession();
|
|
107
|
+
const viewer = session?.user;
|
|
108
|
+
const canSeeTestData = isTesterUser(viewer) || isAdminUser(viewer);
|
|
76
109
|
const query = useQuery({
|
|
77
|
-
queryKey: ["products", qs],
|
|
110
|
+
queryKey: ["products", qs, canSeeTestData ? "with-test-data" : "public"],
|
|
78
111
|
queryFn: () => apiClient.get(`${PRODUCT_ENDPOINTS.LIST}${qs ? `?${qs}` : ""}`),
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
// The SSR paint is the anonymous view, so it is only a valid starting point
|
|
113
|
+
// for an anonymous viewer. Handing it to a tester would show them the very
|
|
114
|
+
// rows this refetch exists to reveal.
|
|
115
|
+
initialData: canSeeTestData ? undefined : opts?.initialData,
|
|
116
|
+
staleTime: opts?.staleTime ??
|
|
117
|
+
(!canSeeTestData && opts?.initialData != null ? Infinity : 0),
|
|
81
118
|
enabled: opts?.enabled,
|
|
82
119
|
});
|
|
83
120
|
const warning = query.data?.warning;
|
|
@@ -108,6 +108,35 @@ export interface TesterChecklistItemDocument extends BaseDocument {
|
|
|
108
108
|
* field to mean two different things.
|
|
109
109
|
*/
|
|
110
110
|
endResult?: string;
|
|
111
|
+
/**
|
|
112
|
+
* The case's own author was unsure of its intent — the report flags the verdict
|
|
113
|
+
* so nobody reads it with more confidence than it earned.
|
|
114
|
+
*
|
|
115
|
+
* 🛑 These were UNDECLARED here until 2026-09-13, which is why the flag had
|
|
116
|
+
* never once fired: `fetch-cases.mjs` read them off the document, SKILL.md
|
|
117
|
+
* documented them and `record-verdicts.mjs` had a line to render them, but
|
|
118
|
+
* `group()` did not forward them and this interface did not admit them. Four
|
|
119
|
+
* hops right, two hops missing, and not one error anywhere. Root Cause #38.
|
|
120
|
+
*/
|
|
121
|
+
needsReview?: boolean;
|
|
122
|
+
reviewNote?: string;
|
|
123
|
+
/**
|
|
124
|
+
* The case needs a channel an automated browser does not have — a real email
|
|
125
|
+
* inbox, or an interactive Google account. Always `null` in an automated run,
|
|
126
|
+
* by nature rather than by fault.
|
|
127
|
+
*
|
|
128
|
+
* 🛑 A PERMANENT FLOOR, not a backlog. Unflagged, these sit in the same
|
|
129
|
+
* "blocked" bucket as a case blocked by a genuine defect, so a raw blocked
|
|
130
|
+
* count mixes a constant with a signal and every run re-triages the same
|
|
131
|
+
* thirteen to rediscover none is a bug. Flagged, the report subtracts them.
|
|
132
|
+
*
|
|
133
|
+
* Never use it for a case that is merely hard, flaky or currently failing —
|
|
134
|
+
* that converts a defect into a documented non-test. See the three-kinds-of-
|
|
135
|
+
* null table in docs/TESTING-RIG-NOTES.md.
|
|
136
|
+
*/
|
|
137
|
+
requiresHumanChannel?: boolean;
|
|
138
|
+
/** Which channel is missing, e.g. "real email inbox". Rendered in the report. */
|
|
139
|
+
humanChannelReason?: string;
|
|
111
140
|
href?: string;
|
|
112
141
|
order: number;
|
|
113
142
|
phase: number;
|
|
@@ -30,4 +30,28 @@ export interface AuthoredCase {
|
|
|
30
30
|
*/
|
|
31
31
|
needsReview?: boolean;
|
|
32
32
|
reviewNote?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The case needs a channel an automated browser does not have — a real email
|
|
35
|
+
* inbox, or an interactive Google account. It is correct, it is worth keeping,
|
|
36
|
+
* and an automated run can never do anything with it but abstain.
|
|
37
|
+
*
|
|
38
|
+
* 🛑 This is a PERMANENT FLOOR, not a backlog. Without the flag those ~13 cases
|
|
39
|
+
* report `null` in every run forever and land in the same "blocked" bucket as a
|
|
40
|
+
* case blocked by a real defect — so a raw blocked count mixes a constant with
|
|
41
|
+
* a signal, and each run re-triages the same thirteen to rediscover that none
|
|
42
|
+
* of them is a bug. Flagged, the report can subtract them and say so.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately distinct from the other two reasons a case yields no verdict —
|
|
45
|
+
* see the three-kinds-of-null table in docs/TESTING-RIG-NOTES.md. A missing
|
|
46
|
+
* fixture is a RIG fault and must be fixed; a case blocked by another defect is
|
|
47
|
+
* a PRODUCT fault and disappears when its cause does. Only this one is a
|
|
48
|
+
* permanent property of running headlessly, and collapsing the three is how a
|
|
49
|
+
* fixable gap hides inside an unfixable one.
|
|
50
|
+
*
|
|
51
|
+
* It must NEVER be used to excuse a case that is merely hard, flaky, or
|
|
52
|
+
* currently failing: that converts a defect into a documented non-test.
|
|
53
|
+
*/
|
|
54
|
+
requiresHumanChannel?: boolean;
|
|
55
|
+
/** Which channel is missing, e.g. "real email inbox" / "interactive Google account". */
|
|
56
|
+
humanChannelReason?: string;
|
|
33
57
|
}
|
|
@@ -148,6 +148,8 @@ export const authored = {
|
|
|
148
148
|
endResult: "All three links reach the same page. Set profile visibility back to Public afterwards, since other cases read this profile as another user.",
|
|
149
149
|
},
|
|
150
150
|
"checklist-account-auth-profile-settings-password-change-reset-link": {
|
|
151
|
+
requiresHumanChannel: true,
|
|
152
|
+
humanChannelReason: "real email inbox",
|
|
151
153
|
roles: ["buyer"],
|
|
152
154
|
startPage: "/user/settings",
|
|
153
155
|
steps: [
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export const authored = {
|
|
30
30
|
"checklist-account-auth-signup-login-auth-email-links-single-use": {
|
|
31
|
+
requiresHumanChannel: true,
|
|
32
|
+
humanChannelReason: "real email inbox",
|
|
31
33
|
roles: ["guest"],
|
|
32
34
|
startPage: "/auth/forgot-password",
|
|
33
35
|
steps: [
|
|
@@ -51,6 +53,8 @@ export const authored = {
|
|
|
51
53
|
endResult: "Account state after the second click is identical to after the first. divya.funko@gmail.com is back on TempPass123! by the final step — skipping that restore leaves the account unusable by every later case, since the seeder never resets an existing password.",
|
|
52
54
|
},
|
|
53
55
|
"checklist-account-auth-signup-login-auth-emails-sender-identity-and-inbox": {
|
|
56
|
+
requiresHumanChannel: true,
|
|
57
|
+
humanChannelReason: "real email inbox",
|
|
54
58
|
roles: ["guest"],
|
|
55
59
|
startPage: "/auth/register",
|
|
56
60
|
steps: [
|
|
@@ -92,6 +96,8 @@ export const authored = {
|
|
|
92
96
|
endResult: "Reloading /user shows 'QA Signup Tester' and the address used. The account persists — this case creates a real row that a later teardown does not remove, which is why the address is numbered rather than fixed.",
|
|
93
97
|
},
|
|
94
98
|
"checklist-account-auth-signup-login-email-verify": {
|
|
99
|
+
requiresHumanChannel: true,
|
|
100
|
+
humanChannelReason: "real email inbox",
|
|
95
101
|
roles: ["guest"],
|
|
96
102
|
startPage: "/auth/register",
|
|
97
103
|
steps: [
|
|
@@ -124,6 +130,8 @@ export const authored = {
|
|
|
124
130
|
endResult: "No email arrives for nobody-here-12345@example.com. The two on-screen responses were indistinguishable, which is the whole point — a difference of any kind is an account-enumeration leak.",
|
|
125
131
|
},
|
|
126
132
|
"checklist-account-auth-signup-login-google-link-confirmation-shown": {
|
|
133
|
+
requiresHumanChannel: true,
|
|
134
|
+
humanChannelReason: "interactive Google account",
|
|
127
135
|
roles: ["buyer"],
|
|
128
136
|
startPage: "/user",
|
|
129
137
|
steps: [
|
|
@@ -137,6 +145,8 @@ export const authored = {
|
|
|
137
145
|
endResult: "After reload the green confirmation is still there and the blue prompt has not returned. Needs a Google account to reach — answer null with that reason rather than guessing if none is available.",
|
|
138
146
|
},
|
|
139
147
|
"checklist-account-auth-signup-login-google-link-conflict-rejected": {
|
|
148
|
+
requiresHumanChannel: true,
|
|
149
|
+
humanChannelReason: "interactive Google account",
|
|
140
150
|
roles: ["buyer"],
|
|
141
151
|
startPage: "/user/settings",
|
|
142
152
|
steps: [
|
|
@@ -156,6 +166,8 @@ export const authored = {
|
|
|
156
166
|
endResult: "After reload vivaan.kapoor@gmail.com has no Google link and rehan.sheikh@gmail.com still has G. Unlink G from rehan.sheikh@gmail.com afterwards. Needs a real Google account — answer null if none is available.",
|
|
157
167
|
},
|
|
158
168
|
"checklist-account-auth-signup-login-google-link-different-email": {
|
|
169
|
+
requiresHumanChannel: true,
|
|
170
|
+
humanChannelReason: "interactive Google account",
|
|
159
171
|
roles: ["buyer"],
|
|
160
172
|
startPage: "/user/settings",
|
|
161
173
|
steps: [
|
|
@@ -174,6 +186,8 @@ export const authored = {
|
|
|
174
186
|
endResult: "After reload the link persists and the primary email is still vivaan.kapoor@gmail.com. Unlink afterwards. Needs a second Google account — answer null if none is available.",
|
|
175
187
|
},
|
|
176
188
|
"checklist-account-auth-signup-login-google-link-existing": {
|
|
189
|
+
requiresHumanChannel: true,
|
|
190
|
+
humanChannelReason: "interactive Google account",
|
|
177
191
|
roles: ["guest"],
|
|
178
192
|
startPage: "/auth/register",
|
|
179
193
|
steps: [
|
|
@@ -194,6 +208,8 @@ export const authored = {
|
|
|
194
208
|
endResult: "One account exists for that email, not two, and it kept its wishlist. Needs a Google account whose address you can also register with — answer null if none is available.",
|
|
195
209
|
},
|
|
196
210
|
"checklist-account-auth-signup-login-google-oauth": {
|
|
211
|
+
requiresHumanChannel: true,
|
|
212
|
+
humanChannelReason: "interactive Google account",
|
|
197
213
|
roles: ["guest"],
|
|
198
214
|
startPage: "/auth/login",
|
|
199
215
|
steps: [
|
|
@@ -208,6 +224,8 @@ export const authored = {
|
|
|
208
224
|
endResult: "Reloading /user keeps the session. Needs a Google account — answer null with that reason if none is available.",
|
|
209
225
|
},
|
|
210
226
|
"checklist-account-auth-signup-login-google-popup-blocked-fallback": {
|
|
227
|
+
requiresHumanChannel: true,
|
|
228
|
+
humanChannelReason: "interactive Google account",
|
|
211
229
|
roles: ["guest"],
|
|
212
230
|
startPage: "/auth/login",
|
|
213
231
|
steps: [
|
|
@@ -253,6 +271,8 @@ export const authored = {
|
|
|
253
271
|
endResult: "Navigating to /user lands on /auth/login and no data for Vivaan Kapoor is rendered at any point. A back-button press does not restore the dashboard.",
|
|
254
272
|
},
|
|
255
273
|
"checklist-account-auth-signup-login-password-reset": {
|
|
274
|
+
requiresHumanChannel: true,
|
|
275
|
+
humanChannelReason: "real email inbox",
|
|
256
276
|
roles: ["guest"],
|
|
257
277
|
startPage: "/auth/login",
|
|
258
278
|
steps: [
|
|
@@ -276,6 +296,8 @@ export const authored = {
|
|
|
276
296
|
endResult: "The password is back at TempPass123! by the final step. That restore is mandatory: the seeder never resets an existing password, so skipping it leaves this account permanently signed out of every later case.",
|
|
277
297
|
},
|
|
278
298
|
"checklist-account-auth-signup-login-signup-verification-email-arrives": {
|
|
299
|
+
requiresHumanChannel: true,
|
|
300
|
+
humanChannelReason: "real email inbox",
|
|
279
301
|
roles: ["guest"],
|
|
280
302
|
startPage: "/auth/register",
|
|
281
303
|
steps: [
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: Authored six-part procedures for the admin/bug-hunter-rewards page.
|
|
3
|
+
* WHAT: 4 case(s), keyed by full checklist id.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 THE LAST UNAUTHORED PAGE IN THE CATALOGUE. It was the sole remaining entry in
|
|
6
|
+
* R5's `UNAUTHORED_PAGES` ratchet (audit-tester-plugin-wiring.mjs), whose own
|
|
7
|
+
* header says the ratchet exists to be emptied. With this file it is.
|
|
8
|
+
*
|
|
9
|
+
* This page is awkward to test for a reason worth stating: confirming a bug
|
|
10
|
+
* DISABLES the case it was reported against, and credit is awarded to exactly one
|
|
11
|
+
* tester. Both effects are visible to every other tester, and the credit cannot be
|
|
12
|
+
* silently reassigned. So the cases below deliberately operate on the seeded demo
|
|
13
|
+
* fixture pair (v1 disabled + credited, v2 active) rather than on a live report:
|
|
14
|
+
*
|
|
15
|
+
* checklist-admin-bug-hunter-rewards-demo-fixture v1, isActive:false,
|
|
16
|
+
* bugConfirmed, credited to
|
|
17
|
+
* "Mock User 18"
|
|
18
|
+
* checklist-admin-bug-hunter-rewards-demo-fixture-v2 v2, isActive:true
|
|
19
|
+
*
|
|
20
|
+
* That pair exists precisely so this flow can be read without spending a real
|
|
21
|
+
* tester's report on it. Where a case must perform the irreversible half —
|
|
22
|
+
* confirming a bug — it does so against v2, which is the fixture reopened for
|
|
23
|
+
* exactly that purpose.
|
|
24
|
+
*
|
|
25
|
+
* @tag domain:tester
|
|
26
|
+
* @tag layer:seed
|
|
27
|
+
* @tag pattern:none
|
|
28
|
+
* @tag access:server-only
|
|
29
|
+
* @tag consumers:seed-data/authored/index.ts
|
|
30
|
+
* @tag sideEffects:none
|
|
31
|
+
*/
|
|
32
|
+
const SIGN_IN_ADMIN = "Sign in as admin@letitrip.in / TempPass123!.";
|
|
33
|
+
export const authored = {
|
|
34
|
+
"checklist-admin-bug-hunter-rewards-confirm-bug": {
|
|
35
|
+
roles: ["admin"],
|
|
36
|
+
startPage: "/admin/tester-feedback",
|
|
37
|
+
steps: [
|
|
38
|
+
SIGN_IN_ADMIN,
|
|
39
|
+
"Open /admin/tester-feedback and select the Main Issues tab.",
|
|
40
|
+
"Find the row for the case 'Demo fixture — reported bug, already confirmed and reopened (v2, active)' and read the tester name on it.",
|
|
41
|
+
"Click 'Mark as Bug' on that row.",
|
|
42
|
+
"Read the confirmation dialog in full before accepting, then accept it.",
|
|
43
|
+
"Read the badge that appears on the row afterwards.",
|
|
44
|
+
"Open /admin/tester-checklist, set the Status filter to 'All', and read the Status of that same case.",
|
|
45
|
+
],
|
|
46
|
+
inputs: { caseId: "checklist-admin-bug-hunter-rewards-demo-fixture-v2" },
|
|
47
|
+
expectedBehaviour: "Confirming a bug does two things at once and both are irreversible from this screen: it credits exactly one tester, and it takes the case out of circulation so no second tester can answer it. A confirmation dialog must stand in front of it — an accidental click here spends a credit on the wrong person and silently shrinks the catalogue.",
|
|
48
|
+
expectedUiState: "A dialog appears BEFORE anything commits. After accepting, the row shows a confirmed-bug badge naming the crediting tester — the same name that was on the row beforehand, not a different one. In the catalog the case's Status reads as bug-confirmed rather than Active.",
|
|
49
|
+
expectedData: { creditedTesterUnchanged: true },
|
|
50
|
+
endResult: "Survives a reload: the badge and the credited name are still there, and the case is still out of the Active view.",
|
|
51
|
+
},
|
|
52
|
+
"checklist-admin-bug-hunter-rewards-confirm-bug-idempotent": {
|
|
53
|
+
roles: ["admin"],
|
|
54
|
+
startPage: "/admin/tester-feedback",
|
|
55
|
+
steps: [
|
|
56
|
+
SIGN_IN_ADMIN,
|
|
57
|
+
"Open /admin/tester-feedback and select the All Submissions tab.",
|
|
58
|
+
"Find a row whose case is ALREADY bug-confirmed — the demo fixture 'reported bug, already confirmed and reopened (v1, disabled)' is credited to Mock User 18.",
|
|
59
|
+
"Read the tester name currently credited on it.",
|
|
60
|
+
"Attempt 'Mark as Bug' on that row.",
|
|
61
|
+
"Read whatever the screen says in response.",
|
|
62
|
+
"Read the credited tester name again and compare it against the one noted earlier.",
|
|
63
|
+
],
|
|
64
|
+
inputs: { caseId: "checklist-admin-bug-hunter-rewards-demo-fixture", creditedTo: "Mock User 18" },
|
|
65
|
+
expectedBehaviour: "A second confirmation on an already-confirmed case is REFUSED, and refused loudly. The failure mode this guards is silent re-crediting: two testers report the same case, an admin confirms twice, and the second confirmation quietly moves the credit off the person who actually found it. Nothing about that is visible unless the screen says so.",
|
|
66
|
+
expectedUiState: "Either the action is absent/disabled on an already-confirmed row, or it is refused with a message naming the existing credit. What must NOT happen is a success message. The credited name is byte-identical to the one read at the start.",
|
|
67
|
+
expectedData: { creditedTo: "Mock User 18" },
|
|
68
|
+
endResult: "Reload the page: the credit still reads Mock User 18, and the case has not gained a second confirmation.",
|
|
69
|
+
},
|
|
70
|
+
"checklist-admin-bug-hunter-rewards-reopen-case": {
|
|
71
|
+
roles: ["admin"],
|
|
72
|
+
startPage: "/admin/tester-checklist",
|
|
73
|
+
steps: [
|
|
74
|
+
SIGN_IN_ADMIN,
|
|
75
|
+
"Open /admin/tester-checklist and set the Status filter to 'Inactive' (or set Bug status to 'Bug Confirmed').",
|
|
76
|
+
"Find 'Demo fixture — reported bug, already confirmed and reopened (v1, disabled)' and read its Status and its bug-hunter credit.",
|
|
77
|
+
"Use its 'Reopen as New Test Case' row action.",
|
|
78
|
+
"Read the new case that appears — its label, its Status, and its version.",
|
|
79
|
+
"Go back to the v1 case and read its Status and credit again.",
|
|
80
|
+
"Open the Tester Hub as the tester and search for the new case.",
|
|
81
|
+
],
|
|
82
|
+
inputs: { v1Id: "checklist-admin-bug-hunter-rewards-demo-fixture" },
|
|
83
|
+
expectedBehaviour: "Reopening creates a NEW version rather than resurrecting the old one. The original must keep both its disabled state and its bug-hunter credit — that credit is a record of who found the defect, and a retest of the same behaviour does not undo the finding. Mutating v1 in place would erase the history the whole feature exists to keep.",
|
|
84
|
+
expectedUiState: "Two rows exist afterwards: the old one still disabled and still credited, and a new active one answerable from the Tester Hub. The new case does NOT carry the old case's bug-hunter credit.",
|
|
85
|
+
expectedData: { originalStaysDisabled: true, originalKeepsCredit: true },
|
|
86
|
+
endResult: "Survives a reload: v1 disabled with its credit, the reopened version active and answerable.",
|
|
87
|
+
},
|
|
88
|
+
"checklist-admin-bug-hunter-rewards-catalog-default-active-filter": {
|
|
89
|
+
roles: ["admin"],
|
|
90
|
+
startPage: "/admin/tester-checklist",
|
|
91
|
+
steps: [
|
|
92
|
+
SIGN_IN_ADMIN,
|
|
93
|
+
"Open /admin/tester-checklist without touching any filter and read the total count shown.",
|
|
94
|
+
"Search for 'Demo fixture — reported bug' and read how many rows match.",
|
|
95
|
+
"Switch the Status filter to 'All' and read the total count and the matching rows again.",
|
|
96
|
+
"Switch the Status filter to 'Inactive' and read which of the two demo fixtures appears.",
|
|
97
|
+
"Set Status back to its default and set 'Bug status' to 'Bug Confirmed'.",
|
|
98
|
+
"Read which rows appear under that filter.",
|
|
99
|
+
],
|
|
100
|
+
inputs: { search: "Demo fixture — reported bug" },
|
|
101
|
+
expectedBehaviour: "The default view is the answerable catalogue, not everything ever written. A bug-confirmed case is deliberately out of circulation, so showing it by default would invite an admin to treat a closed finding as open work. Switching the filter must reveal it — hidden is not the same as gone.",
|
|
102
|
+
expectedUiState: "On the default view only the v2 (active) demo fixture matches the search. Under Status 'All' both appear and the total is higher. Under 'Inactive' the v1 disabled one appears. Under Bug status 'Bug Confirmed' the v1 one appears. The counts move in the direction the filter implies.",
|
|
103
|
+
expectedData: { defaultViewHidesBugConfirmed: true },
|
|
104
|
+
endResult: "Read-only — no case is modified.",
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -211,6 +211,8 @@ export const authored = {
|
|
|
211
211
|
endResult: "Read-only; send nothing.",
|
|
212
212
|
},
|
|
213
213
|
"checklist-admin-site-system-daily-digest-email-content": {
|
|
214
|
+
requiresHumanChannel: true,
|
|
215
|
+
humanChannelReason: "real email inbox",
|
|
214
216
|
roles: ["admin"],
|
|
215
217
|
startPage: "/admin/site",
|
|
216
218
|
steps: [
|
|
@@ -239,6 +239,8 @@ export const authored = {
|
|
|
239
239
|
endResult: "Read-only. If no rejected order exists, answer null rather than creating one — rejecting as fraud bans a real account for seven days.",
|
|
240
240
|
},
|
|
241
241
|
"checklist-buying-my-orders-order-lifecycle-emails-arrive": {
|
|
242
|
+
requiresHumanChannel: true,
|
|
243
|
+
humanChannelReason: "real email inbox",
|
|
242
244
|
roles: ["buyer"],
|
|
243
245
|
startPage: "/user/orders",
|
|
244
246
|
steps: [
|
|
@@ -384,6 +384,8 @@ export const authored = {
|
|
|
384
384
|
endResult: "All four listings keep their type-specific fields. Restore any title that was changed.",
|
|
385
385
|
},
|
|
386
386
|
"checklist-buying-offers-notification-email-is-a-real-email": {
|
|
387
|
+
requiresHumanChannel: true,
|
|
388
|
+
humanChannelReason: "real email inbox",
|
|
387
389
|
roles: ["buyer"],
|
|
388
390
|
startPage: "/user/settings",
|
|
389
391
|
steps: [
|
|
@@ -96,6 +96,8 @@ export const authored = {
|
|
|
96
96
|
endResult: "Read-only; nothing persists beyond the URL.",
|
|
97
97
|
},
|
|
98
98
|
"checklist-content-discovery-notifications-notification-email-actually-arrives": {
|
|
99
|
+
requiresHumanChannel: true,
|
|
100
|
+
humanChannelReason: "real email inbox",
|
|
99
101
|
roles: ["buyer"],
|
|
100
102
|
startPage: "/user/settings",
|
|
101
103
|
steps: [
|
|
@@ -39,17 +39,30 @@ export const authored = {
|
|
|
39
39
|
roles: ["guest"],
|
|
40
40
|
startPage: "/",
|
|
41
41
|
steps: [
|
|
42
|
+
// The batch seeds TWO probe rows: product-{{w}}-draft-probe (status
|
|
43
|
+
// "draft") and product-{{w}}-archived-probe (status "archived"), both
|
|
44
|
+
// titled so the query below matches them. Without a KNOWN unpublished row
|
|
45
|
+
// the case cannot distinguish "drafts are correctly hidden" from "there
|
|
46
|
+
// were no drafts" — a tester reported exactly that: the suggestion payload
|
|
47
|
+
// carries no status field to inspect, and no draft existed to probe with.
|
|
42
48
|
"Open / in a private window with no session.",
|
|
43
|
-
"Click into the header search and type
|
|
49
|
+
"Click into the header search and type draftprobe.",
|
|
44
50
|
"Read every suggestion and note its title.",
|
|
51
|
+
"Check specifically whether product-{{w}}-draft-probe or product-{{w}}-archived-probe appear.",
|
|
52
|
+
"Now type beyblade and read every suggestion.",
|
|
45
53
|
"Open each suggestion in turn and read the page it lands on.",
|
|
46
54
|
"Note any that 404s or shows an unpublished listing.",
|
|
47
55
|
],
|
|
48
|
-
inputs: {
|
|
56
|
+
inputs: {
|
|
57
|
+
query: "beyblade",
|
|
58
|
+
probeQuery: "draftprobe",
|
|
59
|
+
draftProduct: "product-{{w}}-draft-probe",
|
|
60
|
+
archivedProduct: "product-{{w}}-archived-probe",
|
|
61
|
+
},
|
|
49
62
|
expectedBehaviour: "Suggestions are filtered to published, publicly-visible listings. A status filter applied only when the caller explicitly asks for one is absent here, because a typeahead rarely asks — which is exactly how draft rows reach a public suggestion list.",
|
|
50
|
-
expectedUiState: "
|
|
51
|
-
expectedData: { draftSuggestions: 0 },
|
|
52
|
-
endResult: "Read-only;
|
|
63
|
+
expectedUiState: "Searching 'draftprobe' returns NEITHER probe row — that is the real test, because both exist and both match the term. Then every 'beyblade' suggestion opens a real published listing: none 404s, none opens a draft or archived listing, and none names a tester sandbox item for a signed-out visitor.",
|
|
64
|
+
expectedData: { draftSuggestions: 0, probeRowsSuggested: 0 },
|
|
65
|
+
endResult: "Read-only; both probe rows are removed when the batch ends.",
|
|
53
66
|
},
|
|
54
67
|
"checklist-content-discovery-search-search-prefix-match": {
|
|
55
68
|
roles: ["guest"],
|
|
@@ -101,16 +114,24 @@ export const authored = {
|
|
|
101
114
|
roles: ["guest"],
|
|
102
115
|
startPage: "/products",
|
|
103
116
|
steps: [
|
|
104
|
-
|
|
117
|
+
// PRECONDITION FIRST. Without it a broken sort reads as "inconclusive"
|
|
118
|
+
// instead of "fail" — which is exactly what happened: a tester reported
|
|
119
|
+
// sorts=price and sorts=-price returning an IDENTICAL order with no
|
|
120
|
+
// search term at all, and had no way to record that as a verdict.
|
|
121
|
+
"Open /products in a private window with NO search term.",
|
|
122
|
+
"Set the sort to 'Price: low to high' and read the first three prices in order.",
|
|
123
|
+
"Set the sort to 'Price: high to low' and read the first three prices again.",
|
|
124
|
+
"If those two orders are identical, STOP and answer NO — sort is broken before search is involved, and say so. Do not continue.",
|
|
105
125
|
"Type beyblade in the search field and click 'Search'.",
|
|
106
126
|
"Read the card order and note the sort dropdown's selection.",
|
|
107
|
-
"Change the sort to
|
|
127
|
+
"Change the sort to 'Price: low to high'.",
|
|
108
128
|
"Read the card order, the URL and the search field.",
|
|
109
129
|
"Reload the page and read all three again.",
|
|
110
130
|
],
|
|
111
|
-
inputs: { query: "beyblade", sort: "Price: low to high" },
|
|
131
|
+
inputs: { query: "beyblade", sort: "Price: low to high", sortReverse: "Price: high to low" },
|
|
112
132
|
expectedBehaviour: "Changing the sort re-orders the current results and keeps the term. Both live in the URL, so a reload reproduces the same page — and the sort field must be one the query can actually sort on, since an unsortable field is dropped and the dropdown silently reorders nothing.",
|
|
113
|
-
expectedUiState: "
|
|
133
|
+
expectedUiState: "With no search term, ascending and descending produce DIFFERENT orders — that is the precondition. Then, with the term applied, prices ascend down the page and the search field still holds 'beyblade'. The URL carries both. After the reload the order and the term are unchanged. A dropdown selection that changes no order at all is the dropped-sort failure, and it is a NO rather than an unanswerable.",
|
|
134
|
+
expectedData: { ascendingDiffersFromDescending: true },
|
|
114
135
|
endResult: "Read-only; nothing persists beyond the URL.",
|
|
115
136
|
},
|
|
116
137
|
"checklist-content-discovery-search-search-keeps-facets": {
|
|
@@ -126,8 +147,8 @@ export const authored = {
|
|
|
126
147
|
],
|
|
127
148
|
inputs: { query: "beyblade", priceMin: 500, priceMax: 1500 },
|
|
128
149
|
expectedBehaviour: "Price, tag and availability all narrow alongside the term. A facet that renders and counts toward the badge while changing nothing is inert — that happens when it emits a field the query allowlist does not carry, and nothing raises.",
|
|
129
|
-
expectedUiState: "Each facet visibly reduces the count and every card respects it. The availability tab changes which rows appear. A facet that leaves the count identical while the filter badge increments is the failure.",
|
|
130
|
-
expectedData: { inertFacetCount: 0 },
|
|
150
|
+
expectedUiState: "Each facet visibly reduces the count and every card respects it. The availability tab changes which rows appear. A facet that leaves the count identical while the filter badge increments is the failure. RECORD ALL THREE COUNTS SEPARATELY — price, tag, availability. This case names those three specifically; exercising a different facet (category, say) and reporting a partial pass answers a question nobody asked. If one of the three cannot be reached, answer NO or null for that reason rather than passing on the strength of the other two.",
|
|
151
|
+
expectedData: { inertFacetCount: 0, facetsExercised: 3 },
|
|
131
152
|
endResult: "Read-only; nothing persists beyond the URL.",
|
|
132
153
|
},
|
|
133
154
|
"checklist-content-discovery-search-search-case-and-accents": {
|
|
@@ -182,10 +203,11 @@ export const authored = {
|
|
|
182
203
|
startPage: "/faqs",
|
|
183
204
|
steps: [
|
|
184
205
|
"Open /faqs in a private window and read the category list.",
|
|
185
|
-
"
|
|
206
|
+
"CLICK INTO the Shipping category — this case is about the category PAGE, so reading the counters on /faqs is not an answer to it.",
|
|
186
207
|
"Read the questions listed and expand one to read its answer.",
|
|
187
208
|
"Read the address bar.",
|
|
188
209
|
"Open a second category page and read its questions.",
|
|
210
|
+
"Compare the two lists of questions against each other.",
|
|
189
211
|
],
|
|
190
212
|
expectedBehaviour: "Each category has its own page, keyed on the category's slug, rendering only its questions. Two category pages showing the same questions means the slug is not reaching the query.",
|
|
191
213
|
expectedUiState: "The Shipping page lists shipping questions and an expanded answer renders as formatted text, not as raw HTML shown as visible characters. The second category shows a different set.",
|
|
@@ -318,15 +340,22 @@ export const authored = {
|
|
|
318
340
|
startPage: "/admin/products",
|
|
319
341
|
steps: [
|
|
320
342
|
"Sign in as admin@letitrip.in / TempPass123!.",
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
343
|
+
// The batch seeds product-{{w}}-untokenised with NO searchTokens field —
|
|
344
|
+
// a stand-in for a record written before the token field existed. On a
|
|
345
|
+
// freshly-seeded catalogue every row carries current tokens, so without
|
|
346
|
+
// this fixture the case is unanswerable by construction, which is exactly
|
|
347
|
+
// what a tester reported: "the catalogue was freshly seeded... there is no
|
|
348
|
+
// older record to probe with".
|
|
349
|
+
"Open /admin/products and search for the exact word 'untokenised'.",
|
|
350
|
+
"Read whether product-{{w}}-untokenised is returned.",
|
|
351
|
+
"Search for a distinctive word from any ordinary seeded listing's title.",
|
|
352
|
+
"Read whether that one is returned.",
|
|
353
|
+
"Compare the two results.",
|
|
326
354
|
],
|
|
355
|
+
inputs: { untokenisedProduct: "product-{{w}}-untokenised", query: "untokenised" },
|
|
327
356
|
expectedBehaviour: "Search is backed by a normalised token field written on save. Records created before that field existed have no tokens, so they are unfindable — the fix is a backfill, and this case is what detects whether one is needed.",
|
|
328
|
-
expectedUiState: "
|
|
357
|
+
expectedUiState: "BOTH listings are returned by a word from their own titles. The ordinary one being findable while product-{{w}}-untokenised is not is the signature of missing tokens on older rows, not of a broken search — and it means a backfill is owed.",
|
|
329
358
|
expectedData: { oldRecordFound: true },
|
|
330
|
-
endResult: "Read-only;
|
|
359
|
+
endResult: "Read-only; the fixture is removed when the batch ends. Testing only recent records would pass this case while the backfill gap remains.",
|
|
331
360
|
},
|
|
332
361
|
};
|