diff --git a/09 - Audits/Doc vs Code Audit Report - 2026-05-29.md b/09 - Audits/Doc vs Code Audit Report - 2026-05-29.md new file mode 100644 index 0000000..f67549f --- /dev/null +++ b/09 - Audits/Doc vs Code Audit Report - 2026-05-29.md @@ -0,0 +1,1977 @@ +# Doc vs Code Audit Report — 2026-05-29 + +> **Scope:** 8 domains audited · 228 total findings · 35 critical · 123 major · 54 minor/info +> **Audit Date:** 2026-05-29 +> **Source:** Automated doc-vs-code agent audit of nick-doc flow documentation against live codebase + +## Executive Summary + +# Doc vs Code Audit — Executive Summary + +**Scope:** 8 domains · 228 findings · 35 critical · 123 major · 54 minor + +--- + +## 1. Top 10 Critical Discrepancies — Must Fix Before UAT + +These findings will cause testers to either file false bugs, miss real bugs, or test against endpoints that return 404. + +### 1. Delivery code roles are completely reversed (delivery + purchase-request) +The doc says the seller generates the code and the buyer enters it. The code is the exact opposite: the buyer calls `POST .../delivery-code/generate` (buyer-only enforced), verbalises the code, and the seller submits it via `POST .../delivery-code/verify` (seller-only enforced). The documented endpoint `POST .../verify-delivery` does not exist — the real path is `/delivery-code/verify`. Every UAT test case built from the doc will test the wrong actor against the wrong endpoint. + +### 2. Passkey attestation is fully implemented — doc says it is stubbed +The Passkey Flow doc claims `verifyRegistration` stores `publicKey: 'simulated-public-key'` and warns of a severe security risk. The backend calls `verifyRegistrationResponse()` from `@simplewebauthn/server` and stores the real COSE key. QA following the doc will skip attestation validation testing entirely. + +### 3. `deleteAccount` frontend action calls `DELETE /user/profile` — no such route exists +`action.ts` line 144 sends `DELETE /user/profile`. The actual soft-delete route is `DELETE /api/auth/account` (requires password in body, runs `deleteAccountValidation`). Account deletion silently returns 404 from every UI path today. + +### 4. PATCH `/api/disputes/:id/status` and `POST /api/disputes/:id/resolve` have no role guard +Any authenticated buyer or seller can change dispute status to `resolved` or `closed`, or post a resolution including `action=ban_seller`. The doc says both require `Bearer JWT (admin)`. These are privilege-escalation bugs reachable today via the live API. + +### 5. Three Trezor endpoints have no authentication despite the doc claiming admin-only +`POST /api/payment/payments/:id/fetch-tx`, `POST /api/payment/payments/auto-fetch-missing`, and `GET /api/payment/payments/:id/debug` have zero auth middleware. Any unauthenticated caller can read full payment internals or trigger on-chain state writes. `GET /api/admin/scanner/status` has the same problem. + +### 6. DePay intent endpoint: `/create` does not exist — only `/save` is implemented +All doc references to `POST /api/payment/decentralized/create` return 404. The real endpoint is `POST /api/payment/decentralized/save`. Any test harness built from the DePay flow sequence diagram will fail at step one. + +### 7. `POST /api/notifications/read-all` does not exist — correct endpoint is `PATCH /notifications/mark-all-read` +Both the step narrative and the API table are wrong on method (POST vs PATCH) and path (`read-all` vs `mark-all-read`). Every bulk-read test will 404. + +### 8. Seller offer create endpoint is `POST /purchase-requests/:id/offers`, not `POST /api/marketplace/offers` +No flat `/offers` route exists. Any integration test or API client hitting the documented flat path will 404. The three documented GET endpoints (`/offers/request/:requestId`, `/offers/seller/:sellerId`) also do not exist — the buyer list route is scoped under `/purchase-requests/:id/offers` and the seller history route has no HTTP exposure at all. + +### 9. `POST /api/disputes/:id/resolve` (dashboard) resolves the Dispute model record — it does NOT touch escrow +The API doc claims it "triggers refund/release/split escrow action." It does not. `DisputeService.resolveDispute` only updates the Dispute document. The separate `POST /api/disputes/:purchaseRequestId/resolve` (releaseHold router) is required to unblock escrow. Due to route shadowing (both routers mounted at `/api/disputes`), a `POST /api/disputes/{purchaseRequestId}/resolve` with a valid purchase-request ID will match the dashboard router first and execute the wrong handler. + +### 10. Frontend `updateUserStatus` sends `'inactive'`/`'pending'` — backend only accepts `'active'`/`'suspended'`/`'deleted'` +The TypeScript union type in `user.ts` line 159 is `'active' | 'inactive' | 'pending'`. The backend `User.status` enum is `active | suspended | deleted`. The value `'suspended'` is absent from the frontend; `'inactive'` and `'pending'` will be silently rejected or ignored by the backend. Combined with the fact that `updateUserStatus` and `updateUserRole` send `PUT` while the backend registers `PATCH`, these admin actions are broken on two axes. + +--- + +## 2. Top 5 Most Incomplete Flows + +### 1. Trezor Safekeeping Flow — entirely backend-only +Zero frontend implementation exists for any Trezor endpoint. There is no registration page, no xpub input, no Trezor Connect SDK import, and no signing UI. `confirmReleaseTx` and `confirmRefundTx` post `{ txHash }` with no `trezor` object. With `TREZOR_SAFEKEEPING_REQUIRED=true` every admin release/refund from the UI will be rejected by the backend `assertTrezorSignatureForOperation` guard. The flow describes a 12-step challenge-sign-submit sequence that has no frontend surface at any step. + +### 2. Dispute Flow — all socket events are TODO stubs; resolve has no escrow side-effect +Every socket.io emit block in `DisputeService` is commented out as TODO. No real-time updates fire for dispute creation, admin assignment, status changes, evidence uploads, or resolution. The flow doc describes real-time presence as a feature. Additionally, `POST /api/disputes/:id/assign` has no role guard (any user can self-assign as admin), and the documented `decision: buyer|seller|split` resolve schema is completely wrong — the real schema uses `action: refund|replacement|compensation|warning_seller|ban_seller|no_action`. + +### 3. Points/Referral Flow — five frontend pages do not exist +The following routes 404 today: `/dashboard/points/referrals`, `/dashboard/points/transactions`, `/dashboard/points/levels`. `redeemPoints` is never called from any component (no redemption UI). `generateReferralCode` is never called (no regenerate button). `adminAddPoints` has no admin UI page. The referral reward triggers only on `'completed'` status despite the doc saying `'delivered or completed'`. The leaderboard `period` filter (`all|month|week`) is silently ignored by the backend. + +### 4. Payment Flow — multiple provider routing failures and stub endpoints +`getProviderIntentEndpoint()` in `payment.ts` always routes to `request-network/intents` regardless of the `provider` argument — a SHKeeper checkout will silently POST to the wrong backend service. `GET /api/payment/shkeeper/status/:paymentId` (documented as a polling endpoint) does not exist anywhere in the backend or frontend; status transitions are socket-only. Five endpoints called by the frontend have no backend implementation: `/payment/history`, `/payment/methods`, `/payment/validate`, `/payment/transactions`, `/payment/escrow/balance`. The `PaymentProvider` TypeScript type excludes `'shkeeper'` and `'decentralized'`, causing provider-specific UI branches to fall through to unknown state for the two main payment providers used in production. + +### 5. Seller Offer Flow — withdraw, offer history, and notifications are absent +`POST /api/marketplace/offers/:id/withdraw` is documented and has a service method (`SellerOfferService.withdrawOffer`) but no HTTP route. No frontend withdraw button or action exists. No seller offer history page exists at `/dashboard/seller/marketplace/offers`. When a buyer uses `select-offer`, no per-seller socket events or notifications are sent to winning or losing sellers (only a generic `purchase-request-update` to the request room fires). The `'active'` status listed in the SellerOffer state machine does not exist in the schema enum — any attempt to set it will throw a Mongoose `ValidationError`. + +--- + +## 3. Backend Endpoints With No Frontend Coverage + +The following backend endpoints are implemented and accessible but have zero frontend action functions, pages, or components: + +**Auth/User** +- `DELETE /api/auth/account` (account deletion — frontend hits wrong path) +- `POST /api/user/profile/email/verify` and `POST /api/user/profile/email/resend-verification` (email re-verification after profile change) +- `POST /api/user/wallet-address/ton-proof/challenge` (TON wallet proof nonce) +- `PATCH /api/user/admin/:userId/password`, `POST /api/users/admin/:userId/resend-verification`, `PUT /api/users/admin/update/:email` + +**Trezor** (all four documented endpoints, plus two undocumented ones) +- `GET /api/trezor/registration-message`, `POST /api/trezor/register`, `POST /api/trezor/addresses/next`, `POST /api/trezor/operation-message`, `POST /api/trezor/verify-operation`, `GET /api/trezor/account` + +**Points** +- `POST /api/points/redeem` (redemption UI does not exist) +- `POST /api/points/generate-referral-code` (no regenerate button) +- `GET /api/points/levels` (levels page does not exist) +- `GET /api/points/referrals` (referrals page does not exist) +- `POST /api/points/admin/add` (no admin points management page) + +**Dispute** +- `POST /api/disputes/:purchaseRequestId/raise` (no frontend action) +- `GET /api/disputes/:purchaseRequestId/status` (no frontend action) + +**Chat** +- `POST /api/chat/purchase-request` (endpoint key in axios config but no action function) +- `PUT /api/chat/:id/participants/:participantId` (role update — no backend route exists either, so double gap) + +**Admin/Payment** +- `GET /api/admin/settings/aml`, `PATCH /api/admin/settings/aml` +- `GET /api/admin/settings/confirmation-thresholds/history` (frontend action defined but backend route absent) +- All shkeeper admin release/refund/payout endpoints (`POST /api/payment/:id/release`, `/release/confirm`, `/refund`, `/refund/confirm`) +- All data cleanup/GDPR endpoints (`POST /api/admin/cleanup/clean`, `DELETE /api/admin/cleanup/user/:userId`, `POST /api/admin/cleanup/seed-*`) + +--- + +## 4. Doc-Described Flows With No Backend Implementation + +These are flows the documentation describes as functional but for which the backend endpoint does not exist: + +| Documented Endpoint | Doc Claims | Reality | +|---|---|---| +| `POST /api/marketplace/offers/:id/withdraw` | Seller withdraws pending offer | No HTTP route; service method is dead code | +| `POST /api/marketplace/purchase-requests/:id/delivery-code` (bare POST) | Admin code regeneration | No such route; regenerate is also absent (`/regenerate` 404s) | +| `GET /api/marketplace/purchase-requests/search` | Search endpoint | No `/search` sub-path; use query params on list endpoint | +| `GET /api/marketplace/purchase-requests/stats` | Marketplace statistics | No `/stats` sub-path | +| `GET /api/marketplace/offers/seller/:sellerId` | Seller offer history | Route not registered; service method `getOffersBySeller()` unreachable via HTTP | +| `GET /api/payment/shkeeper/status/:paymentId` | Frontend polls for SHKeeper status | Endpoint absent; status is socket-only | +| `GET /payment/:id/status` and `POST /payment/:id/confirm` | Payment status check and confirmation | No `/status` or `/confirm` sub-routes on payment documents | +| `DELETE /payment/:id` | Cancel payment | No DELETE handler on any payment route | +| `POST /api/payment/request-network/:id/payout/initiate` and three related sub-paths | Admin RN payout/release/refund | None of the four sub-paths are implemented | +| `POST /api/notifications/read-all` | Bulk mark-all-read | Wrong method and path; real endpoint is `PATCH /notifications/mark-all-read` | +| `POST /api/admin/rn/networks/reload` and `POST /api/admin/rn/networks/probe/:chainId` | Network registry management | Only `GET /api/admin/rn/networks` exists | +| `GET /api/admin/settings/confirmation-thresholds/history` | Change history | Only current-values GET and per-chain PATCH exist | + +--- + +## 5. Recommended UAT Execution Order + +Execute in this order to unblock dependent flows and surface blockers earliest. + +**Phase 1 — Auth and User (prerequisite for all other flows)** +Test auth before everything else. Critical gaps: `deleteAccount` wrong endpoint, passkey attestation is live (test it), rate-limit counts all attempts not just failures, `reset-password-with-code` has no complexity validation. Verify `updateUserStatus`/`updateUserRole` with correct PATCH method and `'active'`/`'suspended'` values. Confirm admin delete is soft vs hard. Block UAT on other domains until login, registration, and session refresh are verified end-to-end. + +**Phase 2 — Purchase Request and Delivery (core escrow lifecycle)** +These two domains share the status progression that gates every other flow. Verify the full status sequence including the undocumented `pending_payment` and `active` statuses. Test the delivery code flow with correct role assignments (buyer generates, seller verifies). Confirm `PUT /delivery` (not `PATCH /:id`) is the seller shipped action. Verify `confirm-delivery` authorization gap (any authenticated user can call it today). + +**Phase 3 — Seller Offer and Negotiation** +Test offer creation against `POST /purchase-requests/:id/offers` (not the documented flat route). Verify `select-offer` status cascade does not corrupt withdrawn offers. Confirm `PUT` vs `PATCH` method for offer updates. Document that withdraw is only accessible via `PUT /offers/:id/status` with `status='withdrawn'` and that no seller notification fires from the `select-offer` path. + +**Phase 4 — Payment** +Test both SHKeeper and DePay flows with network interception to verify actual endpoint paths hit. Confirm the SIM_ bypass is present and works in staging (document for production gating). Verify `completed` payments do not count as `successfulPayments` in stats. Test the unauthenticated debug/fetch-tx/auto-fetch-missing endpoints — these are security bugs that should be escalated before production go-live regardless of UAT phase. + +**Phase 5 — Dispute** +All socket events are absent — do not waste UAT time on real-time behavior. Focus on CRUD correctness and the two privilege escalation bugs: non-admin can change dispute status and resolve disputes. Test the route shadowing between the two routers mounted at `/api/disputes`. Verify the actual resolve body schema (`action` + `notes`, not `decision` + `refundAmount`). + +**Phase 6 — Chat and Notification** +Test file upload via `POST /chat/:id/messages/file` (frontend sends to wrong endpoint today). Verify `archiveConversation` uses PATCH not PUT. Test rate limiting (20 msgs/min) and 15-minute edit window. For notifications, verify `PATCH /notifications/mark-all-read` (not the documented POST). Confirm `unread-count-update` drives cross-tab badge sync (not the documented `notification-read`). + +**Phase 7 — Points/Referral** +This domain has the most missing UI pages. UAT scope is limited to: points balance display, referral attribution (signup + reward on `completed` only), and leaderboard. All redemption, levels, referral history, and transaction history features are untestable via UI — test their API endpoints directly. + +**Phase 8 — Trezor** +No frontend exists. UAT is API-only. Verify registration challenge-sign flow directly via curl/Postman. Confirm `TREZOR_SAFEKEEPING_REQUIRED=false` in staging so other payment tests are not blocked. Escalate the admin release/refund gap as a known production blocker if safekeeping is intended to be enabled. + +--- + +## 6. Doc Update Priorities + +**Immediate (block UAT if not corrected):** +1. **Delivery flow** — swap all actor references (buyer↔seller) for code generation and verification; replace all documented endpoint paths with actual paths (`/delivery-code/generate`, `/delivery-code/verify`); remove the non-existent `/verify-delivery` and bare `/delivery-code` POST entries. +2. **Passkey flow** — remove all stub/simulated-public-key language; replace with accurate description of `@simplewebauthn/server` integration; remove the false refresh-token gap edge case. +3. **Dispute resolve schema** — replace `decision: buyer|seller|split` + `refundAmount` with `action: refund|replacement|compensation|warning_seller|ban_seller|no_action` + `amount` + `notes`; correct dispute categories (`product_quality`, `delivery_delay`, `wrong_item`, `payment_issue`, `seller_behavior`, `other` — not `fraud`); replace `under_review` with `in_progress`. +4. **Seller offer endpoints** — replace all three wrong GET paths with `GET /purchase-requests/:id/offers`; replace `POST /api/marketplace/offers` with `POST /purchase-requests/:id/offers`; remove the non-existent withdraw route. +5. **Payment DePay flow** — replace `/decentralized/create` with `/decentralized/save` everywhere; correct verify path to include `:paymentId` param; remove `/shkeeper/status/:paymentId` polling step. +6. **Notification endpoints** — replace `POST /api/notifications/read-all` with `PATCH /notifications/mark-all-read`; replace `POST /api/notifications/mark-read` with `PATCH /notifications/:id/read`; add `unread-count-update` to socket events table; remove fictional `notification-read` event. +7. **Admin auth gaps** — add explicit warning that `fetch-tx`, `auto-fetch-missing`, and `debug` payment endpoints currently have no authentication and are exploitable without credentials. + +**High priority (correct before handing doc to integration teams):** +8. **Purchase request status enum** — add `pending_payment` and `active` to all status lists; remove `finalized` and `archived` if not present in frontend types. +9. **Password reset code length** — correct all `8-digit` references to `6-digit` in backend API notable logic and `authController.ts` comment. +10. **Points redeem body schema** — replace `amount`/`purpose` with `pointsToUse`/`purchaseRequestId`; correct response shape to `{ transaction, discount, remainingPoints }`. +11. **Delivery role clarification** — confirm `confirm-delivery` authorization model; add note that any authenticated user can currently call it (authorization gap pending fix). +12. **PointTransaction type enum** — remove `refund` from status values list; the valid types are `earn | spend | expire` only. + +**Standard priority (before final doc release):** +13. Add `pending_payment` and `seller_paid` to notification templates gap documentation. +14. Document 90-day TTL auto-deletion of notifications. +15. Document chat rate limits (20 msgs/min, 15-minute edit window, 5000-char limit). +16. Document `escrowState: releasable` and `escrowState: releasing` values. +17. Document AML settings runtime-only persistence (changes lost on restart). +18. Add `unarchive` behavior to chat archive endpoint documentation (toggle semantics). +19. Document `markAsRead` with empty `messageIds` marks all messages as read. +20. Add `GET /api/trezor/account` and `POST /api/trezor/verify-operation` to Trezor API table. + +--- + +## Finding Statistics + +| Domain | Critical | Major | Minor | Total | +|--------|----------|-------|-------|-------| +| Authentication | 3 | 6 | 4 | 15 | +| User Management | 3 | 9 | 4 | 18 | +| Purchase Request | 4 | 15 | 6 | 27 | +| Delivery | 3 | 8 | 4 | 16 | +| Seller Offer | 3 | 8 | 4 | 15 | +| Payment | 5 | 13 | 7 | 27 | +| Dispute | 3 | 8 | 5 | 18 | +| Chat | 2 | 13 | 5 | 23 | +| Notification | 2 | 8 | 4 | 16 | +| Points/Referral | 2 | 13 | 4 | 19 | +| Trezor Safekeeping | 2 | 5 | 3 | 10 | +| Admin Operations | 3 | 17 | 4 | 24 | +| **TOTAL** | **35** | **123** | **54** | **228** | + +--- + +## Critical Findings (Must Fix Before UAT) + +> These findings will cause testers to test wrong actors, wrong endpoints, or expose live security vulnerabilities. + +### Domain: Authentication + +#### C1. Passkey: attestation stub claim is false — real @simplewebauthn/server is used + +**Description:** The Passkey Flow doc (step 6, edge case, and docFlags) repeatedly states that verifyRegistration stores publicKey as the literal string 'simulated-public-key' and that attestation parsing is stubbed, warning of a severe security risk. In reality, /Users/manwe/CascadeProjects/escrow/backend/src/services/auth/passkeyService.ts imports verifyRegistrationResponse and verifyAuthenticationResponse from @simplewebauthn/server (line 2), calls verifyRegistrationResponse() to cryptographically validate the attestation (line 158), and stores the real COSE public key as Buffer.from(webAuthnCredential.publicKey).toString('base64url') (line 175). Testers relying on this doc claim will file false critical security bugs and skip real attestation testing. + +**Doc Claim:** Step 6: 'appends { publicKey: 'simulated-public-key', ... }'. Edge case: 'Attestation validation is stubbed — publicKey is stored as literal string 'simulated-public-key', allowing a malicious client to register attacker-controlled credential IDs.' + +**Code Reality:** passkeyService.ts:158 calls verifyRegistrationResponse() from @simplewebauthn/server; on success, stores Buffer.from(webAuthnCredential.publicKey).toString('base64url') as publicKey. The stub has been replaced entirely. + +**UAT Impact:** QA must test that passkey registration rejects forged attestations and that the stored public key enables successful authentication on subsequent sign-ins. Do NOT skip attestation validation tests on the assumption the feature is stubbed. + +#### C2. Passkey: refresh tokens ARE persisted to user.refreshTokens[] — doc claims they are not + +**Description:** The Passkey Flow edge cases state 'Refresh-token rotation gap — passkey-issued refresh tokens are not added to user.refreshTokens[]. Standard /api/auth/refresh-token will reject them on next refresh.' This is false. passkeyService.ts:281 explicitly does user.refreshTokens.push(refreshToken) followed by user.save(). The standard token refresh endpoint will accept passkey-issued tokens. + +**Doc Claim:** Edge case: 'Refresh-token rotation gap — passkey-issued refresh tokens are not added to user.refreshTokens[]. Standard /api/auth/refresh-token will reject them on next refresh. User must passkey-sign-in again after token expiry.' + +**Code Reality:** passkeyService.ts lines 281-282: user.refreshTokens.push(refreshToken); await user.save(); — tokens are persisted normally. + +**UAT Impact:** QA must verify that after a passkey sign-in the standard /api/auth/refresh-token endpoint successfully rotates the token without requiring a new biometric prompt. + +#### C3. deleteAccount frontend action calls DELETE /user/profile which has no backend route + +**Description:** The frontend deleteAccount action in /Users/manwe/CascadeProjects/escrow/frontend/src/actions/account.ts (line 144) calls axiosInstance.delete(endpoints.users.profile) which resolves to DELETE /user/profile. There is no DELETE handler on that path in the backend. The actual soft-delete route is DELETE /api/auth/account (authRoutes.ts:86-89), which requires a password in the body and runs deleteAccountValidation. Calling DELETE /user/profile will return 404 or 405, making account deletion silently broken. + +**Doc Claim:** Frontend actions table: deleteAccount → apiPath: '/user/profile', method: DELETE + +**Code Reality:** Backend DELETE /auth/account exists (authRoutes.ts:86-89). No DELETE /user/profile route exists. The frontend action.ts sends to the wrong path. + +**UAT Impact:** QA must attempt to delete a test account from the UI and confirm the request reaches DELETE /api/auth/account (not /user/profile) and that the account is set to status=deleted in MongoDB. + +### Domain: Purchase Request + +#### C4. Delivery code flow: buyer generates, doc says buyer receives; seller verifies, doc says buyer enters code + +**Description:** The documented flow states the seller shares the 6-digit code verbally with the buyer at hand-off, and the buyer enters the code to confirm receipt (POST /api/marketplace/purchase-requests/:id/verify-delivery). In the actual backend, the buyer generates the code (POST .../delivery-code/generate, restricted to buyerId) and shares it with the seller, who then verifies it (POST .../delivery-code/verify, restricted to the selected seller). The frontend confirms this: step-5-receive-goods.tsx auto-generates and displays the code to the buyer, while delivery-code-verification.tsx is rendered for the seller to type the code in. + +**Doc Claim:** Seller shares the 6-digit code verbally with the buyer at hand-off. Buyer enters the code via POST /api/marketplace/purchase-requests/:id/verify-delivery. + +**Code Reality:** Buyer generates the code (POST .../delivery-code/generate), sees it in their dashboard, and verbally gives it to the seller. The seller enters it via POST .../delivery-code/verify. The backend enforces this: generate checks buyerId, verify checks selectedOffer.sellerId. + +**UAT Impact:** QA must verify that (1) the buyer dashboard shows the delivery code in step-5-receive-goods, (2) the seller dashboard shows the code-entry field in delivery-code-verification, and (3) the backend returns 403 if either role tries the other's endpoint. + +#### C5. Doc lists POST /api/marketplace/purchase-requests/:id/verify-delivery; actual endpoint is POST .../delivery-code/verify + +**Description:** The Delivery Confirmation Flow documentation lists 'POST /api/marketplace/purchase-requests/:id/verify-delivery' as the endpoint for code verification. Neither controllerRoutes.ts nor routes.ts registers this path. The real endpoint is POST /api/marketplace/purchase-requests/:id/delivery-code/verify. The frontend correctly uses the real path via endpoints.delivery.verifyCode in src/lib/axios.ts. + +**Doc Claim:** POST /api/marketplace/purchase-requests/:id/verify-delivery + +**Code Reality:** POST /api/marketplace/purchase-requests/:id/delivery-code/verify (registered in routes.ts line 2790) + +**UAT Impact:** Any test harness or API client using the documented path will receive 404. Verify the correct path is used end-to-end. + +#### C6. Doc lists POST /api/marketplace/purchase-requests/:id/delivery-code for code regeneration; endpoint does not exist + +**Description:** The Delivery Confirmation Flow lists 'POST /api/marketplace/purchase-requests/:id/delivery-code' as the admin regeneration endpoint. No such POST route exists in routes.ts or controllerRoutes.ts. The backend only has GET .../delivery-code (retrieve code), POST .../delivery-code/generate (create new code), and POST .../delivery-code/verify. The frontend's regenerateDeliveryCode action calls a non-existent '/delivery-code/regenerate' endpoint and falls back to generate. No POST to the bare /delivery-code path is registered anywhere. + +**Doc Claim:** POST /api/marketplace/purchase-requests/:id/delivery-code — Manual code regeneration (admin) + +**Code Reality:** No such endpoint exists. Only GET /delivery-code (retrieve), POST /delivery-code/generate (buyer), and POST /delivery-code/verify (seller) are registered. + +**UAT Impact:** Admin code regeneration is broken. Testers must confirm that expired code recovery path is unavailable and document the workaround. + +#### C7. Backend status enum includes 'pending_payment' and 'active'; these are absent from documented statuses + +**Description:** The actual PurchaseRequest.status enum in the codebase (confirmed in both routes.ts logic and the frontend IPurchaseRequest type at src/types/marketplace.ts line 107-119) includes 'pending_payment' and 'active' as valid statuses. Neither appears in the documented Purchase Request Flow status list. The STATUS_PROGRESSION_ORDER noted in backend code also includes 'active' between 'pending' and 'received_offers'. Template batch-convert can create requests in 'pending_payment' or 'active' initial states. + +**Doc Claim:** Statuses: pending, received_offers, in_negotiation, payment, processing, delivery, delivered, confirming, seller_paid, completed, finalized, archived, cancelled + +**Code Reality:** Actual statuses include pending_payment and active (confirmed in IPurchaseRequest type and routes.ts workflow-steps handling for status:'pending_payment' and status:'active' branches). 'finalized' and 'archived' appear only in the doc but not in the frontend type definition. + +**UAT Impact:** Test cases for status-based visibility, progression guards, and UI step rendering must cover 'pending_payment' and 'active'. Missing 'finalized'/'archived' in frontend types means those statuses cannot be rendered. + +### Domain: Seller Offer + +#### C8. Doc claims POST /api/marketplace/offers creates an offer; actual endpoint is POST /api/marketplace/purchase-requests/:id/offers + +**Description:** The documented API table lists 'POST /api/marketplace/offers' as the create-offer endpoint. No such flat route exists in the backend. The real endpoint registered in routes.ts line 1163 and marketplaceController.ts line 881 is POST /api/marketplace/purchase-requests/:id/offers, where :id is the purchaseRequestId path parameter. The frontend correctly uses the scoped path (src/lib/axios.ts endpoints.marketplace.requests.offers), so the mismatch is between doc and code, not frontend and backend. + +**Doc Claim:** POST /api/marketplace/offers — Create offer + +**Code Reality:** Route is POST /api/marketplace/purchase-requests/:id/offers (purchaseRequestId in path, not in body). Defined in routes.ts and marketplaceController.ts; frontend uses endpoints.marketplace.requests.offers which maps to this path. + +**UAT Impact:** Any tester or integration client hitting POST /api/marketplace/offers will get a 404. Verify that the create-offer call goes to /purchase-requests/:id/offers. + +#### C9. POST /api/marketplace/offers/:id/withdraw endpoint documented but does not exist + +**Description:** The flow at step 16 and the API table both document 'POST /api/marketplace/offers/:id/withdraw — Seller withdraws'. The SellerOfferService.withdrawOffer() method exists (SellerOfferService.ts lines 427-443), but searching all of routes.ts and marketplaceController.ts finds zero route that calls it. The only way to achieve a 'withdrawn' status today is via PUT /api/marketplace/offers/:id/status with { status: 'withdrawn' }, which is accessible to seller, buyer, or admin without status guard. The dedicated withdraw route is entirely absent. + +**Doc Claim:** POST /api/marketplace/offers/:id/withdraw — Seller withdraws; blocked once accepted or rejected. + +**Code Reality:** No HTTP route for /offers/:id/withdraw exists. withdrawOffer() service method is dead code from the API surface. PUT /offers/:id/status accepts 'withdrawn' as a value but applies no pending-only guard at the route level (only the service-level SellerOfferService.updateOfferStatus does not guard status transitions either). + +**UAT Impact:** The documented seller withdraw flow cannot be tested as documented. Testers must use PUT /offers/:id/status with { status: 'withdrawn' } and verify the pending-only guard is NOT enforced at the route level (regression risk). + +#### C10. Doc lists GET /api/marketplace/offers/request/:requestId and GET /api/marketplace/offers/seller/:sellerId — neither route exists + +**Description:** The API table documents two GET endpoints: 'GET /api/marketplace/offers/request/:requestId — Buyer view of offers on a request' and 'GET /api/marketplace/offers/seller/:sellerId — Seller's own offer history'. Neither route is registered in routes.ts or marketplaceController.ts. The actual routes are GET /api/marketplace/purchase-requests/:id/offers (list offers for a request) and there is no seller-history endpoint at all (getOffersBySeller() service method exists but is unexposed). The frontend uses the purchase-requests-scoped path correctly. + +**Doc Claim:** GET /api/marketplace/offers/request/:requestId and GET /api/marketplace/offers/seller/:sellerId are documented as valid API endpoints. + +**Code Reality:** GET /api/marketplace/purchase-requests/:id/offers is the real list endpoint (routes.ts line 1223). No route exists for /offers/seller/:sellerId; getOffersBySeller() in SellerOfferService is unreachable via HTTP. + +**UAT Impact:** Hitting the documented GET paths returns 404. Verify buyer offer listing uses /purchase-requests/:id/offers. No seller offer history endpoint can be tested at all. + +### Domain: Payment + +#### C11. DePay flow: /api/payment/decentralized/create does not exist; only /save is implemented + +**Description:** The sequence diagram and API table in the DePay/Web3 flow documentation both reference POST /api/payment/decentralized/create as the intent-creation endpoint. The actual backend code exposes only POST /api/payment/decentralized/save for this purpose. There is no /create route in decentralizedPaymentRoutes. The frontend axios config in src/lib/axios.ts also only defines endpoints.payments.decentralized.save. Any external documentation or integration test that calls /create will receive a 404. + +**Doc Claim:** POST /api/payment/decentralized/create (sequence diagram and API table) creates the payment intent + +**Code Reality:** Only POST /api/payment/decentralized/save exists in both the backend route list and frontend endpoint config. No /create route is registered. + +**UAT Impact:** QA must verify that the intent-creation step POSTs to /api/payment/decentralized/save and NOT /create. Any test harness using /create will silently fail with 404. + +#### C12. DePay verify path mismatch: step narrative says :paymentId path param; API table says no path param + +**Description:** The DePay flow step narrative specifies POST /api/payment/decentralized/verify/:paymentId with the payment ID as a path parameter. The API table documents POST /api/payment/decentralized/verify with no path param. The backend code exposes POST /api/payment/decentralized/verify/:paymentId. The frontend paymentBackendService.ts calls /api/payment/decentralized/verify/${paymentId} as a path param. The API docs table entry is wrong and will mislead QA and API consumers. + +**Doc Claim:** API table: POST /api/payment/decentralized/verify (no path param). Step narrative: POST /api/payment/decentralized/verify/:paymentId + +**Code Reality:** Backend route is POST /api/payment/decentralized/verify/:paymentId. Frontend src/web3/paymentBackendService.ts line 427 calls this with paymentId in the path. + +**UAT Impact:** Tester must call POST /api/payment/decentralized/verify/:paymentId. Calling /verify without a path param will return 404. + +#### C13. Frontend calls GET /payment/:id/status and POST /payment/:id/confirm — neither endpoint exists on backend + +**Description:** The frontend actions getPaymentStatus() and getPaymentStatus()/confirmPayment() build URLs as /payment/:id/status and /payment/:id/confirm respectively. Neither endpoint is registered in the backend route list. The backend has no /status sub-route and no /confirm sub-route on individual payment documents. The getPaymentStatus action is actively called from the dispute payment-details-card component (src/sections/dispute/components/payment-details-card.tsx line 101), meaning the 'Verify' button in the dispute panel will always return a 404. + +**Doc Claim:** API docs list GET /api/payment/:id with a payment document response but no /status or /confirm sub-routes. Frontend actions docs claim apiPath: /payment/:id/status and /payment/:id/confirm. + +**Code Reality:** Backend routes: GET /api/payment/payments/:id (with auth) and GET /api/payment/:id (controller). No /status or /confirm sub-routes exist. dispute/payment-details-card.tsx actively calls getPaymentStatus() which hits the non-existent /status path. + +**UAT Impact:** QA must verify: (1) the 'Verify' button in the dispute payment card returns a 404 in practice; (2) confirmPayment() is not reachable via the normal checkout UI, check whether any live component actually calls it. + +#### C14. Frontend calls DELETE /payment/:id to cancel payment — no DELETE route exists + +**Description:** cancelPayment() in src/actions/payment.ts sends a DELETE request to /payment/:id. The backend code exposes no DELETE method on any payment route. The backend only has GET and PUT on that path. cancelPayment() is locally defined in the web3 context as a UI state reset (no HTTP call), but the action-layer version makes a real HTTP DELETE that will 404. + +**Doc Claim:** Frontend actions list cancelPayment with apiPath: /payment/:id and method: DELETE. + +**Code Reality:** Backend has no DELETE handler on /api/payment/:id or /api/payment/payments/:id. The cancelPayment used in web3-provider.tsx is a local state reset and does not call the action. + +**UAT Impact:** QA must confirm that no UI flow calls the action-layer cancelPayment(). If any component imports and invokes it, it will 404. + +#### C15. SHKeeper flow documents GET /api/payment/shkeeper/status/:paymentId — endpoint does not exist + +**Description:** The SHKeeper flow step 32 states the frontend polls GET /api/payment/shkeeper/status/:paymentId to transition to 'Payment received'. This endpoint is listed in the flow's apiEndpoints. The actual backend code and API docs do not register this route. The frontend also has no call to this path in axios config or component code. The frontend actually relies on socket events (payment-update) and the template-checkout-payment-confirmed custom event for status transitions, not polling. + +**Doc Claim:** GET /api/payment/shkeeper/status/:paymentId is listed as an API endpoint used by the frontend checkout page for polling. + +**Code Reality:** No such route exists in the backend code. Frontend checkout components listen to socket events (payment-update, template-checkout-payment-confirmed) rather than polling any shkeeper status endpoint. + +**UAT Impact:** QA must verify that the SHKeeper checkout page does not call this path. Status transitions should be tested via socket event reception, not HTTP polling. + +### Domain: Dispute + +#### C16. PATCH /api/disputes/:id/status has no role guard — any authenticated user can change dispute status + +**Description:** The updateStatus controller method calls DisputeService.updateStatus with no call to authorizeRoles. Any logged-in buyer or seller can therefore PATCH /api/disputes/:id/status to set status=resolved, status=closed, or even status=rejected, completely bypassing admin authority. The dashboard router in /backend/src/routes/disputeRoutes.ts attaches only authenticateToken. + +**Doc Claim:** API docs state 'Bearer JWT (admin)' is required for PATCH /api/disputes/:id/status. Flow step 11 describes this as an admin-only action. + +**Code Reality:** No authorizeRoles guard exists in DisputeController.updateStatus or in the route definition. Any authenticated user with the dispute ID can transition the dispute to any status including resolved or closed. + +**UAT Impact:** QA must verify that a buyer or seller token (non-admin) is rejected with 403 when calling PATCH /api/disputes/:id/status. Currently it succeeds with 200, which is a privilege-escalation bug in production. + +#### C17. POST /api/disputes/:id/resolve (dashboard) has no role guard — any user can resolve a dispute + +**Description:** The dashboard resolveDispute controller in /backend/src/controllers/disputeController.ts does not call authorizeRoles('admin'). Only authenticateToken is applied on the router. Any authenticated buyer, seller, or third party who knows the dispute _id can post a resolution including action=ban_seller. + +**Doc Claim:** API docs state 'Bearer JWT (admin)' is required for POST /api/disputes/:id/resolve. Flow step 12 treats resolution as an admin action. + +**Code Reality:** No authorizeRoles guard in the controller or route. Any authenticated user can call POST /api/disputes/:id/resolve with any action value. The releaseHold resolve endpoint (/api/disputes/:purchaseRequestId/resolve) correctly uses authorizeRoles('admin'), but the dashboard router does not. + +**UAT Impact:** QA must verify that POST /api/disputes/:disputeId/resolve returns 403 for non-admin tokens. Currently returns 200 and persists the resolution, including destructive actions like ban_seller. + +#### C18. Route shadowing: /:purchaseRequestId/raise and /:purchaseRequestId/resolve may collide with /:id routes + +**Description:** Both /api/disputes route handlers (dashboardDisputeRoutes at line 521 and releaseHold disputeRoutes at line 585 in /backend/src/app.ts) are mounted on the same path /api/disputes. Express processes them in registration order. POST /api/disputes/:purchaseRequestId/resolve in the second router can potentially shadow POST /api/disputes/:id/resolve in the first router if the param patterns match. GET /api/disputes/:purchaseRequestId/status is defined only in the second router, but a request like GET /api/disputes/abc123/status could match GET /api/disputes/:id in the first router and return a 404 or wrong response before reaching the status handler — depending on Express route matching order. + +**Doc Claim:** The doc's docFlags flag this as a potential problem: 'Second mount's paths overlap with :id routes from the first mount — potential route shadowing risk.' + +**Code Reality:** Confirmed in app.ts: dashboardDisputeRoutes is mounted first (line 521); releaseHold disputeRoutes is mounted second (line 585). Express evaluates routes in order, so for POST /api/disputes/:purchaseRequestId/resolve the dashboard router's POST /:id/resolve will match first, executing the Dispute CRUD resolve instead of the purchaseRequest hold-clear logic. + +**UAT Impact:** QA must test POST /api/disputes/{purchaseRequestId}/resolve with a valid purchaseRequestId to confirm whether the hold-clearing logic fires or the Dispute model resolve fires. The outcome depends on whether the ID happens to match a Dispute _id. This is non-deterministic and high-severity. + +### Domain: Chat + +#### C19. sendFileMessage posts to wrong endpoint — missing /file suffix + +**Description:** The frontend sendFileMessage action in /frontend/src/actions/chat.ts (line 386) sends multipart form data to endpoints.chat.sendMessage which resolves to POST /api/chat/:id/messages. The actual file upload endpoint on the backend is POST /api/chat/:id/messages/file. The API docs also list the correct path as POST /api/chat/:id/messages/file. As a result, file uploads hit the text-message handler, which expects a JSON body with a string content field, not a multipart file payload — the upload will fail or silently discard the attachment. + +**Doc Claim:** Flow doc step 13 and API docs specify POST /api/chat/:chatId/upload (flow doc) or POST /api/chat/:id/messages/file (API docs) for file uploads. + +**Code Reality:** Frontend sendFileMessage POSTs multipart/form-data to /chat/:id/messages (same endpoint as text messages). The axios endpoints object has no entry for /chat/:id/messages/file — only sendMessage: '/chat/:id/messages'. + +**UAT Impact:** QA must verify: pick a file in the chat input, send it, and confirm the backend receives it at the /messages/file route and returns a message with an attachments array. Expect this to fail with the current code. + +#### C20. archiveConversation uses PUT but backend exposes PATCH /api/chat/:id/archive + +**Description:** The frontend archiveConversation action (/frontend/src/actions/chat.ts line 289) calls axiosInstance.put(...), and the axios endpoints config entry is '/chat/:id/archive'. The backend registers this as PATCH /api/chat/:id/archive and the API docs also document it as PATCH. HTTP method mismatch means the frontend will receive a 404 or 405 from the backend on every archive attempt. + +**Doc Claim:** API docs list PATCH /api/chat/:id/archive. Backend code registers PATCH /api/chat/:id/archive. + +**Code Reality:** Frontend action calls axiosInstance.put(endpoints.chat.archive...) — HTTP verb is PUT, not PATCH. + +**UAT Impact:** QA must verify: attempt to archive a chat from the UI and confirm the backend receives a PATCH request and returns success. With the current code this will fail. + +### Domain: Notification + +#### C21. POST /api/notifications/read-all does not exist — correct method is PATCH + +**Description:** The flow doc's step 8 narrative references 'POST /api/notifications/read-all' for bulk marking. The API table also lists 'POST /api/notifications/read-all'. In reality the backend exposes this as PATCH /notifications/mark-all-read (not POST, and not at the /read-all path). The frontend correctly uses PATCH /notifications/mark-all-read (as seen in axios.ts endpoints.notifications.markAllRead and the actions/notification.ts markAllNotificationsAsRead function). Any test or integration that POSTs to /notifications/read-all will receive a 404. + +**Doc Claim:** POST /api/notifications/read-all (mark all notifications read) + +**Code Reality:** PATCH /notifications/mark-all-read — method is PATCH not POST, and the path segment is 'mark-all-read' not 'read-all' + +**UAT Impact:** Verify that clicking 'Mark all read' in the notifications drawer sends PATCH /notifications/mark-all-read and returns modifiedCount. A POST to /notifications/read-all must 404. + +#### C22. GET /notifications/:id is a broken workaround — only returns the user's most-recent notification + +**Description:** The backend's getNotificationById controller does not perform a direct DB lookup. Instead it calls getUserNotifications(userId, 1, 1) — fetching page 1 with limit 1 — and then does an in-memory _id string match. This means any notification that is not the single most-recent record for that user will always return 404, regardless of ownership. This endpoint is completely unreliable for direct notification lookup by ID. The flow documentation does not mention this endpoint at all. + +**Doc Claim:** No mention of GET /notifications/:id in the flow doc + +**Code Reality:** GET /notifications/:id exists but silently fails for all but the user's most-recent notification due to a pagination bug in the controller + +**UAT Impact:** Attempt to fetch a notification by its ID when it is not the user's latest notification — expect 404 erroneously. Verify this regression before exposing the endpoint to consumers. + +### Domain: Points/Referral + +#### C23. Referral flow doc claims 'referral-signup' socket event; backend never emits it from PointsService + +**Description:** The Referral Flow doc lists 'referral-signup' as a PointsService-emitted socket event. In reality, PointsService.processReferralReward emits 'referral-reward', not 'referral-signup'. The 'referral-signup' event is only emitted from authController.ts (lines 704, 1132) during the sign-up attribution step — not from PointsService. The frontend points-main-view.tsx listens for both 'referral-signup' and 'referral-reward' and handles them separately, so wiring is functional, but the doc incorrectly attributes 'referral-signup' as a PointsService/points-domain event rather than an auth-domain event. + +**Doc Claim:** Referral Flow step 7: 'emits referral-signup to user-{referrerId} with the referee's name, email, and updated total'. Socket events section lists 'referral-signup — emitted to user-{referrerId} on referee creation'. + +**Code Reality:** PointsService only emits 'referral-reward' (PointsService.ts:417). 'referral-signup' is emitted in authController.ts (lines 704, 1132) and is an auth-domain event, not a points-domain event. + +**UAT Impact:** Verify that when a new user registers with a referral code, the referrer's dashboard receives a toast notification. Then verify separately that when that referred user completes a purchase and the request is marked 'completed', the referrer receives a second 'referral-reward' toast with points earned. + +#### C24. PointTransaction type 'refund' in doc does not exist; actual types are 'earn', 'spend', 'expire' + +**Description:** The Referral Flow doc lists 'PointTransaction.type: refund' as a valid status value. The actual Mongoose schema enum for PointTransaction.type is ['earn', 'spend', 'expire']. There is no 'refund' type. The frontend IPointTransaction interface also correctly uses 'earn' | 'spend' | 'expire'. Any attempt to create a refund-type transaction will fail schema validation. + +**Doc Claim:** Referral Flow statuses list 'PointTransaction.type: refund' as a valid type. + +**Code Reality:** PointTransaction model (schema enum): ['earn', 'spend', 'expire']. No 'refund' type exists anywhere in the schema or service code. + +**UAT Impact:** Confirm that there is no refund/reversal mechanism for points. If a purchase is cancelled after points were redeemed, check whether points are restored — if they are, verify the mechanism used (it should create an 'earn' transaction, not a 'refund' type). + +### Domain: User Management + +#### C25. Frontend admin actions use /users/admin/* (legacy) instead of /user/admin/* (new controller) + +**Description:** All admin mutation endpoints in user.ts (createUser, updateUser, deleteUser, updateUserStatus, updateUserRole, toggleUserStatus, getUserDependencies, getAllUsers) resolve via endpoints.users.admin.* which map to /users/admin/... paths (the legacy router). The new controller routes live under /api/user/admin/... (singular). The two controllers have meaningfully different behavior: the legacy DELETE does a hard findByIdAndDelete, whereas the new DELETE does a soft status='deleted'. The legacy status update uses an isActive boolean; the new controller accepts a status string (active/suspended/deleted). Sending traffic to the wrong router produces incorrect behavior silently. + +**Doc Claim:** API doc lists both /api/user/admin/... (primary) and /api/users/admin/... (legacy alias) endpoints. Frontend action doc says createUser -> /users/admin/create, deleteUser -> /users/admin/:id (soft delete). + +**Code Reality:** axios.ts maps endpoints.users.admin.* to /users/admin/* (legacy). The legacy DELETE is a hard findByIdAndDelete; the new /api/user/admin/:userId DELETE is a soft delete setting status='deleted'. Frontend action comment says 'soft delete' but calls the hard-delete legacy route. + +**UAT Impact:** QA must verify: (1) deleteUser actually soft-deletes (status='deleted') vs hard-deletes the record. (2) updateUserStatus sends correct payload format — frontend sends { status } string but legacy endpoint may expect { isActive } boolean. (3) createUser, toggleUserStatus, getUserDependencies, getAllUsers — confirm they hit the intended controller by checking response shape and DB state. + +#### C26. updateUserStatus and updateUserRole use PUT but backend only accepts PATCH + +**Description:** In user.ts, updateUserStatus calls axiosInstance.put(...) and updateUserRole calls axiosInstance.put(...). The backend exposes these as PATCH /api/users/admin/:userId/status and PATCH /api/users/admin/:userId/role (both legacy and new controllers). PUT is not registered for these sub-routes; the calls will receive 404 or 405 in production. + +**Doc Claim:** API doc specifies PATCH /api/user/admin/:userId/status and PATCH /api/user/admin/:userId/role. + +**Code Reality:** user.ts line 162: axiosInstance.put(endpoints.users.admin.status...). user.ts line 175: axiosInstance.put(endpoints.users.admin.role...). Backend registers PATCH, not PUT. + +**UAT Impact:** QA must exercise the 'Update Status' and 'Update Role' admin actions and confirm HTTP 200 is returned, not 404/405. Test should also verify the DB record is actually updated. + +#### C27. Frontend updateUserStatus sends wrong status values + +**Description:** The TypeScript signature for updateUserStatus accepts 'active' | 'inactive' | 'pending' and sends the value as-is in the request body. The backend (both controllers) only recognises 'active', 'suspended', and 'deleted' as valid status values. The values 'inactive' and 'pending' are not valid on the backend and will be rejected or silently ignored. + +**Doc Claim:** API doc states PATCH /api/user/admin/:userId/status accepts isActive boolean (new controller) or status string active/suspended/deleted. + +**Code Reality:** user.ts line 157-168: status typed as 'active' | 'inactive' | 'pending'. Backend User.status enum: active, suspended, deleted. + +**UAT Impact:** QA must attempt to set a user status to 'inactive' and 'pending' via the admin UI and confirm these are rejected by the backend. Only 'active' and 'suspended' (and 'deleted') should be accepted. + +### Domain: Admin Operations + +#### C28. fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +**Description:** The API doc lists POST /api/payment/payments/:id/fetch-tx and POST /api/payment/payments/auto-fetch-missing as admin-protected endpoints requiring Bearer JWT with role=admin. The actual backend registers both with NO authentication middleware at all — any unauthenticated caller can trigger on-chain fetches or read full payment state. The GET /api/payment/payments/:id/debug endpoint has the same problem (also has no auth). + +**Doc Claim:** POST /api/payment/payments/:id/fetch-tx and POST /api/payment/payments/auto-fetch-missing require Bearer JWT, role=admin + +**Code Reality:** Both endpoints are mounted with no authenticateToken middleware. The backend notableLogic explicitly flags this: 'UNPROTECTED DEBUG/UTILITY ENDPOINTS: ... have NO authentication middleware — any unauthenticated caller can read full payment internals or trigger on-chain fetches.' + +**UAT Impact:** QA must verify: (1) call POST /api/payment/payments/test123/fetch-tx without an Authorization header — it should return 401 but currently returns 200. (2) Same for auto-fetch-missing. (3) Same for GET /api/payment/payments/:id/debug. All three are exploitable in production without credentials. + +#### C29. GET /api/admin/scanner/status has no authentication middleware despite being under /api/admin/ + +**Description:** The scanner status proxy endpoint sits under the /api/admin/ route prefix, which would conventionally imply admin auth. The API doc lists it as requiring Bearer JWT role=admin. But the backend explicitly has no authenticateToken middleware on this route — it proxies directly to AMN_SCANNER_URL without any auth check. + +**Doc Claim:** GET /api/admin/scanner/status requires Bearer JWT, role=admin + +**Code Reality:** Backend notes: 'SCANNER STATUS PROXY: GET /api/admin/scanner/status has no authenticateToken middleware despite being an /api/admin/ route.' Any unauthenticated request can query the scanner. + +**UAT Impact:** QA must verify: call GET /api/admin/scanner/status without Authorization header and confirm it currently returns scanner data (200). It should return 401. + +#### C30. Shkeeper release/refund doc paths do not match backend paths + +**Description:** The API doc describes four admin shkeeper endpoints under the /api/payment/shkeeper/:id/ prefix: release, release/confirm, refund, and refund/confirm. The actual backend routes these under /api/payment/:id/ (without the /shkeeper/ segment). Any client built against the documented paths will receive 404 errors. + +**Doc Claim:** POST /api/payment/shkeeper/:id/release, POST /api/payment/shkeeper/:id/release/confirm, POST /api/payment/shkeeper/:id/refund, POST /api/payment/shkeeper/:id/refund/confirm + +**Code Reality:** Backend registers: POST /api/payment/:id/release, POST /api/payment/:id/release/confirm, POST /api/payment/:id/refund, POST /api/payment/:id/refund/confirm. The /shkeeper/ path segment is absent. The frontend payment actions also use the correct /payment/:id/* path (via endpoints.payments.details), confirming the doc is wrong. + +**UAT Impact:** QA must verify: POST /api/payment/shkeeper/{id}/release returns 404. POST /api/payment/{id}/release with valid admin token returns the expected escrow-release transaction. + +### Domain: Trezor Safekeeping + +#### C31. No frontend implementation for any Trezor API endpoint + +**Description:** A comprehensive search across all .ts and .tsx files in /Users/manwe/CascadeProjects/escrow/frontend/src finds zero calls to any of the four documented Trezor endpoints (GET /api/trezor/registration-message, POST /api/trezor/register, POST /api/trezor/addresses/next, POST /api/trezor/operation-message) and also zero calls to the undocumented POST /api/trezor/verify-operation and GET /api/trezor/account endpoints that exist in the backend. The only Trezor reference in the entire frontend src tree is a brand logo entry in wallet-icons.ts. There is no Trezor registration page, no xpub input, no signing UI, and no admin Trezor safekeeping panel anywhere in the frontend. + +**Doc Claim:** Step 1: 'User connects a Trezor in the frontend and exports an Ethereum account xpub.' Steps 2-4 describe a frontend-driven registration challenge-sign-submit flow. Steps 9-11 describe admin Trezor signing for operation approval. + +**Code Reality:** No frontend page, component, action, or hook references any /api/trezor/* endpoint. The Trezor Connect SDK is not imported anywhere. The frontend actions/payment.ts confirmReleaseTx and confirmRefundTx only send { txHash } with no trezor object (message+signature). + +**UAT Impact:** The entire Trezor registration flow is untestable via the frontend UI. There is no page to navigate to for xpub registration. The admin Trezor sign-before-release flow is also untestable. QA must verify whether any external (non-Next.js) admin tool handles this, or whether this feature is entirely backend-only at this stage. + +#### C32. Release/refund confirmation does not include Trezor signature payload + +**Description:** When TREZOR_SAFEKEEPING_REQUIRED=true the backend assertTrezorSignatureForOperation guard blocks release/refund unless the request body includes a valid trezor object (message + signature). The frontend confirmReleaseTx and confirmRefundTx in /Users/manwe/CascadeProjects/escrow/frontend/src/actions/payment.ts post only { txHash, ...extra } — there is no code anywhere in the frontend that builds an operation-message request, prompts for Trezor signing, or appends the resulting signature to the release/refund confirmation body. In safekeeping-enforced mode, every admin release/refund attempt from the frontend will be rejected by the backend. + +**Doc Claim:** Steps 9-12: 'Admin requests the exact operation message via POST /api/trezor/operation-message, signs the operation message with the Trezor, submits release/refund confirmation including txHash and trezor object (message + signature).' + +**Code Reality:** confirmReleaseTx (line 487) and confirmRefundTx (line 503) in src/actions/payment.ts post { txHash, ...extra } — no trezor field is ever constructed or passed. No component in the dispute or admin sections calls POST /api/trezor/operation-message or POST /api/trezor/verify-operation. + +**UAT Impact:** QA must verify: (1) with TREZOR_SAFEKEEPING_REQUIRED=true, admin release/refund from the UI fails with a backend 4xx; (2) with TREZOR_SAFEKEEPING_REQUIRED=false, release/refund works normally. Confirm whether the gap is intentional (feature flagged off) or a missing implementation. + +### Domain: Delivery + +#### C33. Delivery code is generated by the buyer, not the seller/admin + +**Description:** The documentation step-by-step narrative describes the seller clicking 'Mark as shipped' which triggers code generation as a backend side-effect. In reality, code generation is a separate buyer-initiated action via POST /api/marketplace/purchase-requests/:id/delivery-code/generate. Both the legacy routes.ts (line 2738) and the marketplaceController (line 1403) enforce that only the buyer (request.buyerId === userId) may call this endpoint. The seller has no role in triggering code generation. + +**Doc Claim:** Step 4 states: 'Backend invokes DeliveryService.generateDeliveryCode(requestId)' as an automatic side-effect of the seller marking shipment. The API table lists 'POST /api/marketplace/purchase-requests/:id/delivery-code — Manual code regeneration (admin)'. + +**Code Reality:** POST /api/marketplace/purchase-requests/:id/delivery-code/generate is restricted to the buyer (buyerId check), requires status='delivery', and is a manually triggered action. There is no automatic code generation when the seller marks shipped. The endpoint listed in the doc (/delivery-code without the /generate suffix) does not exist in the backend; the actual path is /delivery-code/generate. + +**UAT Impact:** QA must verify: (1) seller cannot call POST /delivery-code/generate — must receive 403; (2) admin cannot call it either — must receive 403; (3) buyer must explicitly call the endpoint after status reaches 'delivery' to get a code; (4) marking shipped does NOT auto-generate a code. + +#### C34. Delivery code is verified by the seller, not the buyer + +**Description:** The documentation states 'Buyer enters the code in the dashboard' and 'Frontend sends POST /api/marketplace/purchase-requests/:id/verify-delivery with {code}'. In reality the code is entered and submitted by the seller. Both routes.ts (line 2790) and marketplaceController (line 1447) check that the authenticated user is the selectedOffer.sellerId. The buyer generates the code; the seller submits it at handoff. + +**Doc Claim:** Steps 7-9: 'Seller shares the 6-digit code verbally with the buyer at hand-off' then 'Buyer enters the code in the dashboard' and 'Frontend sends POST .../verify-delivery'. + +**Code Reality:** POST /api/marketplace/purchase-requests/:id/delivery-code/verify enforces that req.user.id === selectedOffer.sellerId. The buyer has no access to submit a code. The correct endpoint path is /delivery-code/verify, not /verify-delivery as the doc states. + +**UAT Impact:** QA must verify: (1) buyer cannot call POST /delivery-code/verify — must receive 403; (2) seller submitting correct code succeeds and transitions status to 'delivered'; (3) buyer calling the documented /verify-delivery path returns 404. + +#### C35. Documented API endpoint paths do not match actual backend routes + +**Description:** The documentation API table lists these endpoints which do not exist: 'POST /api/marketplace/purchase-requests/:id/delivery-code' (for manual code regeneration) and 'POST /api/marketplace/purchase-requests/:id/verify-delivery' (for buyer code submission). The actual backend endpoints are /delivery-code/generate and /delivery-code/verify respectively. There is no regenerate endpoint registered in any backend router; the frontend action falls back to /generate on 404. + +**Doc Claim:** API endpoints table: 'POST /:id/delivery-code — Manual code regeneration (admin)' and 'POST /:id/verify-delivery — Buyer confirms with code'. + +**Code Reality:** Registered routes: POST /:id/delivery-code/generate (buyer only), POST /:id/delivery-code/verify (seller only), GET /:id/delivery-code (buyer+seller), GET /:id/delivery-code/status (buyer+seller). No /verify-delivery or bare /delivery-code POST routes exist. Frontend axios.ts lists /delivery-code/regenerate as the regenerate endpoint but the backend has no such route. + +**UAT Impact:** QA must confirm: POST /delivery-code and POST /verify-delivery both return 404. The correct working endpoints are /delivery-code/generate and /delivery-code/verify. + +--- + +## Major Findings + +> Major findings will cause test failures, wrong behavior, or documentation-code mismatches that affect integration teams. + +### Domain: Authentication + +#### M1. Axios interceptor only handles 401, not 403, for token refresh — doc says both + +**Description:** The Authentication Flow doc step 17 says the Axios interceptor 'handles 401/403 by triggering the refresh flow'. The actual interceptor in /Users/manwe/CascadeProjects/escrow/frontend/src/lib/axios.ts line 105 only triggers the refresh flow for status === 401. 403 responses (for example from EMAIL_NOT_VERIFIED or a blocked account) are not handled by the refresh flow and will propagate as errors. + +**Doc Claim:** Step 17: 'Axios interceptor attaches Authorization: Bearer ${accessToken} to every subsequent request and handles 401/403 by triggering the refresh flow.' + +**Code Reality:** axios.ts:105: if (status === 401 && !isAuthRoute && !originalRequest?._retry) — 403 is not included. + +**UAT Impact:** QA must verify that a 403 response from the backend (e.g., email not verified) is surfaced as an error to the user rather than silently attempting a token refresh and looping. + +#### M2. Password reset code is 6 digits, not 8 — backend API doc and controller comment are wrong + +**Description:** The backend API docs notableLogic entry states 'Generates 8-digit code, 1-hour TTL' for the request-password-reset endpoint. The authController.ts comment at line 838 also says '// Generate reset code (8 digits)'. However, authService.generateVerificationCode() (authService.ts:226-228) always generates a 6-digit code via Math.floor(100000 + Math.random() * 900000), and isValidVerificationCode() validates with /^\d{6}$/ (authService.ts:237). The Password Reset Flow doc correctly says '6-digit code' but it conflicts with the API-level description. An 8-digit code will always fail validation. + +**Doc Claim:** Backend API notable logic: 'Generates 8-digit code'. authController.ts comment: '// Generate reset code (8 digits)'. + +**Code Reality:** authService.ts:226-228 generates exactly 6 digits. isValidVerificationCode() at line 237 validates /^\d{6}$/. Any 8-digit code submitted to reset-password-with-code returns 400 Invalid code format. + +**UAT Impact:** QA must confirm that the password reset email delivers a 6-digit code (not 8) and that submitting it to POST /api/auth/reset-password-with-code succeeds. + +#### M3. Login rate limit counts all attempts, not only failures — doc says '5 failures' + +**Description:** The Authentication Flow doc step 5 and edge case describe the limit as '5 failures in 15 min'. The actual implementation calls rateLimitService.checkLoginAttempts() (which calls checkLimit(), which increments on every invocation) BEFORE the password comparison. This means every login attempt — including ones with correct credentials — increments the counter. A successful login does reset the counter via resetLoginAttempts() afterward, but if a user makes 4 correct logins followed by 1 failed attempt without a reset in between, they can be locked out faster than the doc implies. + +**Doc Claim:** Step 5: '5 failures in 15 min → 429 TOO_MANY_ATTEMPTS'. Edge case: '5 failed login attempts within 15 minutes → 429 TOO_MANY_ATTEMPTS'. + +**Code Reality:** rateLimitService.checkLimit() increments the counter on every call (line 49: redisService.incr). The check is made before password validation. The counter is only reset on a successful full login. 5 total attempts (not 5 failures) within the window will trigger the lock. + +**UAT Impact:** QA must verify rate-limiting with a mix of correct and incorrect passwords to confirm the lockout triggers on attempt count, not failure count only. + +#### M4. changePassword action is defined but never wired to any page or UI component + +**Description:** The changePassword action is implemented in action.ts (line 263) and the endpoint POST /api/auth/change-password exists in the backend, but no dashboard page or view component calls it. The frontend actions audit also flags this. The changePasswordValidation middleware enforces password complexity (uppercase, lowercase, digit required). There is no 'Change Password' UI anywhere under /dashboard. + +**Doc Claim:** Frontend actions: changePassword → apiPath: /auth/change-password, method: POST (implied to be accessible from the UI). + +**Code Reality:** changePassword() function exists in action.ts:263 but grep across all .tsx files finds no call site. No dashboard page under /dashboard renders a change-password form. + +**UAT Impact:** QA cannot test password change from the UI because there is no UI for it. Verify the endpoint directly via API. This is also an incomplete feature that should be tracked. + +#### M5. Passkey sign-in calls Next.js /api/auth/passkey/* paths directly — no Next.js route handlers exist + +**Description:** The frontend actions table notes passkey actions as 'Next.js route handler, not direct backend'. However there are no Next.js API route files under /Users/manwe/CascadeProjects/escrow/frontend/src/app/api/ for passkey paths (only /api/health exists). PasskeySignIn.tsx calls fetch('/api/auth/passkey/authenticate/challenge') which is resolved by the Next.js rewrite rule in next.config.ts (source: '/api/:path*' → backend) — it goes directly to the backend. There are no intermediate Next.js server-side handlers. + +**Doc Claim:** Frontend actions: registerPasskey (WebAuthn challenge/complete) and authenticateWithPasskey — notes say 'Next.js route handler, not direct backend'. + +**Code Reality:** No Next.js route.ts files exist for passkey paths. next.config.ts rewrites /api/:path* to the backend. All passkey API calls proxy directly to the Express backend. + +**UAT Impact:** QA must configure CORS and ensure the backend PASSKEY_RP_ORIGIN matches the Next.js frontend URL, since there is no Next.js intermediary to mutate headers or credentials. + +#### M6. reset-password-with-code has no password complexity validation middleware — reset-password (token) does + +**Description:** POST /api/auth/reset-password is wired with passwordResetValidation middleware which enforces 6+ chars, uppercase+lowercase+digit. POST /api/auth/reset-password-with-code has no validation middleware at all (authRoutes.ts:54-56: no middleware between route and controller). The controller only validates email/code/password existence and the 6-digit code format. A new password of '123456' or 'aaaaaa' is accepted on reset-with-code but would be rejected on token-based reset. + +**Doc Claim:** API docs: reset-password-with-code body: 'email, code, password'. No note about differing complexity requirements. + +**Code Reality:** authRoutes.ts:49-53: reset-password uses passwordResetValidation. authRoutes.ts:54-57: reset-password-with-code has no validation middleware. authValidation.ts:44-54: passwordResetValidation requires /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/. + +**UAT Impact:** QA must test that reset-password-with-code accepts weak passwords that would be rejected by the token-based reset, and decide if this is an intentional design or a validation gap to fix. + +### Domain: Purchase Request + +#### M7. Negotiation Flow: PATCH /api/marketplace/offers/:id documented but backend only has PATCH in routes.ts, not in controllerRoutes.ts; frontend uses PUT + +**Description:** The Negotiation Flow lists PATCH /api/marketplace/offers/:id for offer edits. The legacy routes.ts registers PATCH /offers/:id (line 1260). However, the controllerRoutes.ts (the primary router) registers PUT /api/marketplace/offers/:id/status — not a bare PATCH on /offers/:id. The frontend updateOffer action uses axiosInstance.put against endpoints.marketplace.offers.update ('/marketplace/offers/:id'), which does not match any known route in controllerRoutes.ts. Both the doc and the frontend are misaligned with the active controller. + +**Doc Claim:** PATCH /api/marketplace/offers/:id + +**Code Reality:** routes.ts registers PATCH /offers/:id (legacy, auth required). controllerRoutes.ts has no offer update route other than PUT /offers/:id/status. Frontend uses PUT /marketplace/offers/:id which maps to neither registered path in the controller router. + +**UAT Impact:** Verify that offer price/ETA edits from the buyer or seller actually reach the backend without 404. Confirm which router (legacy or controller) handles the request in the mounted app. + +#### M8. Negotiation Flow: POST /api/marketplace/offers/:id/withdraw documented; no such route exists in backend + +**Description:** The Negotiation Flow documents 'POST /api/marketplace/offers/:id/withdraw' as the seller withdrawal endpoint. Neither controllerRoutes.ts nor routes.ts contains a /withdraw sub-path on offers. The documented axios endpoint list in the frontend also has no withdraw action. Withdrawal is instead handled via PUT /marketplace/offers/:id/status with body {status:'withdrawn'}, which routes.ts line 1914 handles. + +**Doc Claim:** POST /api/marketplace/offers/:id/withdraw + +**Code Reality:** No /withdraw endpoint exists. Withdrawal is done via PUT /offers/:id/status with status='withdrawn' (routes.ts line 1914). Frontend has no dedicated withdraw action either. + +**UAT Impact:** Any test that posts to the withdraw path will get 404. Correct test must use PUT /offers/:id/status with {status:'withdrawn'}. + +#### M9. Purchase Request Flow step 4: sellers fetched from GET /api/users/sellers; actual endpoint is GET /api/marketplace/sellers + +**Description:** The Purchase Request Flow step 4 states the buyer selects preferred sellers via a typeahead bound to GET /api/users/sellers. In reality, the sellers endpoint is GET /api/marketplace/sellers (registered in both controllerRoutes.ts line 183 and routes.ts line 1441). The frontend getSellers action calls endpoints.marketplace.sellers ('/marketplace/sellers'), confirming the real path. + +**Doc Claim:** Typeahead bound to GET /api/users/sellers + +**Code Reality:** GET /api/marketplace/sellers (controllerRoutes.ts line 183, frontend src/lib/axios.ts line 268) + +**UAT Impact:** API tests hitting /api/users/sellers will get 404. Integration tests should use /api/marketplace/sellers. + +#### M10. Purchase Request Flow step 5: attachments uploaded via POST /api/files/upload; actual endpoint is POST /api/marketplace/purchase-requests/:id/attachments + +**Description:** The documented flow says optional attachments on the Review step are uploaded via POST /api/files/upload. The frontend uploadRequestAttachment action posts to endpoints.marketplace.requests.attachments = '/marketplace/purchase-requests/:id/attachments'. No general /api/files/upload endpoint is used in the wizard. + +**Doc Claim:** Step 5 — uploads optional attachments via POST /api/files/upload + +**Code Reality:** Frontend uses POST /marketplace/purchase-requests/:id/attachments (src/actions/marketplace.ts line 326-339) + +**UAT Impact:** Attachment upload tests must target the correct endpoint. Verify the backend registers /purchase-requests/:id/attachments and returns the expected shape. + +#### M11. Delivery Confirmation doc says status goes delivery→delivered via buyer code entry; backend code flow reverses actors + +**Description:** The doc says backend sets status 'delivered' after the buyer enters the delivery code successfully. In the actual backend, the seller calls POST .../delivery-code/verify, which triggers updatePurchaseRequestStatus(id, 'delivered') (routes.ts line 2828). The buyer's confirmDelivery endpoint (PATCH .../confirm-delivery in controllerRoutes.ts) is a separate fast-track path that also moves to 'delivered'. These are two distinct paths to 'delivered' that are conflated in the doc. + +**Doc Claim:** Buyer enters code → POST .../verify-delivery → backend flips status to 'delivered' + +**Code Reality:** Seller calls POST .../delivery-code/verify with the code the buyer gave them → backend sets 'delivered'. Separately, PATCH .../confirm-delivery is a buyer fast-track that also sets 'delivered' without requiring a code. + +**UAT Impact:** Test both paths independently: (1) seller verifies code → request becomes 'delivered', (2) buyer confirm-delivery without code → request becomes 'delivered'. Confirm neither path is blocked when the other was already used. + +#### M12. Documented socket event 'request-cancelled' not emitted or listened to anywhere in codebase + +**Description:** The Purchase Request Flow documents a 'request-cancelled' socket event emitted to user-{buyerId} and user-{sellerId} when the buyer cancels. A grep of the entire frontend socket layer and backend routes shows no emission or listener for this event name. The backend routes.ts emits purchase-request-update with eventType 'status-changed' for all status transitions, including cancellation. + +**Doc Claim:** request-cancelled (emitted to user-{buyerId} and user-{sellerId} when buyer cancels) + +**Code Reality:** No emission of 'request-cancelled' exists in any backend file. Cancellation emits purchase-request-update with eventType:'status-changed'. + +**UAT Impact:** Any frontend component or test listening for 'request-cancelled' will never receive it. Cancellation UI must be verified via the 'purchase-request-update' + 'status-changed' path instead. + +#### M13. Documented 'join-request-room' emitted by frontend on detail page; actual rooms use seller-specific rooms via 'join-seller-room' + +**Description:** The Purchase Request Flow lists 'join-request-room' as a client-to-server event emitted on detail page mount. The socket-context.tsx confirms this exists. However, the useSellerMarketplaceSocket hook emits 'join-seller-room' / 'leave-seller-room' (use-marketplace-socket.ts lines 339, 344) which is not documented. The backend app.ts presumably handles both but the doc only mentions one. + +**Doc Claim:** join-request-room (emitted by frontend on detail page mount to subscribe buyer to request-{id} room) + +**Code Reality:** Frontend also emits 'join-seller-room'/'leave-seller-room' for sellers and 'join-buyer-room'/'leave-buyer-room' for buyers (use-marketplace-socket.ts). These room joins are undocumented. + +**UAT Impact:** Verify that seller real-time events (new offers, offer updates, payment events) arrive correctly via the seller room, not just the request room. + +#### M14. Backend emits 'new-purchase-request' to room 'sellers'; doc describes 'new-notification' to each seller via user-{sellerId} + +**Description:** The Purchase Request Flow step 11 says the backend fans out notifications to sellers via Socket.IO by emitting to 'user-{sellerId}' for each seller. The actual backend code emits 'new-purchase-request' to the shared 'sellers' room (confirmed in backend socket events list). The frontend useSellerMarketplaceSocket listens for 'new-purchase-request' (use-marketplace-socket.ts line 392), not per-user notifications. + +**Doc Claim:** Backend emits via Socket.IO to user-{sellerId} for each notified seller + +**Code Reality:** Backend emits 'new-purchase-request' to room 'sellers' (all sellers) for public requests. Per-seller notifications use 'seller-offer-update' and 'new-notification' separately. + +**UAT Impact:** Verify new public requests appear in real time on all connected seller dashboards. Verify private requests (with specific preferredSellerIds) do not appear on other sellers' dashboards. + +#### M15. Frontend actions for delivery-code/regenerate, delivery-code/attempts, and /delivery/stats call endpoints that do not exist in backend + +**Description:** The frontend delivery.ts defines regenerateDeliveryCode (calls /delivery-code/regenerate), getDeliveryAttempts (calls /delivery-code/attempts), and getDeliveryStats (calls /delivery/stats). None of these paths are registered in routes.ts or controllerRoutes.ts. regenerateDeliveryCode catches the 404 and falls back to generateDeliveryCode as a workaround, but the fallback silently ignores the missing endpoint. + +**Doc Claim:** Frontend actions list includes: regenerateDeliveryCode, getDeliveryAttempts, getDeliveryStats + +**Code Reality:** Backend has no routes for /delivery-code/regenerate, /delivery-code/attempts, or /delivery/stats. These are phantom endpoints. + +**UAT Impact:** Test that regenerateDeliveryCode falls back to generate without visible error. getDeliveryAttempts and getDeliveryStats will return 404 and throw — any UI calling them will fail unless errors are caught. + +#### M16. Frontend searchPurchaseRequests calls /marketplace/purchase-requests/search which is not a backend endpoint + +**Description:** The frontend defines searchPurchaseRequests pointing to /marketplace/purchase-requests/search. Neither controllerRoutes.ts nor routes.ts registers a /search sub-path. Search is handled via query parameters on the list endpoint GET /purchase-requests. The missingFrontendFeatures note also confirms there is no standalone search page. + +**Doc Claim:** Frontend action: searchPurchaseRequests → GET /marketplace/purchase-requests/search + +**Code Reality:** No /search endpoint exists in the backend. Search/filter is via query params on GET /marketplace/purchase-requests. + +**UAT Impact:** Calling searchPurchaseRequests will produce a 404. Verify search functionality uses getPurchaseRequests with filter params instead. + +#### M17. Frontend getMarketplaceStats calls /marketplace/purchase-requests/stats which has no backend handler + +**Description:** The frontend defines getMarketplaceStats calling endpoints.marketplace.requests.stats = '/marketplace/purchase-requests/stats'. Neither controllerRoutes.ts nor routes.ts registers a /stats sub-path under purchase-requests. This will 404 in production. + +**Doc Claim:** Frontend action: getMarketplaceStats → GET /marketplace/purchase-requests/stats + +**Code Reality:** No /stats endpoint under /marketplace/purchase-requests is registered in the backend. + +**UAT Impact:** Any dashboard page that calls getMarketplaceStats will receive a 404. Confirm no production UI currently depends on this. + +#### M18. updatePurchaseRequest uses PUT in frontend but backend only registers PATCH /purchase-requests/:id + +**Description:** The frontend updatePurchaseRequest action (marketplace.ts line 71) calls axiosInstance.put against endpoints.marketplace.requests.update = '/marketplace/purchase-requests/:id'. The backend controllerRoutes.ts and routes.ts register PATCH (not PUT) on /purchase-requests/:id. Sending PUT will result in 404 from the controller router. + +**Doc Claim:** Frontend action updatePurchaseRequest → PUT /marketplace/purchase-requests/:id + +**Code Reality:** Backend registers PATCH /purchase-requests/:id (controllerRoutes.ts registers nothing; routes.ts line 1007 has router.patch). Frontend sends PUT, causing a method mismatch. + +**UAT Impact:** Test editing a purchase request from the buyer edit view. A PUT request should return 404/405; only PATCH should succeed. + +#### M19. Purchase Request Flow: description minimum is documented as 20 chars; frontend schema enforces 5 chars minimum + +**Description:** The Purchase Request Flow documents the description field as '20–2000 chars'. The frontend RequestFormSchema (request-form-wizard.tsx line 94-98) sets the minimum to 5 characters, and the field is marked as optional. The two constraints are inconsistent. + +**Doc Claim:** Step 2 — description (20–2000 chars) + +**Code Reality:** Frontend zod schema: description is optional; if provided, minimum is 5 characters (request-form-wizard.tsx lines 92-108). + +**UAT Impact:** Test submitting a description of 6–19 characters. Frontend should accept it. Verify whether the backend also enforces a minimum and which value takes precedence. + +#### M20. Purchase Request Flow: step 3 shows urgency values low/medium/high; 'urgent' also exists + +**Description:** The documented Budget step lists urgency options as low/medium/high. The actual frontend schema (request-form-wizard.tsx line 281) and IPurchaseRequest type also include 'urgent' as a valid urgency value. The backend statusValues list also includes 'urgent'. The doc is incomplete. + +**Doc Claim:** urgency (low/medium/high) + +**Code Reality:** urgency enum includes 'urgent' as a fourth value (request-form-wizard.tsx, IPurchaseRequest type, backend statusValues). + +**UAT Impact:** Test submitting a request with urgency='urgent'. It should be accepted by both frontend validation and backend. + +#### M21. Escrow Flow: doc lists GET /api/payment/:id but backend payment routes use /api/payment/:id (not /api/marketplace/payments/:paymentId) + +**Description:** The Escrow Flow documents endpoint GET /api/payment/:id for fetching payment details. The actual Payment routes are under /api/marketplace/payments/:paymentId (controllerRoutes.ts and routes.ts). The standalone /api/payment/ path may refer to a separate payment service router, but this is not confirmed in the backend endpoints provided. + +**Doc Claim:** GET /api/payment/:id + +**Code Reality:** Backend marketplace router registers GET /marketplace/payments/:paymentId (controllerRoutes.ts line 53, routes.ts line 206). A separate /payment/* router may also exist (referenced in frontend endpoints.payments). + +**UAT Impact:** Confirm which router serves /api/payment/:id and whether it is the same as /api/marketplace/payments/:paymentId. Test payment detail fetching from the buyer and admin views. + +### Domain: Seller Offer + +#### M22. 'active' status is documented for SellerOffer but absent from the schema enum + +**Description:** The flow document lists 'active (SellerOffer — optional manual seller activation)' as a valid SellerOffer status and mentions it in the docFlags. The SellerOffer Mongoose schema (SellerOffer.ts line 80) and TypeScript interface (line 17) enumerate only 'pending | accepted | rejected | withdrawn'. The service methods at SellerOfferService.ts line 307 also use only these four values. The 'active' status would be rejected by Mongoose on save. The createOffer() status gate at line 83 does allow PurchaseRequest.status='active' (not SellerOffer.status='active'), which is a separate concept. + +**Doc Claim:** SellerOffer status 'active' exists as an optional manual seller activation state. + +**Code Reality:** SellerOffer.status enum: ['pending', 'accepted', 'rejected', 'withdrawn']. No 'active' value. Any attempt to save status='active' on a SellerOffer will throw a Mongoose ValidationError. + +**UAT Impact:** Do not attempt to set a SellerOffer to 'active' — it will fail. The state machine diagram showing 'active' as reachable is incorrect and should not guide test case design. + +#### M23. select-offer cascade rejects ALL competing offers regardless of current status, not just pending/active + +**Description:** The flow doc says at step 15 that 'all other offers on same request → rejected via SellerOffer.updateMany'. The SellerOfferService.acceptOffer() method (lines 376-387) correctly filters by status: { $in: ['pending', 'active'] }. However, the direct route handler for POST /purchase-requests/:id/select-offer (routes.ts lines 1386-1395) uses updateMany with only { purchaseRequestId, _id: { $ne: offerId } } — no status filter. This means selecting an offer via select-offer can overwrite already-withdrawn or previously-rejected offers back to 'rejected', corrupting their status history. + +**Doc Claim:** Step 15: 'all other offers on same request → rejected via SellerOffer.updateMany'. The acceptOffer cascade description says 'rejects all competing pending/active offers'. + +**Code Reality:** POST /purchase-requests/:id/select-offer route (routes.ts:1386) calls updateMany without a status filter, affecting offers in any status. Only the SellerOfferService.acceptOffer() used by POST /offers/:id/accept filters by ['pending', 'active']. + +**UAT Impact:** Create a request with one withdrawn offer and one pending offer. Select the pending offer via select-offer. Verify whether the withdrawn offer's status is corrupted to 'rejected'. This is a data integrity regression. + +#### M24. Doc missing: new-offer socket event emitted to buyer-{buyerId} room on offer creation + +**Description:** The backend marketplaceController.ts (lines 47-56, 931-936) emits a 'new-offer' event to the room buyer-{buyerId} whenever a seller creates an offer via the controller's createOffer route. The socket events table in the doc only lists 'seller-offer-update (eventType: new-offer) → seller-{sellerId}' and 'new-notification → user-{buyerId}'. The direct 'new-offer' event to the buyer room is not documented. The backend socket events list in the provided code data does list 'EMIT new-offer — room: buyer-{buyerId}', confirming the omission is in the flow doc's socket section. + +**Doc Claim:** Socket events: 'seller-offer-update (eventType: new-offer) → seller-{sellerId}' and 'new-notification → user-{buyerId}'. No mention of a direct new-offer event to the buyer. + +**Code Reality:** marketplaceController.ts emits 'new-offer' directly to buyer-{buyerId} room in addition to the seller-specific event and the notification. The frontend use-marketplace-socket.ts (lines 300, 497) listens on 'new-offer'. + +**UAT Impact:** Buyer dashboard real-time offer arrival must be tested by listening on the buyer-{buyerId} room for the 'new-offer' event, not only new-notification. Verify both events fire when a seller submits a proposal. + +#### M25. select-offer does not emit per-seller socket events or notifications to losing sellers + +**Description:** The flow doc at step 15 states 'notifications sent to winning seller (notifyOfferAccepted) and losing sellers; socket events emitted to winner and losers'. The POST /purchase-requests/:id/select-offer route handler (routes.ts lines 1300-1438) emits only a single purchase-request-update event to the request room with eventType 'offer-selected'. It does NOT call notifyOfferAccepted, does NOT call notifyOfferRejected for losing sellers, and does NOT emit seller-offer-update events to individual seller rooms. Those notifications are only sent via SellerOfferService.updateOfferStatus() (which fires when using PUT /offers/:id/accept or PUT /offers/:id/status), not via the select-offer path. + +**Doc Claim:** Step 15: 'notifications sent to winning seller (notifyOfferAccepted) and losing sellers; socket events emitted to winner and losers'. + +**Code Reality:** POST /purchase-requests/:id/select-offer emits only purchase-request-update to the request room. No per-seller notifications or socket events are sent to winning or losing sellers from this path. + +**UAT Impact:** After a buyer selects an offer via the select-offer flow, verify whether the winning seller receives a notification and whether losing sellers are notified. Expected result per doc: yes. Actual result per code: no notifications are sent. + +#### M26. No frontend withdraw offer action or UI — POST /api/marketplace/offers/:id/withdraw has no frontend coverage + +**Description:** The flow documents a seller withdraw path at step 16 and the rejectOffer() frontend action (src/actions/marketplace.ts lines 299-308) only sends status='rejected', never 'withdrawn'. There is no withdrawOffer() action, no withdraw button in any seller step component, and the axios endpoint map marks the withdraw-capable /offers/:id/status endpoint only as 'withdraw/update offer status' without a dedicated frontend action using it for withdrawal. Confirmed by the missingFrontendFeatures list: 'No frontend action or UI for withdrawing an offer'. + +**Doc Claim:** Step 16: 'Seller can withdraw their pending offer via /dashboard/seller/marketplace/offers/{offerId} → withdrawOffer'. + +**Code Reality:** No withdraw button, no withdrawOffer() action exists in frontend. rejectOffer() always sends { status: 'rejected' }. The route /dashboard/seller/marketplace/offers/{offerId} does not appear to exist as a page. + +**UAT Impact:** There is no UI path to test seller offer withdrawal. This feature is completely untestable from the frontend. Backend-only testing via PUT /offers/:id/status with status='withdrawn' is the only path. + +#### M27. No seller 'My Offers' page — GET /api/marketplace/offers/seller/:sellerId has no frontend page or action + +**Description:** The doc lists GET /api/marketplace/offers/seller/:sellerId and the flow implies a seller can review their offer history. The frontend has no page at /dashboard/seller/marketplace/offers (referenced in backend notification actionUrl), no getSellerOffers() action, and the missingFrontendFeatures list confirms 'No frontend page or action for listing a seller's own offers'. The backend service method getOffersBySeller() also has no HTTP route, compounding the gap. + +**Doc Claim:** GET /api/marketplace/offers/seller/:sellerId — Seller's own offer history + +**Code Reality:** No HTTP route exposes getOffersBySeller(). No frontend page /dashboard/seller/marketplace/offers exists. Notification actionUrls pointing to this path are broken links. + +**UAT Impact:** Seller offer history view cannot be tested. Notification links to /dashboard/seller/marketplace/offers will produce a 404 page. Both the missing route and missing page need to be verified. + +#### M28. Frontend updateOffer uses PUT /marketplace/offers/:id but backend registers PATCH /offers/:id + +**Description:** The frontend updateOffer action (src/actions/marketplace.ts line 289) uses axiosInstance.put() against endpoints.marketplace.offers.update which maps to /marketplace/offers/:id. The backend registers this as router.patch('/offers/:id', ...) at routes.ts line 1260. PUT vs PATCH is a method mismatch. Many HTTP routers treat them as distinct methods; Express does not alias them. The frontend step-1-send-proposal.tsx calls updateOffer() for existing offer edits, so this path is actively exercised. + +**Doc Claim:** PATCH /api/marketplace/offers/:id — Update price / ETA / notes (seller, while pending) + +**Code Reality:** Backend: router.patch('/offers/:id'). Frontend: axiosInstance.put(). Method mismatch means the PUT request may match the wrong route or return 404 depending on Express routing order. + +**UAT Impact:** Edit an existing offer from the seller proposal form and verify the network request method is PUT. Then verify the backend accepts PUT (it registers PATCH). If Express does not match PUT to PATCH, the update will silently fail or 404. + +#### M29. createOffer() allows PurchaseRequest in 'active' status but doc step 6 only mentions 'pending' or 'received_offers' + +**Description:** SellerOfferService.createOffer() at line 83 checks that PurchaseRequest.status is in ['pending', 'active', 'received_offers']. The flow doc step 6 states 'validates purchase request status is pending or received_offers'. The 'active' PurchaseRequest status is accepted by the backend but not documented in the narrative, leaving testers unaware that offers can be submitted against active requests. The PurchaseRequest status enum does include 'active' as a valid value (confirmed in the status list). + +**Doc Claim:** Step 6: 'validates purchase request status is pending or received_offers'. + +**Code Reality:** SellerOfferService.createOffer() line 83: status !== 'pending' && status !== 'active' && status !== 'received_offers' — three statuses are allowed. + +**UAT Impact:** Testers should also verify that a seller can submit an offer against a PurchaseRequest in 'active' status. The doc-based test cases for the status gate are incomplete. + +### Domain: Payment + +#### M30. API docs path prefix mismatch: /api/payment/stats vs /api/payment/payments/stats + +**Description:** The API docs list GET /api/payment/stats and GET /api/payment/stats/:userId without the /payments/ infix. The backend code registers these under GET /api/payment/payments/stats and GET /api/payment/payments/stats/:userId (with the /payments/ segment). A duplicate controller-pattern route exists at /api/payment/stats (no /payments/) but requires admin role vs the /payments/ version. The frontend axios config defines endpoints.payments.stats as '/payment/stats' (without /payments/), which routes to the controller pattern endpoint. + +**Doc Claim:** API docs: GET /api/payment/stats (auth: Bearer JWT) and GET /api/payment/stats/:userId + +**Code Reality:** Two parallel implementations: /api/payment/payments/stats (admin-gated, strict) and /api/payment/stats (controller-pattern, authenticateToken only). Frontend hits the controller-pattern route. Auth level differs between the two. + +**UAT Impact:** QA must check which stats route the admin dashboard calls. The /payments/stats route requires admin role and will 403 for non-admins; the /stats route does not enforce this, creating a privilege gap. + +#### M31. API docs path prefix mismatch: /api/payment/export vs /api/payment/payments/export + +**Description:** Same dual-path pattern as stats. API docs list /api/payment/export and /api/payment/export/:userId. Backend registers both /api/payment/payments/export (admin-only) and /api/payment/export (controller-pattern, no admin guard — 'uses role-based filter in service'). The frontend axios config uses '/payment/export', hitting the controller-pattern route that has no admin guard at the router level. + +**Doc Claim:** API docs: GET /api/payment/export with auth: Bearer JWT (admin). GET /api/payment/export/:userId + +**Code Reality:** GET /api/payment/payments/export has admin role guard; GET /api/payment/export (controller route) has only authenticateToken — no admin guard at the route level. Frontend hits the non-admin-gated path. + +**UAT Impact:** QA must verify that non-admin buyers cannot export all payment data. Test with a buyer JWT against GET /api/payment/export. + +#### M32. API docs say GET /api/payment/fetch-tx/:paymentId; backend is POST /api/payment/payments/:id/fetch-tx + +**Description:** The DePay flow API endpoints table lists GET /api/payment/fetch-tx/:paymentId as the manual rechecker. The actual backend route is POST /api/payment/payments/:id/fetch-tx (POST method, /payments/ infix). The frontend fetchTransactionHashFromBlockchain() correctly calls POST /payment/payments/${paymentId}/fetch-tx (line 693 of payment.ts), confirming the backend reality. The flow doc is wrong on both the method (GET vs POST) and the path. + +**Doc Claim:** DePay flow apiEndpoints: GET /api/payment/fetch-tx/:paymentId + +**Code Reality:** Backend: POST /api/payment/payments/:id/fetch-tx (no auth). Frontend action calls POST /payment/payments/${paymentId}/fetch-tx. + +**UAT Impact:** QA must use POST, not GET, when manually invoking the tx-hash rechecker. Sending a GET will 404. + +#### M33. Frontend defines createDePayIntent calling /payment/depay/intents — no such backend route + +**Description:** src/actions/payment.ts exports createDePayIntent() which posts to /payment/depay/intents (or a fallback hardcoded string). No such route exists in the backend endpoint list. The function comment says it is 'used by tests', but the endpoint is not present. The frontend axios endpoints object also has no payments.depay key, so the fallback hardcoded string is always used. + +**Doc Claim:** Frontend actions list createDePayIntent with apiPath: /payment/depay/intents + +**Code Reality:** No /depay/intents route on backend. Function uses a fallback hardcoded URL '/payment/depay/intents'. Will always 404 if called. + +**UAT Impact:** QA must confirm createDePayIntent() is not called in any production flow. If a test path invokes it, it will receive 404. + +#### M34. Frontend actions for Request Network payout/release/refund confirm point to non-existent routes + +**Description:** src/actions/payment.ts exports initiateRequestNetworkPayout(), confirmRequestNetworkPayout(), confirmRequestNetworkRelease(), and confirmRequestNetworkRefund() hitting /api/payment/request-network/:id/payout/initiate, /payout/confirm, /release/confirm, and /refund/confirm. None of these sub-paths appear in the backend endpoint list. The backend only documents POST /api/payment/request-network/intents, GET /api/payment/request-network/:paymentId/checkout, and POST /api/payment/request-network/webhook. + +**Doc Claim:** Frontend actions docs list these as valid endpoints for admin Request Network payout/release/refund operations. + +**Code Reality:** Backend code does not expose any of the four Request Network payout/release/refund sub-routes. These actions will return 404 when called. + +**UAT Impact:** QA must test the admin release/refund flow for Request Network payments. All four actions are currently broken and will 404. + +#### M35. Multiple frontend stub endpoints have no backend implementation: /payment/history, /payment/methods, /payment/validate, /payment/transactions, /payment/escrow/balance + +**Description:** The frontend axios.ts registers endpoints for /payment/history, /payment/methods, /payment/validate, /payment/transactions, and /payment/escrow/balance. None of these routes exist in the backend. The corresponding frontend actions (getPaymentHistory, getPaymentMethods, validatePayment, getTransactionHistory, getEscrowBalance) will all receive 404 responses. These appear to be placeholder definitions for planned features. + +**Doc Claim:** Frontend actions docs list these as available API paths with GET/POST methods. + +**Code Reality:** No matching routes in backend. Calls will 404. + +**UAT Impact:** QA must confirm none of these actions are invoked from live UI components. If any dashboard widget calls them, it will silently fail or show empty state due to caught errors. + +#### M36. 'completed' status is not counted as successful in payment stats aggregate — only 'confirmed' is + +**Description:** The backend notable logic states: 'Payment.status confirmed counts as successfulPayments in stats; completed is not counted in successfulPayments'. The flow docs for both SHKeeper and DePay describe the final successful terminal state as 'completed'. If the admin dashboard displays successfulPayments from the stats endpoint, payments that followed the standard 'completed' terminal path will not be counted. The SHKeeper flow maps PAID to 'completed', so most successful SHKeeper payments will be invisible in the success count. + +**Doc Claim:** Both payment flows document 'completed' as the terminal success status. API docs describe GET /api/payment/stats as returning aggregated counts per status. + +**Code Reality:** paymentService.getPaymentStats aggregate counts only 'confirmed' as successfulPayments; 'completed' is excluded. + +**UAT Impact:** QA must run a SHKeeper payment to completion, then check GET /api/payment/stats. The successful payment should appear in completed count but NOT in successfulPayments. Verify the dashboard does not mislead admins by showing an artificially low success count. + +#### M37. PaymentProvider type in frontend excludes 'shkeeper' and 'decentralized' — only 'request.network', 'test', 'other' + +**Description:** src/types/payment.ts defines PaymentProvider as 'request.network' | 'test' | 'other'. The SHKeeper and DePay flows both create Payment records with provider values that are not in this union type. The backend accepts 'shkeeper', 'decentralized', and 'other' as provider values. Frontend TypeScript code that reads payment.provider and switches on PaymentProvider type will miss shkeeper and decentralized payments, causing UI components to fall through to a default/unknown state. + +**Doc Claim:** DePay flow doc says provider is 'other' or 'decentralized'. SHKeeper flow implies provider is 'shkeeper'. Frontend type definition says only 'request.network' | 'test' | 'other'. + +**Code Reality:** src/types/payment.ts line 15: PaymentProvider = 'request.network' | 'test' | 'other'. No 'shkeeper' or 'decentralized' variant. + +**UAT Impact:** QA must verify that the payment list and payment details views correctly display and label SHKeeper and DePay payments. Any provider-based conditional rendering may show incorrect labels or skip those payment records. + +#### M38. createProviderPaymentIntent always routes to request-network/intents regardless of provider argument + +**Description:** src/actions/payment.ts getProviderIntentEndpoint() ignores the provider parameter and always returns endpoints.payments.requestNetwork.intents ('/payment/request-network/intents'). If any UI component passes provider='shkeeper' to createProviderPaymentIntent(), the call will go to the Request Network endpoint instead of /payment/shkeeper/intents, creating a silent routing failure. The SHKeeper intents endpoint (/payment/shkeeper/intents) is defined in axios.ts but is never routed to by the provider intent factory. + +**Doc Claim:** Frontend actions list createProviderPaymentIntent as a provider-agnostic factory that routes to the appropriate provider endpoint. + +**Code Reality:** getProviderIntentEndpoint() at line 444 of payment.ts always returns requestNetwork.intents, ignoring the provider argument. The shkeeper.intents endpoint defined in axios.ts is never called by this factory. + +**UAT Impact:** QA must verify that any checkout path intended to use SHKeeper actually POSTs to /payment/shkeeper/intents and not /payment/request-network/intents. Test by intercepting network requests during a SHKeeper checkout. + +#### M39. Simulated transaction bypass (SIM_ prefix) is active in production frontend code with no environment guard + +**Description:** src/web3/context/web3-provider.tsx lines 225 and 232 generate SIM_ prefixed transaction hashes when wallet connection fails, and these are passed to the backend. The backend notable logic confirms: paymentHash starting with 'SIM_' skips on-chain verification — controlled only by hash prefix, not an environment flag. The frontend generates SIM_ hashes in an error fallback path that can be triggered in production when the wallet connection attempt fails, not just in development. + +**Doc Claim:** Backend notable logic notes: 'Simulated transaction bypass: paymentHash starting with SIM_ ... intended for dev but controlled only by hash prefix, not environment flag' + +**Code Reality:** Frontend web3-provider.tsx returns SIM_ hashes on wallet connection failure with no process.env.NODE_ENV check. This can occur in production if wallet connection times out or throws. + +**UAT Impact:** QA must test the payment flow with a wallet connection failure in a staging environment and confirm that the resulting SIM_ hash does not successfully create a completed payment record in the database. + +#### M40. API docs list auth for /api/payment/payments/:id/debug as 'Bearer JWT' — backend has NO auth middleware + +**Description:** The API docs table lists GET /api/payment/:id/debug with auth: Bearer JWT. The backend code explicitly notes: 'GET /api/payment/payments/:id/debug — SECURITY: returns payment + walletMonitor status without authentication'. The route has no auth middleware applied. Any unauthenticated caller can read full payment documents including blockchain metadata and wallet monitor state. + +**Doc Claim:** API docs: GET /api/payment/:id/debug with auth: Bearer JWT + +**Code Reality:** Backend code comment explicitly flags this as having no auth middleware. Full payment data exposed without authentication. + +**UAT Impact:** QA must verify that the debug endpoint returns data when called without an Authorization header. If confirmed, this is a data exposure vulnerability requiring an auth middleware fix before production. + +#### M41. API docs list auth for POST /api/payment/payments/auto-fetch-missing as 'Bearer JWT' — backend has NO auth + +**Description:** The API docs table lists POST /api/payment/payments/auto-fetch-missing with auth: Bearer JWT. The backend code notes this endpoint has no authentication middleware. Any unauthenticated caller can trigger batch blockchain tx-hash lookups and cause writes to payment records. + +**Doc Claim:** API docs: POST /api/payment/payments/auto-fetch-missing with auth: Bearer JWT + +**Code Reality:** Backend code: 'POST /api/payment/payments/auto-fetch-missing — Auto-fetches missing tx hashes for completed payments; no auth' + +**UAT Impact:** QA must call this endpoint without an Authorization header and confirm it either rejects the request or is intentionally public. If unintentionally public, it represents a state-mutation endpoint with no access control. + +#### M42. SHKeeper flow documents 'payment-created' as emitted on intent creation — backend only emits it after admin-payout and Request Network pay-in + +**Description:** The SHKeeper flow step 12 states 'Emit payment-created globally via emitGlobalEvent so admin dashboard sees the new pending payment in real time'. However, the backend socket events list specifies: 'payment-created (server→client, global): emitted after admin-payout save and after Request Network pay-in creation'. The SHKeeper create handler is not mentioned as a source of this event. If the SHKeeper intent creation does not emit payment-created, the admin dashboard real-time view will not show new SHKeeper payments. + +**Doc Claim:** SHKeeper flow step 12: payment-created is emitted globally on SHKeeper intent creation. + +**Code Reality:** Backend socket events documentation attributes payment-created only to admin-payout and Request Network pay-in. SHKeeper create is not listed. + +**UAT Impact:** QA must create a SHKeeper payment intent while the admin dashboard is open and verify whether a payment-created socket event is received and the new payment appears in real time. + +### Domain: Dispute + +#### M43. All Socket.IO events for disputes are unimplemented — every socket claim in docs is a TODO stub + +**Description:** The flow documentation lists three socket events: new-message (active via ChatService), new-notification (planned), and dispute-updated (planned). The code reality is that no Socket.IO emit occurs anywhere in DisputeService or the dispute controllers. All notification emit blocks are commented out as TODO. The new-message event only fires if ChatService.sendMessage is called separately; DisputeService.createDispute inserts the system message directly into the Chat document without calling ChatService.sendMessage, so no socket event fires even for that path. + +**Doc Claim:** Flow step 6: 'chat creation provides real-time presence via new-message socket emit'. Steps 5 and 14 note new-notification as TODO. dispute-updated is listed as planned. + +**Code Reality:** DisputeService.createDispute, assignAdmin, updateStatus, resolveDispute, and addEvidence all contain only commented-out TODO blocks for socket notifications. No socket.io emit calls exist in any dispute service or controller file. + +**UAT Impact:** QA must verify: (1) No real-time update appears in the buyer/seller browser when a dispute is created, an admin is assigned, status changes, evidence is added, or resolution is posted. All real-time feedback is absent. Chat participants do not receive a system message notification on dispute creation. + +#### M44. API docs use 'under_review' status; code uses 'in_progress' + +**Description:** The API docs for POST /api/disputes/:id/assign state it 'transitions status to under_review'. The Dispute model, DisputeService, and all frontend code use in_progress as the status value when an admin is assigned. There is no under_review value in the status enum anywhere in the codebase. + +**Doc Claim:** API docs: 'Sets assignedAdminId, transitions status to under_review, notifies participants'. + +**Code Reality:** Dispute model enum: ['pending', 'in_progress', 'waiting_response', 'resolved', 'rejected', 'closed']. DisputeService.assignAdmin sets dispute.status = 'in_progress'. No under_review value exists. + +**UAT Impact:** QA calling POST /api/disputes/:id/assign and checking the returned dispute.status should expect in_progress, not under_review. API doc consumers will be misled. + +#### M45. API docs describe buyer/seller/split decision model for resolve; code uses refund/replacement/compensation/warning_seller/ban_seller/no_action + +**Description:** The API docs for POST /api/disputes/:id/resolve specify a decision field with values 'buyer'|'seller'|'split' and refundAmount/releaseAmount. The backend and frontend use an action field with values 'refund'|'replacement'|'compensation'|'warning_seller'|'ban_seller'|'no_action' and an optional amount field. The two schemas are completely different. + +**Doc Claim:** API docs body: 'decision: buyer|seller|split; refundAmount?: number (required for split); releaseAmount?: number (required for split); reasoning: string'. + +**Code Reality:** DisputeController.resolveDispute reads { action, amount, currency, notes } from req.body. Dispute model resolution.action enum: ['refund', 'replacement', 'compensation', 'warning_seller', 'ban_seller', 'no_action']. Frontend ResolveDisputeData interface: { action: DisputeResolutionAction; amount?: number; currency?: string; notes?: string }. + +**UAT Impact:** QA following the API docs will send wrong field names and receive no validation error (the service will accept it but store undefined). Integration tests or clients built from the docs will be broken. + +#### M46. Flow doc says dispute categories are delivery/payment/quality/fraud/other; code uses a different enum + +**Description:** Flow step 2 describes the category options as 'delivery, payment, quality, fraud, other'. The actual Dispute model and frontend define six different categories: product_quality, delivery_delay, wrong_item, payment_issue, seller_behavior, other. 'fraud' does not exist in the code; 'delivery' maps to 'delivery_delay'; 'quality' maps to 'product_quality'; 'wrong_item' and 'seller_behavior' are absent from the doc. + +**Doc Claim:** Flow step 2: 'Initiator selects a category (delivery, payment, quality, fraud, other)'. + +**Code Reality:** Dispute model enum and frontend constants: ['product_quality', 'delivery_delay', 'wrong_item', 'payment_issue', 'seller_behavior', 'other']. No 'fraud' category exists. + +**UAT Impact:** QA testing category filtering or creation with 'fraud' will get a validation error. The list of available categories visible in the UI also does not match the doc, which will confuse testers using the doc as a reference. + +#### M47. Statistics endpoint omits waiting_response, rejected, and closed from counts + +**Description:** DisputeService.getStatistics only counts pending, in_progress, and resolved disputes. The model supports six statuses. The frontend dispute list view uses the statistics counts to populate tab badges, meaning the 'all' tab shows an accurate total but the status-specific tabs only cover three of six statuses. Disputes in waiting_response, rejected, or closed are invisible in the stats summary. + +**Doc Claim:** API docs describe 200 response as '{ success, data: { open, byReason, avgResolutionHours, ... } }' suggesting a broader stats shape. + +**Code Reality:** DisputeService.getStatistics returns { total, pending, inProgress, resolved, byCategory, byPriority }. No counts for waiting_response, rejected, closed, or avgResolutionHours. + +**UAT Impact:** QA should verify that closed and rejected disputes do not appear in any statistics panel. They would also note no avgResolutionHours despite docs claiming it is returned. + +#### M48. POST /api/disputes/:id/assign lacks a role guard but flow and docs say admin-only + +**Description:** The route for assign admin is documented as admin-only and the flow step 8 shows only an admin picking up a dispute. However, the dashboard router applies only authenticateToken. The controller reads req.user.id and uses that as the admin ID when assignToSelf=true. A buyer could self-assign as 'admin' to their own dispute, bypassing the intended workflow. + +**Doc Claim:** API docs: 'Bearer JWT (admin)'. Flow step 8: 'Admin clicks Pick up'. + +**Code Reality:** Backend router: router.post('/:id/assign', DisputeController.assignAdmin) — only authenticateToken, no authorizeRoles guard. Any user with assignToSelf=true will be set as dispute.adminId. + +**UAT Impact:** QA must test POST /api/disputes/:id/assign with a buyer token and assignToSelf=true — currently succeeds. Expected behavior: 403. + +#### M49. Resolve dispute does not trigger financial side effects — escrow state is unchanged + +**Description:** DisputeService.resolveDispute only updates the Dispute document status and resolution fields. It does not call releaseHoldService.resolveDispute, does not interact with Payment or PurchaseRequest models, and does not initiate any refund or release. The admin must separately call POST /api/disputes/:purchaseRequestId/resolve (releaseHold router) to unblock the escrow hold, and then separately trigger a payout or refund through the payment system. + +**Doc Claim:** Flow step 14: 'Depending on the resolution action, the admin manually triggers the financial side-effect'. API docs for POST /api/disputes/:id/resolve state 'triggers refund/release/split escrow action'. + +**Code Reality:** DisputeService.resolveDispute modifies only the Dispute document. No escrow interaction, no Payment update, no releaseHold call. The API docs claim is false — no escrow action is triggered automatically. + +**UAT Impact:** QA resolving a dispute through POST /api/disputes/:id/resolve must confirm that the payment escrow state is NOT automatically changed. A separate call to the hold-clear endpoint is required. The API doc claim that it 'triggers refund/release/split escrow action' will cause integration misuse. + +#### M50. Dispute timeline initialised twice: pre('save') middleware adds dispute_created, but service also sets timeline: [] + +**Description:** DisputeService.createDispute passes timeline: [] when creating the Dispute document. The Dispute model's pre('save') middleware then fires on the new document and pushes a dispute_created entry. This means the timeline always has exactly one entry after creation — the one added by the middleware — but any evidence items or other entries passed in the initial create call would be discarded because timeline: [] is explicitly set. This interaction is not documented. + +**Doc Claim:** Flow step 4 says 'creates the Dispute with ... empty timeline[]'. No mention of middleware auto-populating it. + +**Code Reality:** Dispute.pre('save') in /backend/src/models/Dispute.ts line 226: if (this.isNew) { this.timeline.push({ action: 'dispute_created', ... }) }. The service sets timeline: [] at creation, and the middleware appends to that empty array, resulting in one entry. + +**UAT Impact:** QA fetching a newly-created dispute via GET /api/disputes/:id should confirm timeline has exactly one entry (dispute_created). If timeline has zero entries, the middleware is not firing. If it has more, there is an unexpected duplicate. + +### Domain: Chat + +#### M51. leaveConversation frontend action calls non-existent backend endpoint PUT /api/chat/:id/leave + +**Description:** The frontend defines a leaveConversation action (chat.ts line 298) that calls PUT /chat/:id/leave. Neither the backend code summary nor the API docs list any endpoint at this path. The backend removes participants via DELETE /api/chat/:id/participants/:participantId. The leave endpoint is registered in the axios config (endpoints.chat.leave: '/chat/:id/leave') but has no backend handler. + +**Doc Claim:** Flow doc does not document a leave endpoint. API docs do not list it. Backend code does not expose it. + +**Code Reality:** Frontend has a dedicated leaveConversation action pointing to PUT /chat/:id/leave which does not exist on the backend. + +**UAT Impact:** QA must verify: when a user attempts to leave a group chat, the action must call DELETE /chat/:id/participants/:participantId instead. The current leave action will return 404. + +#### M52. GET /api/chat/:id/participants has no backend implementation + +**Description:** The frontend getParticipants action (chat.ts line 445) calls GET /chat/:id/participants. Neither the backend code summary nor the API docs list a GET endpoint at that path. The backend exposes POST and DELETE at that path, but no GET. This means the frontend function will always 404. + +**Doc Claim:** API docs list POST /api/chat/:id/participants and DELETE /api/chat/:id/participants/:participantId. No GET is listed. + +**Code Reality:** Frontend defines getParticipants calling GET /chat/:id/participants. Backend has no such route. + +**UAT Impact:** QA must verify: any UI that calls getParticipants to refresh the participant list will fail. Confirm participant data is only loaded through GET /chat/:id/info (which returns participants in the chat metadata). + +#### M53. PUT /api/chat/:id/participants/:participantId (role update) has no backend implementation + +**Description:** The frontend updateParticipantRole action (chat.ts line 456) calls PUT /chat/:id/participants/:participantId. The backend does not expose this route. The backend only has POST (add) and DELETE (remove) for participants. There is no role-update endpoint defined in backend code or API docs. + +**Doc Claim:** API docs do not document a role-update endpoint for participants. + +**Code Reality:** Frontend defines updateParticipantRole calling PUT /chat/:id/participants/:participantId — backend has no such route. + +**UAT Impact:** QA must verify: any admin UI for changing participant roles will silently fail with a 404 or 405. + +#### M54. editMessage sends field 'text' but backend expects field 'content' + +**Description:** The frontend editMessage action (chat.ts line 400) sends { text: string } as the request body. The backend PUT /api/chat/:id/messages/:messageId handler expects { content: string } (max 5000 chars) and will treat 'text' as an unrecognised field, resulting in a validation error or empty content update. + +**Doc Claim:** API docs specify body: content: string for PUT /api/chat/:id/messages/:messageId. + +**Code Reality:** Frontend editMessage sends { text: '...' } — field name is 'text', not 'content'. + +**UAT Impact:** QA must verify: edit a message in the chat UI, save, and confirm the message content is updated. Expect the backend to reject or ignore the 'text' field. + +#### M55. Flow doc states markAsRead is POST but backend and API docs define it as PATCH + +**Description:** The flow doc step 14 says 'frontend POSTs POST /api/chat/:chatId/read'. The actual backend endpoint is PATCH /api/chat/:id/messages/read. The API docs and backend code both agree on PATCH. The frontend correctly uses axiosInstance.patch() and the path /chat/:id/messages/read, so the implementation is correct, but the flow document has the wrong HTTP method and path. + +**Doc Claim:** Flow doc step 14: 'frontend POSTs POST /api/chat/:chatId/read'. + +**Code Reality:** Backend: PATCH /api/chat/:id/messages/read. Frontend markAsRead action (chat.ts line 476) correctly uses axiosInstance.patch() to /chat/:id/messages/read. + +**UAT Impact:** No runtime impact since the frontend is correct. Developers following the flow doc may write incorrect test scripts or integrations using POST — verify any external API clients use PATCH. + +#### M56. Flow doc describes POST /api/chat/:chatId/upload; this endpoint does not exist + +**Description:** Flow doc step 13 references 'POST /api/chat/:chatId/upload' and the docFlags section says the doc uses 'chatService.uploadChatFile(chatId, file) or the equivalent POST ...'. Neither the backend code nor the API docs expose /api/chat/:chatId/upload. The correct file endpoint is POST /api/chat/:id/messages/file. The misleading path in the doc led the frontend to also use the wrong endpoint (see the sendFileMessage finding). + +**Doc Claim:** Flow doc step 13: 'frontend POSTs POST /api/chat/:chatId/upload'. + +**Code Reality:** Backend exposes POST /api/chat/:id/messages/file. No /upload route exists for the chat domain. + +**UAT Impact:** Any integration test or API script that follows the flow doc and calls /api/chat/:chatId/upload will get a 404. Use /api/chat/:id/messages/file instead. + +#### M57. markAsRead with empty messageIds marks all unread — behavior undocumented + +**Description:** The flow doc step 14 mentions 'optionally with messageIds array' but does not state what happens when the array is omitted or empty. The backend note explicitly says 'omit to mark all'. The frontend clickConversation helper calls markAsRead(conversationId, []) (empty array) as a side-effect of opening a conversation, which will silently mark all messages read. This is a significant implicit behavior that is not documented. + +**Doc Claim:** Flow doc states the messageIds parameter is optional but does not describe the 'mark all' fallback behavior. + +**Code Reality:** Backend PATCH /api/chat/:id/messages/read: when messageIds is omitted or empty, all messages are marked as read. Frontend clickConversation (chat.ts line 493) always passes an empty array. + +**UAT Impact:** QA must verify: open a conversation with multiple unread messages, confirm all are marked read without supplying explicit messageIds. Also test that passing a specific messageIds array marks only those messages. + +#### M58. Flow doc lists 'user-online' as a client-to-server socket event; backend joins user room via 'join-user-room' not 'user-online' + +**Description:** The flow doc step 7 says 'frontend emits socket.emit(user-online, userId)'. The backend code shows that user-online is handled separately from room joining — user-online broadcasts a status change to all clients, while join-user-room actually joins the socket to the user-{userId} room. The frontend socket context (socket-context.tsx line 198) emits join-user-room for room joining, and setUserOnline (line 238) emits user-online for the online broadcast. The flow doc conflates these two distinct events. + +**Doc Claim:** Flow doc step 7: 'frontend emits socket.emit(user-online, userId) so other clients see green status'. Implies user-online is what joins the user room. + +**Code Reality:** Backend listens to join-user-room to add socket to room user-{userId}. user-online is a separate event that broadcasts user-status-change to all. They are distinct — room joining and online broadcasting are separate operations. + +**UAT Impact:** QA must verify: on login/app load, the frontend emits both join-user-room and user-online, and other online users see the green status indicator update. + +#### M59. disconnect does not emit offline status — doc implies it does + +**Description:** The documented socket event 'user-status-change' is described as 'emitted when user-online is received', which implies an online/offline toggle. The backend code note explicitly states: 'disconnect: logs disconnection (no offline-status broadcast in current implementation)'. So when a user disconnects, no user-status-change event is broadcast to other clients. Other users will never see the user go offline. + +**Doc Claim:** Flow doc implies user-status-change covers both online and offline transitions via socket events. + +**Code Reality:** Backend only emits user-status-change on user-online. On disconnect it only logs; no offline broadcast occurs. + +**UAT Impact:** QA must verify: have two users in a chat, one disconnects (closes tab/network loss), confirm the other user's UI does NOT update the online indicator to offline. This is a known gap — verify it does not cause stale 'online' indicators that mislead users. + +#### M60. POST /api/chat/purchase-request has no frontend UI or action wiring + +**Description:** The backend exposes POST /api/chat/purchase-request to create a direct chat linked to a PurchaseRequest. The axios endpoints config registers endpoints.chat.purchaseRequest = '/chat/purchase-request'. However, the frontend actions/chat.ts has no exported action function for this endpoint, and per the frontend actions audit there is no frontend UI that calls it. The flow doc describes a post-payment auto-chat mechanism but the manual trigger path has no frontend surface. + +**Doc Claim:** Flow doc step 5 describes a post-payment auto-chat; the API docs list POST /api/chat/purchase-request as a supported endpoint. + +**Code Reality:** No action function exists in chat.ts for the purchase-request endpoint. The axios endpoint key exists (endpoints.chat.purchaseRequest) but is unused by any exported action. + +**UAT Impact:** QA must verify: after payment is confirmed, confirm a direct chat between buyer and seller is automatically created (server-side). The manual creation path via this endpoint has no UI to test. + +#### M61. Backend enforces rate limiting (20 msgs/min) and message deduplication — not documented in flow + +**Description:** The backend uses Redis-based rate limiting (20 messages per user per chat per 60 seconds, 5 typing indicators per 10 seconds) and message deduplication via Redis (5-minute window). Neither the flow doc nor the API docs mention these constraints. Users and QA testers will encounter unexplained 429-style errors when hitting the rate limit. + +**Doc Claim:** Flow doc and API docs have no mention of rate limiting or message deduplication. + +**Code Reality:** Backend ChatRateLimiter enforces 20 msgs/min per user per chat. Typing indicators are rate-limited to 5 per user per 10 seconds. Message deduplication via Redis for 5 minutes. + +**UAT Impact:** QA must verify: rapidly send more than 20 messages in a minute in a single chat and confirm a meaningful error is returned. Verify the frontend handles this error gracefully (shows a user-facing message rather than silently failing). + +#### M62. Edit message has a 15-minute time window constraint not documented in flow doc + +**Description:** The backend enforces that messages can only be edited within 15 minutes of their original timestamp. After 15 minutes, a 400 error is returned. This constraint is not mentioned in the flow doc or API docs. The frontend editMessage action has no awareness of this limit and will fail silently after the window expires. + +**Doc Claim:** Flow doc and API docs do not mention any time restriction on message editing. + +**Code Reality:** Backend: 'messages can only be edited within 15 minutes of their original timestamp; attempts after that return a 400 validation error'. + +**UAT Impact:** QA must verify: attempt to edit a message sent more than 15 minutes ago and confirm the backend returns an appropriate error, and the frontend displays it to the user rather than silently failing. + +#### M63. Soft-delete on message DELETE and participant removal not documented in flow + +**Description:** The flow doc describes message deletion as 'rare' with no endpoint or narrative step. The backend actually soft-deletes messages (sets deletedAt, clears content) and emits message-deleted. Similarly participant removal sets isActive=false and records leftAt instead of removing the subdocument. Neither behavior is documented. Frontend message-deleted socket handling exists in use-chat-socket.ts (line 264) but the soft-delete semantics (content cleared, deletedAt set) are not reflected in the frontend data model. + +**Doc Claim:** Flow doc says deletion is 'rare' with no step. API docs say 'Soft-delete: sets deletedAt, clears content' but flow doc narrative is silent. + +**Code Reality:** Backend deleteMessage soft-deletes (sets deletedAt, clears content, repairs lastMessage). removeParticipant sets participant.isActive=false with leftAt timestamp. Frontend listens to message-deleted socket event. + +**UAT Impact:** QA must verify: delete a message, confirm it disappears from the UI (message-deleted socket event fires), and confirm the backend record shows deletedAt set and content cleared. Verify the same for participant removal (isActive=false, leftAt set). + +### Domain: Notification + +#### M64. PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete are undocumented + +**Description:** The backend exposes two bulk endpoints not present anywhere in the flow doc or API table: PATCH /notifications/bulk/mark-read (accepts { notificationIds: string[] }, loops individually) and DELETE /notifications/bulk/delete (accepts { notificationIds: string[] }, loops individually). Neither endpoint is listed in the documented API, and neither has a corresponding action function in the frontend notification.ts or an entry in axios.ts endpoints. These endpoints are unreachable from the frontend today. + +**Doc Claim:** API table lists only: GET /notifications, GET /notifications/unread-count, PATCH /notifications/:id/read, POST /notifications/read-all, DELETE /notifications/:id + +**Code Reality:** Backend also exposes PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete; neither is in the doc or accessible from the frontend + +**UAT Impact:** Test bulk mark-read and bulk delete via direct API calls. Confirm partial failure behavior (per-ID success/failure array) and that no atomic rollback occurs. + +#### M65. POST /api/notifications/mark-read (narrative step 8) matches no real endpoint + +**Description:** Step 8 of the flow narrative says 'frontend calls POST /api/notifications/mark-read' for per-item reading. The API table does not list this endpoint. The real single-notification read endpoint is PATCH /notifications/:id/read. The frontend correctly uses PATCH, but the narrative text creates confusion for QA and integrators who may expect a POST route at /mark-read. + +**Doc Claim:** Step 8: 'frontend calls POST /api/notifications/mark-read (or POST /api/notifications/read-all for bulk)' + +**Code Reality:** PATCH /notifications/:id/read for single, PATCH /notifications/mark-all-read for bulk. No POST /notifications/mark-read route exists. + +**UAT Impact:** Confirm that single-notification read uses PATCH /notifications/:id/read and returns the updated notification object with isRead=true and readAt set. + +#### M66. unread-count-update socket event is undocumented but actively used + +**Description:** The backend emits 'unread-count-update' to user-{userId} after createNotification, markAsRead, and markAllAsRead. The frontend notification-context.tsx explicitly listens for this event and calls setUnreadCount(data.unreadCount). The flow doc mentions only 'new-notification' and vaguely references 'notification-read' for syncing. The actual unread sync mechanism is 'unread-count-update', which is entirely absent from the documented socket events table. + +**Doc Claim:** Socket events table lists 'notification-read → user-{userId}' as the sync mechanism after markAsRead + +**Code Reality:** Backend emits 'unread-count-update' (payload: { unreadCount, timestamp }) after markAsRead, markAllAsRead, and createNotification. Frontend listens for 'unread-count-update', not 'notification-read'. + +**UAT Impact:** Open two browser tabs, mark a notification read in one tab, and verify the badge in the second tab updates automatically via the unread-count-update socket event without a page refresh. + +#### M67. notification-read socket event does not exist in the backend or frontend + +**Description:** The flow doc lists 'notification-read → user-{userId}' as a socket event emitted after markAsRead for cross-tab sync. Neither the backend socket event list nor the frontend socket-context.tsx nor notification-context.tsx registers any listener or emitter for 'notification-read'. This event is entirely fictional in the current implementation; the real sync is done via 'unread-count-update'. + +**Doc Claim:** Socket events: 'notification-read → user-{userId} (or unread count recomputed; emitted after markAsRead so other tabs sync)' + +**Code Reality:** No 'notification-read' event exists in backend or frontend. Cross-tab sync uses 'unread-count-update' instead. + +**UAT Impact:** Do not write test hooks for 'notification-read'. Verify cross-tab sync via 'unread-count-update' only. + +#### M68. useNotifications hook in use-notifications.ts has fetchNotifications stubbed out (TODO comment) + +**Description:** The hook at src/socket/hooks/use-notifications.ts has a fetchNotifications function that is entirely commented out with a 'TODO: Implement getNotifications' comment. The hook initialises an empty notifications array and never fetches real data from the API. It also assigns a temporary fake _id (Date.now()) to incoming socket notifications. The actual working implementation lives in the NotificationProvider context (notification-context.tsx), but any component using the use-notifications.ts hook directly will get empty state and malformed IDs. + +**Doc Claim:** Flow doc step 7: 'User opens bell-icon dropdown — frontend fetches GET /api/notifications for paginated history' + +**Code Reality:** src/socket/hooks/use-notifications.ts fetchNotifications body is a no-op TODO; real fetch only happens in NotificationProvider. Socket notifications receive fake string IDs. + +**UAT Impact:** Identify all components using useNotifications from src/socket/hooks/use-notifications.ts. Verify they display real data and not an empty list. Verify that incoming socket notifications show correct _id values (not timestamp-based strings). + +#### M69. GET /notifications/settings is wired in axios endpoints but has no backend route + +**Description:** The axios endpoints config at src/lib/axios.ts defines endpoints.notifications.settings = '/notifications/settings'. The frontend notes also flag this: 'the endpoint is defined in axios endpoints config but has no corresponding action function in notification.ts and no dedicated settings page'. There is no backend route for GET /notifications/settings in the backend endpoint list, and no action function in actions/notification.ts. The endpoint is dead at both ends. + +**Doc Claim:** Flow doc does not mention a /notifications/settings endpoint + +**Code Reality:** endpoints.notifications.settings = '/notifications/settings' exists in axios.ts but is never called by any action, has no backend handler, and has no frontend page + +**UAT Impact:** Confirm GET /notifications/settings returns 404. Remove or implement this endpoint before exposing settings UI. + +#### M70. Dual router registration creates ambiguity about which controller handles /notifications + +**Description:** The backend mounts notification routes from two separate files (notificationControllerRoutes.ts and routes.ts) both at the same /notifications prefix. Depending on app.ts mount order, one set of handlers will shadow the other. The flow doc and API table describe a single canonical set of endpoints without acknowledging this conflict. UAT cannot determine which controller's behaviour is authoritative without inspecting app.ts mount order. + +**Doc Claim:** API table presents a single clean set of notification endpoints with no mention of dual registration + +**Code Reality:** Two router registrations exist for /notifications. One may shadow the other depending on mount order. + +**UAT Impact:** Inspect app.ts to determine mount order. Run all notification endpoint tests and confirm which router's response headers/body are returned. Ensure the intended controller handles all routes. + +#### M71. Notification category 'general' is used in code but not listed in documented category enum + +**Description:** The backend POST /notifications endpoint docs state the default category is 'general'. The use-notifications.ts hook also hardcodes category: 'system' as a fallback for socket notifications. The flow doc lists valid category values as: purchase_request | offer | payment | delivery | system. The value 'general' is not in this list. If the schema enforces an enum, 'general' will cause validation errors on default-category notifications. + +**Doc Claim:** category values: purchase_request | offer | payment | delivery | system + +**Code Reality:** Backend POST /notifications defaults category to 'general'; socket hook hardcodes 'system'. Neither 'general' appears in the documented enum. + +**UAT Impact:** Create a notification via POST /notifications without specifying a category. Verify the saved document has a valid category value and does not fail schema validation. + +### Domain: Points/Referral + +#### M72. API doc claims GET /points/levels is public (no auth); backend requires authenticateToken + +**Description:** The API doc marks GET /points/levels with 'Bearer JWT' auth. The flow doc describes it as 'public info'. The backend pointsRoutes.ts applies router.use(authenticateToken) globally to ALL routes including /levels — there is no unauthenticated access. The API spec says auth is required which matches the code, but the flow doc's description of it as 'public' is misleading. + +**Doc Claim:** Referral Flow: GET /api/points/levels is listed as 'GET /api/points/levels — level config (public)'. + +**Code Reality:** pointsRoutes.ts line 8: router.use(authenticateToken) applies to all routes including /levels. The route comment says 'public info' but auth is enforced. + +**UAT Impact:** Verify that an unauthenticated GET /api/points/levels returns 401. Confirm any marketing page or public-facing level display that needs to show tiers without login has a separate unauthenticated mechanism or is served from static config. + +#### M73. POST /points/redeem request/response shape mismatch between API doc and actual code + +**Description:** The API doc describes the redeem endpoint body as: amount (required), purpose (wallet_credit|discount_code, optional), purchaseRequestId (optional). The actual controller expects: purchaseRequestId (required) and pointsToUse (required) — 'amount' is not used, 'purpose' is not accepted. The response shape also differs: doc claims redemption object with creditAmount/currency/code; code returns discount (numeric, always pointsToUse * 1000 IRR) and remainingPoints. The 'purpose' field controlling wallet_credit vs discount_code behavior does not exist in the code. + +**Doc Claim:** POST /api/points/redeem body: amount (number, required), purpose (wallet_credit|discount_code, optional), purchaseRequestId (optional). Response: transaction, redemption (creditAmount/currency/code), newBalance. + +**Code Reality:** pointsController.ts lines 128-135: expects purchaseRequestId (required) and pointsToUse (required). No 'amount' field. No 'purpose' field. Response: { transaction, discount (pointsToUse * 1000), remainingPoints }. No 'newBalance' or 'redemption' object. + +**UAT Impact:** Test POST /points/redeem with { amount: 100, purchaseRequestId: '...' } — it will fail because 'pointsToUse' is the required field name. Test with { pointsToUse: 100, purchaseRequestId: '...' } to confirm success. Verify discount calculation is always 1 point = 1000 IRR (no currency flexibility). Confirm purpose/wallet_credit/discount_code options are unavailable. + +#### M74. POST /points/admin/add request body mismatch: doc requires 'reason', code accepts 'description' (and silently ignores it) + +**Description:** The API doc specifies the admin add endpoint body as: userId (required), amount (required), reason (string, required), metadata (object, optional). The actual controller reads: userId, amount, description — no 'reason' field. Furthermore the 'description' value is read from req.body but never passed to PointsService.addPoints (the call at line 209 passes an empty metadata object {}). Both 'reason' and 'description' are effectively dropped. + +**Doc Claim:** POST /api/points/admin/add body: userId (string, required), amount (number, required), reason (string, required), metadata (object, optional). + +**Code Reality:** pointsController.ts lines 201-209: reads { userId, amount, description } — 'reason' is never read, 'description' is read but not passed to addPoints. The PointsService.addPoints call is addPoints(userId, amount, 'admin', {}) with empty metadata. + +**UAT Impact:** Test POST /points/admin/add with a reason field — verify the reason does not appear in the PointTransaction record. Confirm that admin-granted points have no human-readable reason stored, which hinders audit trails. + +#### M75. API doc claims leaderboard supports period filter (all|month|week); backend ignores it + +**Description:** The API doc states GET /points/leaderboard accepts a 'period' query parameter (all|month|week). The actual controller only reads 'limit' from req.query and passes it to getLeaderboard(limitNum). PointsService.getLeaderboard only accepts a limit parameter — there is no period/date filtering in the aggregation pipeline. All leaderboard queries return all-time data regardless of any period parameter sent. + +**Doc Claim:** GET /api/points/leaderboard query: limit (default 10), period (all|month|week). + +**Code Reality:** pointsController.ts line 232: only reads 'limit' from req.query. PointsService.getLeaderboard(limit) only accepts limit. No period filtering exists. + +**UAT Impact:** Test GET /points/leaderboard?period=week — confirm the response is identical to ?period=all, proving the filter is silently ignored. Document this as a known limitation for the leaderboard feature. + +#### M76. POST /points/generate-referral-code: doc says 'force' param rotates existing code; code always overwrites + +**Description:** The API doc lists a 'force' boolean parameter for generate-referral-code that implies conditional behavior (generate only if missing vs force-rotate). The actual controller does not read the 'force' param at all. PointsService.generateReferralCode always generates a new code and overwrites any existing one via User.findByIdAndUpdate. There is no 'force' conditional — the behavior is always to replace. The response also only returns { referralCode } — not { referralCode, link } as the doc states. + +**Doc Claim:** POST /api/points/generate-referral-code body: force (boolean, optional). Response: referralCode, link. + +**Code Reality:** pointsController.ts line 177: only calls PointsService.generateReferralCode(userId) with no force param. Response: { referralCode } only — no 'link' field returned. + +**UAT Impact:** Verify that calling POST /points/generate-referral-code without force=true still always regenerates the code. Verify the response does not include a 'link' field. Check whether the frontend invite-friends component constructs the share URL client-side (it does: points-invite-friends.tsx line 36 builds the URL from NEXT_PUBLIC_API_URL). + +#### M77. GET /points/transactions type filter: API doc accepts 'redeem|referral|purchase|review|admin_grant|admin_deduct'; backend only accepts 'earn|spend|expire' + +**Description:** The API doc lists the transactions type filter as: earn|redeem|referral|purchase|review|admin_grant|admin_deduct. The actual backend and frontend action both use the PointTransaction.type enum which is earn|spend|expire. 'redeem', 'referral', 'purchase', 'review', 'admin_grant', 'admin_deduct' are all invalid type values that the backend will silently ignore (no match). The correct way to filter by referral transactions is by source (source='referral'), not by type. + +**Doc Claim:** GET /api/points/transactions query type filter: earn|redeem|referral|purchase|review|admin_grant|admin_deduct. + +**Code Reality:** PointsService.getTransactions filters by PointTransaction.type which is enum ['earn', 'spend', 'expire']. Frontend actions/points.ts also types it as 'earn' | 'spend' | 'expire'. No source-based filtering is supported by the transactions endpoint. + +**UAT Impact:** Test GET /points/transactions?type=referral — verify it returns 0 results or all results (no filtering), not referral-source transactions. Test with type=earn to confirm it works. Verify there is no way to filter transactions by source (purchase, referral, admin) through the API. + +#### M78. Referral reward triggered on 'completed' status only; doc says 'delivered or completed' + +**Description:** The backend notable logic note states referral reward is awarded on 'status delivered or completed'. The actual code in marketplaceController.ts shows PointsService.processReferralReward is only called when newStatus === 'completed' (line 473). There is no 'delivered' trigger. If the flow ends at 'delivered' without reaching 'completed', the referrer never earns their commission. + +**Doc Claim:** Backend notable logic: '2% of the selected offer price... awarded in points to the referrer on purchase completion (status delivered or completed)'. + +**Code Reality:** marketplaceController.ts line 473: processReferralReward is called only inside `if (newStatus === 'completed')`. No 'delivered' trigger exists. + +**UAT Impact:** Create a purchase request with a referred buyer, advance it to 'delivered' status, and verify no referral points are awarded. Then advance to 'completed' and verify points are awarded. Confirm this is the intended behavior or flag as a gap. + +#### M79. Points redemption has no UI — redeemPoints action is wired to nothing + +**Description:** The redeemPoints action is defined and correctly calls POST /points/redeem, but it is never invoked from any component or page in the frontend. There is no checkout integration, no discount code entry, and no redemption confirmation modal. Users cannot redeem points through the UI at all. + +**Doc Claim:** Referral Flow step 12: 'Users view their balance at /dashboard/account/points and can spend points via POST /api/points/redeem (e.g. for service credit or discount codes)'. + +**Code Reality:** grep across all frontend src files confirms redeemPoints is only defined in actions/points.ts and never called from any component or page. + +**UAT Impact:** Confirm that on any purchase/checkout flow there is no 'use points' option. Confirm /dashboard/points has no redeem button or form. This is a complete missing feature that blocks the points-spend use-case entirely. + +#### M80. Referrals list page does not exist despite route path being defined + +**Description:** paths.ts defines /dashboard/points/referrals as a route. The getReferrals action is correctly defined. However no Next.js page file exists at /app/dashboard/points/referrals/ and getReferrals is never called from any component. The route path will 404. + +**Doc Claim:** Referral Flow step 12 implies users can view referred users. GET /api/points/referrals is listed as a supported endpoint. + +**Code Reality:** find /app/dashboard/points confirms only page.tsx, error.tsx, loading.tsx exist — no referrals/ subdirectory. getReferrals action is never imported or called anywhere outside actions/points.ts. + +**UAT Impact:** Navigate to /dashboard/points/referrals — confirm it returns a 404 or renders the parent layout with no content. Verify there is no link to this page in the points dashboard nav. + +#### M81. Full paginated transactions page does not exist; only first 5 items shown in overview + +**Description:** The points main view fetches only { page: 1, limit: 5 } transactions for the 'recent transactions' widget and provides a 'view all' button that routes to /dashboard/points/transactions. However no Next.js page exists at /app/dashboard/points/transactions/. The route will 404. + +**Doc Claim:** Points main view has a handleViewTransactions callback pushing to paths.dashboard.points.transactions. + +**Code Reality:** find /app/dashboard/points shows no transactions/ subdirectory. The 'View All' button in points-main-view.tsx will navigate to a non-existent route. + +**UAT Impact:** Click 'View All Transactions' on the points dashboard. Confirm the route 404s or shows an error page. Confirm there is no paginated history accessible to users. + +#### M82. generateReferralCode action is never called; there is no regenerate button in the UI + +**Description:** The PointsInviteFriends component displays the referral code passed as a prop (fetched once via getMyPoints) but has no button wired to generateReferralCode. The share UI is static — the code cannot be rotated from the UI. The POST /points/generate-referral-code endpoint is unused from the frontend. + +**Doc Claim:** Referral Flow step 1-2: 'User opens /dashboard/account/referrals; if no code exists, clicks Generate code'. Frontend generates via POST /api/points/generate-referral-code. + +**Code Reality:** points-invite-friends.tsx only displays referralCode prop — no button calls generateReferralCode. The action is defined in actions/points.ts but never invoked from any component. + +**UAT Impact:** On the points dashboard, verify there is no 'Generate Code' or 'Regenerate Code' button. Confirm that if a user's referral code is auto-generated by getMyPoints lazy-bootstrap, there is no way to intentionally rotate it via the UI. + +#### M83. Levels/tiers page does not exist and getLevels is never called from any component + +**Description:** paths.ts defines /dashboard/points/levels but no page file exists at /app/dashboard/points/levels/. getLevels action is defined but never called from any component or page — users cannot see the loyalty tier structure, benefits, or thresholds. + +**Doc Claim:** Frontend actions list getLevels as returning 'all loyalty level configurations (thresholds, benefits, icons)'. + +**Code Reality:** No page at /app/dashboard/points/levels/. getLevels is never imported or called in any component outside actions/points.ts. + +**UAT Impact:** Navigate to /dashboard/points/levels — expect 404. Confirm that the PointsLevelProgress component in the main view only shows current level vs next level (not the full tier ladder). + +#### M84. Admin points management page does not exist; adminAddPoints action is never invoked + +**Description:** No admin page exists under /app/dashboard/admin/ for managing user points. The adminAddPoints action is defined and the backend endpoint is functional, but there is no UI surface for admins to grant or deduct points. + +**Doc Claim:** Frontend actions list adminAddPoints as 'Admin action to manually add points to any user account'. API doc specifies POST /api/points/admin/add with userId, amount, reason. + +**Code Reality:** find /app/dashboard/admin confirms only confirmation-thresholds, derived-destinations, networks, payments-awaiting-confirmation subdirectories exist. No points-management page. + +**UAT Impact:** Confirm there is no admin UI to manually adjust user point balances. Admins must use direct API calls or database access. Test that POST /points/admin/add works via curl/Postman with a valid admin JWT. + +### Domain: User Management + +#### M85. No frontend action for wallet address read or update + +**Description:** The backend exposes GET /api/user/wallet-address (returns walletAddress, type, provider) and PATCH /api/user/wallet-address (EVM signature-verified update, TON with optional TonProof). The axios endpoints object defines both endpoints.users.walletAddress and endpoints.users.updateWalletAddress, but no function in user.ts or any other action file consumes them. Users cannot view or update their wallet address through the frontend. + +**Doc Claim:** API doc specifies GET and PATCH /api/user/wallet-address with full request/response shapes. + +**Code Reality:** axios.ts lines 219-220 define the endpoints. user.ts has no getWalletAddress or updateWalletAddress function. missingFrontendFeatures list confirms this gap. + +**UAT Impact:** QA must verify there is no wallet management UI. If wallet address display/edit is expected in the user profile or settings page, this is a feature gap that needs a frontend implementation. + +#### M86. No frontend action for email verification after profile email change + +**Description:** The backend automatically invalidates email verification when a user changes their email via PUT /api/user/profile and sends a 6-digit verification code. Two endpoints exist to support the follow-up flow: POST /api/user/profile/email/verify and POST /api/user/profile/email/resend-verification. Neither has a corresponding frontend action function, meaning users who change their email have no UI path to re-verify it. + +**Doc Claim:** Backend notableLogic: 'Changing a user's email via PUT /api/user/profile automatically sets isEmailVerified=false and immediately dispatches a new 6-digit verification code.' Endpoints are documented in the API. + +**Code Reality:** axios.ts defines endpoints.users.verifyProfileEmail and endpoints.users.resendProfileEmailVerification. user.ts has no verifyProfileEmail or resendProfileEmailVerification functions. missingFrontendFeatures list confirms this gap. + +**UAT Impact:** QA must test changing an email address in profile settings. Verify whether a verification prompt appears and whether the user can complete verification. If not, the email change flow is incomplete. + +#### M87. No frontend action for admin user stats endpoint + +**Description:** The backend provides GET /api/users/admin/stats returning aggregated totals (total/active/verified counts, role distribution, 24h/7d/30d activity buckets). The endpoint is defined in axios.ts as endpoints.users.admin.stats but no function in user.ts or any admin action file fetches it. Admin analytics/dashboard cannot display these statistics. + +**Doc Claim:** API doc specifies GET /api/users/admin/stats with full response shape. axios.ts line 223 defines the endpoint. + +**Code Reality:** No function in user.ts or discovered action files calls endpoints.users.admin.stats. missingFrontendFeatures list confirms this gap. + +**UAT Impact:** QA must check whether an admin statistics/analytics page exists. If it does, verify the data is populated. If it does not, treat as a missing feature. + +#### M88. getUserAddresses calls /addresses (GET) but frontend docs describe endpoint as /addresses/list + +**Description:** The getUserAddresses action calls endpoints.addresses.list which resolves to '/addresses' (GET). The frontend action documentation describes the apiPath as '/addresses/list'. The backend API doc lists GET /api/addresses (no /list suffix). The actual path in axios.ts is correct ('/addresses'), but the action documentation path '/addresses/list' is wrong and could mislead developers or QA. + +**Doc Claim:** Frontend action doc for getUserAddresses: apiPath = '/addresses/list'. + +**Code Reality:** axios.ts addresses.list = '/addresses'. user.ts calls endpoints.addresses.list which resolves to '/addresses'. Backend registers GET /api/addresses. + +**UAT Impact:** QA should verify GET /api/addresses returns the user's address list correctly. No runtime bug expected since the code path is correct, but the documentation is misleading. + +#### M89. API doc describes PATCH /api/user/admin/:userId/status body as isActive boolean; backend also accepts status string and isEmailVerified + +**Description:** The API doc states the body for PATCH /api/user/admin/:userId/status is '{ isActive: boolean, reason?: string }'. The actual backend (new controller) accepts a status string ('active'/'suspended'/'deleted') and also accepts isEmailVerified as a boolean. The doc omits the status string form and the isEmailVerified field, which are actually implemented. + +**Doc Claim:** API doc: body = 'isActive: boolean, reason?: string'. + +**Code Reality:** Backend notes: 'Set status to active/suspended/deleted; also accepts isEmailVerified boolean'. The new controller uses a status string enum, not a boolean isActive. + +**UAT Impact:** QA must test the status update endpoint with both the boolean isActive form and the string status form to determine which the new controller actually validates. Also verify isEmailVerified can be set through this endpoint. + +#### M90. API doc states admin DELETE blocks admin-on-admin; new controller only blocks self-deletion + +**Description:** The API doc states DELETE /api/user/admin/:userId returns '403 admin-on-admin' error when an admin tries to delete another admin. The actual new controller (DELETE /api/user/admin/:userId) only blocks self-deletion; it does not prevent deleting other admins. The legacy route (DELETE /api/users/admin/:userId) does block admin-on-admin deletion. These two routes now have divergent authorization logic. + +**Doc Claim:** API doc: 'errors: 400 self-delete, 403 admin-on-admin, 404 not found'. + +**Code Reality:** Backend notableLogic: 'Legacy admin DELETE /api/users/admin/:userId blocks deletion of users with role=admin; new controller DELETE /api/user/admin/:userId only blocks self-deletion.' + +**UAT Impact:** QA must test deleting another admin account via the new controller endpoint and verify whether a 403 is returned. If no 403, an admin can delete other admin accounts — a significant privilege escalation issue. + +#### M91. API doc states resend-verification generates 8-digit code; new controller generates 6-digit code + +**Description:** The API doc for POST /api/users/admin/:userId/resend-verification states it 'Regenerates 8-digit email verification code'. The backend notableLogic explicitly documents a discrepancy: the new userController uses 6-digit codes while the legacy userRoutes uses 8-digit codes. The legacy endpoint path /api/users/admin/:userId/resend-verification uses 8-digit codes, but the new controller endpoint /api/user/admin (if it had a resend endpoint) would use 6-digit codes. + +**Doc Claim:** API doc POST /api/users/admin/:userId/resend-verification: 'Regenerates 8-digit email verification code and re-sends email'. + +**Code Reality:** Backend notableLogic: 'Email verification code is a 6-digit numeric code (userController) or 8-digit numeric code (legacy userRoutes). Both expire 15 minutes after generation. There is a discrepancy in code length.' + +**UAT Impact:** QA must trigger admin resend-verification and check the actual code length in the email. Verify whether the UI input field accepts 6 or 8 digits and whether validation matches the code sent. + +#### M92. TON wallet proof challenge endpoint not documented + +**Description:** The backend exposes POST /api/user/wallet-address/ton-proof/challenge to generate a TON proof nonce for the current user. This endpoint is not listed in the API documentation at all. Any frontend wallet connection flow for TON wallets using TonProof requires this endpoint first. + +**Doc Claim:** API doc does not mention POST /api/user/wallet-address/ton-proof/challenge. + +**Code Reality:** Backend endpoint list includes: POST /api/user/wallet-address/ton-proof/challenge — 'Generate a TON proof nonce/challenge for the current user'. + +**UAT Impact:** QA must check whether TON wallet connection is implemented in the frontend. If so, verify the challenge/nonce flow is called before submitting the TonProof. If not, this is a missing feature for TON wallet users. + +#### M93. Profile email verification flow endpoints (POST /user/profile/email/verify and resend) not in API docs + +**Description:** The backend implements two endpoints for re-verifying email after a profile email change: POST /api/user/profile/email/verify (accepts 6-digit code) and POST /api/user/profile/email/resend-verification. Neither endpoint appears in the API documentation. This makes the email change flow invisible to developers and QA. + +**Doc Claim:** API doc does not list POST /api/user/profile/email/verify or POST /api/user/profile/email/resend-verification. + +**Code Reality:** Backend endpoint list includes both endpoints. axios.ts defines endpoints.users.verifyProfileEmail and endpoints.users.resendProfileEmailVerification. + +**UAT Impact:** QA must test the complete email-change flow: change email in profile, receive verification code email, enter code in UI (if UI exists), confirm isEmailVerified is set back to true. + +### Domain: Admin Operations + +#### M94. User admin endpoint prefix inconsistency: doc mixes /api/user/ and /api/users/ + +**Description:** The API doc describes create, delete, status, toggle-status, role, list, and dependencies under /api/user/admin/* (singular). The stats, get-by-id, PUT update, PUT update/:email, password, and resend-verification are listed under /api/users/admin/* (plural). The frontend consistently calls /api/users/admin/* (plural) for all operations. The backend has two separate route groups (/api/user/* new controller, /api/users/* legacy), and the admin sub-routes are mounted on the legacy /api/users/* group. + +**Doc Claim:** PATCH /api/user/admin/:userId/status, DELETE /api/user/admin/:userId, PATCH /api/user/admin/:userId/role, GET /api/user/admin/list, etc. + +**Code Reality:** Frontend uses /api/users/admin/:id/status (plural), /api/users/admin/:id, etc. Backend mounts admin routes on /api/users/* (legacy). The singular /api/user/admin/* paths documented for create/delete/status/role/list are incorrect. + +**UAT Impact:** QA must verify: PATCH /api/user/admin/:userId/status returns 404 while PATCH /api/users/admin/:userId/status returns 200 with valid admin token. Confirm all documented /api/user/admin/* singular paths are unreachable. + +#### M95. updateUserStatus and updateUserRole use PUT in frontend but PATCH in API doc + +**Description:** The API doc specifies PATCH /api/user/admin/:userId/status and PATCH /api/user/admin/:userId/role. The frontend user actions (src/actions/user.ts) call axiosInstance.put() for both updateUserStatus and updateUserRole. HTTP method semantics differ: PATCH is partial update, PUT is full replacement. If the backend only registers a PATCH handler, all PUT calls from the frontend will fail with 404 or 405. + +**Doc Claim:** PATCH /api/user/admin/:userId/status, PATCH /api/user/admin/:userId/role + +**Code Reality:** Frontend calls PUT /api/users/admin/:id/status and PUT /api/users/admin/:id/role. The frontend actions file at /Users/manwe/CascadeProjects/escrow/frontend/src/actions/user.ts lines 162 and 175 use axiosInstance.put(). + +**UAT Impact:** QA must verify: Using the admin UI to change a user status or role succeeds. If the backend only accepts PATCH, these actions will silently fail in production. Test both the network tab method and the server response. + +#### M96. updateUserStatus frontend accepts 'inactive'/'pending' but API doc says 'active'/'suspended' + +**Description:** The API doc for PATCH /api/user/admin/:userId/status states the body accepts status values of 'active' or 'suspended'. The frontend TypeScript type in updateUserStatus (src/actions/user.ts line 159) constrains the parameter to 'active' | 'inactive' | 'pending'. The value 'suspended' from the doc is absent from the frontend, and 'inactive'/'pending' are not mentioned in the doc. This creates a mismatch where the frontend may send values the backend does not recognize. + +**Doc Claim:** body: status (active/suspended) + +**Code Reality:** Frontend type: status: 'active' | 'inactive' | 'pending'. The value 'suspended' is not in the frontend union type. + +**UAT Impact:** QA must verify: sending status='suspended' from a test client updates the user correctly. Verify what the backend model actually accepts. Test that the frontend status toggle UI sends a value the backend understands. + +#### M97. POST /api/admin/cleanup/clean: confirm body param documented as optional but backend requires it for real deletions + +**Description:** The API doc lists the confirm parameter for POST /api/admin/cleanup/clean with a question mark suffix, implying it is optional. The backend notableLogic explicitly states: 'Requires body.confirm="DELETE_ALL_DATA" and dryRun=false for actual deletion.' Omitting confirm will either silently do nothing or error, depending on implementation — not perform the deletion as a caller following the doc would expect. + +**Doc Claim:** body: collections?, dryRun?, keepAdmins?, olderThanDays?, confirm? (all optional per doc notation) + +**Code Reality:** Backend enforces confirm='DELETE_ALL_DATA' for any non-dry-run execution. The field is required to perform actual cleanup, not optional. + +**UAT Impact:** QA must verify: POST /api/admin/cleanup/clean with dryRun=false but no confirm field — should be rejected, not execute deletions. Verify the API returns a clear error rather than silently skipping. + +#### M98. AML settings endpoints entirely absent from API documentation + +**Description:** The backend exposes GET and PATCH /api/admin/settings/aml to read and update the AML provider configuration at runtime. These are admin-only operations that mutate process.env (provider and cost) without persistence — changes are lost on server restart. Neither endpoint is mentioned in the API doc. Additionally there is no frontend page or action for AML settings management. + +**Doc Claim:** No entry for /api/admin/settings/aml in the API doc + +**Code Reality:** Backend registers GET /api/admin/settings/aml (returns provider=none|chainalysis and costUsd, never exposes API key) and PATCH /api/admin/settings/aml (updates process.env at runtime). The runtime-only nature of the PATCH means a server restart silently reverts any change. + +**UAT Impact:** QA must verify: GET /api/admin/settings/aml returns current AML config. PATCH updates the running config. After a server restart, confirm the change is lost (documents the persistence limitation). No frontend UI exists for this operation. + +#### M99. Confirmation thresholds, awaiting-confirmation, and RN network registry endpoints are undocumented + +**Description:** The backend exposes a suite of admin blockchain-monitoring endpoints that have corresponding frontend pages and actions but are absent from the API doc: GET /api/admin/settings/confirmation-thresholds, PATCH /api/admin/settings/confirmation-thresholds/:chainId, GET /api/admin/payments/awaiting-confirmation, and GET /api/admin/rn/networks. All four have frontend pages under /dashboard/admin/ and dedicated action files (confirmation-thresholds.ts, network-registry.ts). + +**Doc Claim:** None of these endpoints appear in the API documentation + +**Code Reality:** All four endpoints exist in backend, have admin auth, and are actively used by the frontend at /Users/manwe/CascadeProjects/escrow/frontend/src/app/dashboard/admin/ + +**UAT Impact:** QA must verify: all four endpoints return correct data with admin JWT. The awaiting-confirmation page shows payments with tx hash but not yet in funded/released state. The confirmation-thresholds page shows and allows updating per-chain values persisted to DB. + +#### M100. Derived destinations and sweep endpoints are undocumented + +**Description:** The backend exposes a full derived-destinations management suite (derive, list, by-id, balance, sweep, native-balance, sweep-native) all under /api/payment/derived-destinations/* with admin auth. A frontend page exists at /dashboard/admin/derived-destinations with corresponding actions in src/actions/derived-destinations.ts. None of these endpoints appear in the API doc. + +**Doc Claim:** No /api/payment/derived-destinations/* endpoints in the API doc + +**Code Reality:** Backend registers 7 derived-destinations endpoints. Frontend page at /Users/manwe/CascadeProjects/escrow/frontend/src/app/dashboard/admin/derived-destinations/ calls getDerivedDestinations, triggerSweep, triggerSingleSweep, getSweepCronStatus, startSweepCron, stopSweepCron. + +**UAT Impact:** QA must verify the complete derived-destinations workflow: list all destinations, trigger a dry-run sweep, check cron status, start/stop the cron, and trigger a single-destination sweep. + +#### M101. Frontend calls derived-destinations cron and single-sweep endpoints not present in backend data + +**Description:** The frontend derived-destinations actions call four endpoints that do not appear in the backend endpoint inventory: GET /api/payment/derived-destinations/cron/status, POST /api/payment/derived-destinations/cron/start, POST /api/payment/derived-destinations/cron/stop, and POST /api/payment/derived-destinations/:id/sweep. The backend lists only the bulk POST /api/payment/derived-destinations/sweep and POST /api/payment/derived-destinations/:id/sweep-native. The cron management and per-id token sweep may be unimplemented. + +**Doc Claim:** Frontend actions: getSweepCronStatus, startSweepCron, stopSweepCron, triggerSingleSweep (all defined in src/actions/derived-destinations.ts) + +**Code Reality:** Backend data lists no cron/* endpoints and no plain /:id/sweep endpoint (only /:id/sweep-native). The derived-destinations UI page actively calls getSweepCronStatus on mount. + +**UAT Impact:** QA must verify: open /dashboard/admin/derived-destinations and observe the network tab — check whether the cron status request returns 200 or 404. Attempt to start/stop the cron and trigger a single sweep; any 404 confirms the backend routes are missing. + +#### M102. Frontend calls network registry reload and chain probe endpoints not in backend data + +**Description:** The frontend network-registry actions call POST /api/admin/rn/networks/reload and POST /api/admin/rn/networks/probe/:chainId. The backend endpoint inventory lists only GET /api/admin/rn/networks. The reload and probe operations may not be implemented on the backend, causing the UI buttons for these actions to silently fail. + +**Doc Claim:** Frontend actions reloadNetworkRegistry and probeChain defined in src/actions/network-registry.ts + +**Code Reality:** Backend data shows only GET /api/admin/rn/networks. No reload or probe endpoints are registered. + +**UAT Impact:** QA must verify: in the /dashboard/admin/networks page, click Reload Registry and Probe Chain buttons. Check network tab for 404 responses. Confirm whether these are implemented on the backend. + +#### M103. Frontend calls GET /api/admin/settings/confirmation-thresholds/history which is not in backend data + +**Description:** The frontend confirmation-thresholds action file defines getConfirmationThresholdHistory() which calls GET /api/admin/settings/confirmation-thresholds/history. The backend endpoint inventory lists only GET /api/admin/settings/confirmation-thresholds (returns current values) and PATCH /api/admin/settings/confirmation-thresholds/:chainId (update). A history endpoint is not registered. + +**Doc Claim:** Frontend action getConfirmationThresholdHistory in src/actions/confirmation-thresholds.ts calls /admin/settings/confirmation-thresholds/history + +**Code Reality:** Backend data lists no history endpoint for confirmation thresholds. Only the current-values GET and the per-chain PATCH exist. + +**UAT Impact:** QA must verify: if the confirmation-thresholds UI page requests history, confirm the network request returns 404 or 200. Determine if history tracking was ever implemented. + +#### M104. GET /api/disputes/statistics auth: doc claims admin-only, backend applies only authenticateToken + +**Description:** The API doc lists GET /api/disputes/statistics as requiring Bearer JWT with role=admin. The backend registers this endpoint with only authenticateToken middleware, with no authorizeRoles(admin) guard. Any authenticated non-admin user can access aggregate dispute KPI data. + +**Doc Claim:** GET /api/disputes/statistics requires Bearer JWT, role=admin + +**Code Reality:** Backend registers GET /api/disputes/statistics with auth: authenticateToken only. No role restriction is enforced at the route or controller level per the backend data. + +**UAT Impact:** QA must verify: call GET /api/disputes/statistics with a valid non-admin user JWT. If it returns 200 with statistics data, the endpoint is under-protected. It should return 403 for non-admin users. + +#### M105. POST /api/payment/payments/cleanup-pending: doc claims admin middleware, backend enforces admin in handler only + +**Description:** The API doc states cleanup-pending requires Bearer JWT with role=admin (implying middleware-level enforcement). The backend registers this with only authenticateToken at the route level; the admin check is performed inside the handler. Any authenticated non-admin who discovers this endpoint can attempt to call it; the in-handler check is the only defense. + +**Doc Claim:** POST /api/payment/payments/cleanup-pending requires Bearer JWT, role=admin + +**Code Reality:** Backend registers: auth: authenticateToken, notes: 'Admin check inside handler; deletes pending payments older than 2h'. No authorizeRoles(admin) middleware at the route level. + +**UAT Impact:** QA must verify: call POST /api/payment/payments/cleanup-pending with a valid non-admin user JWT. Should return 403. Also verify the cleanup logic only deletes pending payments older than 2 hours. + +#### M106. POST /api/points/admin/add: doc claims middleware-level admin auth, backend uses handler-level check + +**Description:** The API doc lists POST /api/points/admin/add as requiring Bearer JWT with role=admin. The backend registers this with authenticateToken and a role check inside the handler, not via authorizeRoles middleware. The distinction matters because a middleware rejection returns 403 before any business logic runs; a handler check may have edge-case bypass risk. + +**Doc Claim:** POST /api/points/admin/add requires Bearer JWT, role=admin + +**Code Reality:** Backend: auth: authenticateToken + role check in handler. The /api/admin/ path prefix and the 'admin/add' route name imply admin-level protection but the middleware chain does not enforce it. + +**UAT Impact:** QA must verify: send POST /api/points/admin/add with a non-admin JWT. Should return 403. Verify the role check fires before any points mutation occurs. + +#### M107. No admin UI for shkeeper release, refund, payout, and webhook-stats operations + +**Description:** The API doc defines admin-only shkeeper endpoints for triggering escrow releases, refunds, payout tasks, and reading webhook telemetry. No frontend page, action, or component exists for any of these operations. The frontend does have releasePayment and processRefund in src/actions/payment.ts but these call /api/payment/:id/release (not the documented shkeeper paths) and are invoked from payment detail views, not dedicated admin tooling. + +**Doc Claim:** POST shkeeper release/confirm, POST shkeeper refund/confirm, POST shkeeper payout, GET shkeeper/webhook-stats all documented as admin endpoints + +**Code Reality:** No frontend page exists under /dashboard/admin/ or elsewhere for these shkeeper admin operations. src/actions/payment.ts has releasePayment hitting /payment/:id/release (correct backend path) but no dedicated admin shkeeper management UI. + +**UAT Impact:** QA must verify: admins have no in-app way to trigger shkeeper release/refund or create payout tasks. These operations must currently be done via API client or curl. Confirm whether this is intentional or a missing feature. + +#### M108. No admin UI for data cleanup, seeder, and GDPR user-deletion operations + +**Description:** The API doc defines a full cleanup/maintenance suite: stats, collections list, bulk clean, GDPR per-user deletion, temp cleanup, seed-templates, and seed-all. All are admin-only. No frontend page or action exists for any of these endpoints. They can only be called directly via API. + +**Doc Claim:** GET /api/admin/cleanup/stats, GET /api/admin/cleanup/collections, POST /api/admin/cleanup/clean, DELETE /api/admin/cleanup/user/:userId, POST /api/admin/cleanup/temp, POST /api/admin/cleanup/seed-templates, POST /api/admin/cleanup/seed-all + +**Code Reality:** No frontend page under /dashboard/admin/ handles any cleanup route. No action file imports or calls these endpoints. The backend has all routes with proper auth. + +**UAT Impact:** QA must verify: admins cannot currently perform GDPR user data deletion, run data cleanup, or seed templates through the UI. These are operational gaps. Confirm if a separate admin tool or script covers these. + +#### M109. No admin UI for user password reset, resend-verification, update-by-email, and user stats + +**Description:** The API doc defines admin endpoints for resetting any user's password (wiping refresh tokens), resending verification email, updating user by email address, and viewing aggregate user statistics. None of these have frontend UI, action functions, or routes. The user management pages at /dashboard/user/ handle basic CRUD but lack these admin-specific operations. + +**Doc Claim:** PATCH /api/users/admin/:userId/password, POST /api/users/admin/:userId/resend-verification, PUT /api/users/admin/update/:email, GET /api/users/admin/stats + +**Code Reality:** src/actions/user.ts has no functions for password reset, resend-verification, or update-by-email. The axios endpoints config has stats: '/users/admin/stats' defined but the missingFrontendFeatures list confirms no UI page exists for it. + +**UAT Impact:** QA must verify: admin cannot reset a user's password or resend verification through the UI. Test the raw endpoints directly: PATCH /api/users/admin/:userId/password should clear refresh tokens; POST /api/users/admin/:userId/resend-verification should queue an email. + +#### M110. Blog admin CRUD endpoints are undocumented + +**Description:** The backend exposes a complete admin blog management API: GET /api/blog/admin/posts (list), POST /api/blog/posts (create), GET /api/blog/admin/posts/:id (detail), PUT /api/blog/posts/:id (update), DELETE /api/blog/posts/:id (delete). All require admin auth. Frontend actions for getAdminBlogPosts and getBlogPostById exist (src/actions/blog.ts), and the missingFrontendFeatures note confirms no admin blog page exists. None of these endpoints appear in the API doc. + +**Doc Claim:** No blog admin endpoints in the API documentation + +**Code Reality:** Backend registers 5 blog admin endpoints with authorizeRoles(admin). Frontend has action functions that call them. No dedicated admin blog management UI page exists. + +**UAT Impact:** QA must verify: GET /api/blog/admin/posts returns all posts for admin, not just published ones. POST /api/blog/posts creates a post. Confirm the blog post editor at /dashboard/post/ is restricted to admins. + +### Domain: Trezor Safekeeping + +#### M111. GET /api/trezor/account endpoint not documented + +**Description:** The backend exposes GET /api/trezor/account (authenticated) which returns the active Trezor account summary including xpubFingerprint, registrationAddress, basePath, nextAddressIndex, and addressCount (or {registered:false}). This endpoint is not listed in the documented API endpoints at all. + +**Doc Claim:** Documented API endpoints are: GET /api/trezor/registration-message, POST /api/trezor/register, POST /api/trezor/addresses/next, POST /api/trezor/operation-message. + +**Code Reality:** Backend also has GET /api/trezor/account (auth: authenticateToken) returning the account summary or {registered:false}. + +**UAT Impact:** QA cannot verify the account status check flow. A frontend that eventually implements registration would use this endpoint to pre-check whether a user is already registered — the missing documentation means this was never wired up in the frontend either. + +#### M112. POST /api/trezor/verify-operation endpoint not documented + +**Description:** The backend exposes POST /api/trezor/verify-operation (admin-only: authenticateToken + authorizeRoles('admin')) which verifies a Trezor ECDSA signature for an operation payload against the admin's registered safekeeping address. This is a distinct step from operation-message generation and is not mentioned in the documented flow at all. + +**Doc Claim:** Step 11 describes submitting release/refund confirmation with a trezor object, and step 12 references confirmReleaseRefundInstruction verifying the Trezor signature, but no separate /api/trezor/verify-operation endpoint is documented. + +**Code Reality:** Backend has POST /api/trezor/verify-operation (admin-only) as a standalone verification endpoint separate from operation-message. The notable logic note confirms it checks the recovered signer against TrezorAccount.registrationAddress using a per-operation nonce. + +**UAT Impact:** QA should test this endpoint directly: submit a valid operation payload with a correct admin Trezor signature and confirm 200 response; submit with a wrong signer and confirm rejection. Also verify whether the release/refund confirmation endpoints call this internally or require the frontend to call it first. + +#### M113. Registration role ambiguity: doc says 'User' but operation endpoints are admin-only + +**Description:** The flow says 'User (seller/buyer connecting Trezor)' registers in steps 1-6, but POST /api/trezor/operation-message and POST /api/trezor/verify-operation are both admin-only (authorizeRoles('admin')). The doc flags in the original document acknowledge this ambiguity. Given that the Trezor safekeeping guard is about admin authorization of releases/refunds, the registrant for the safekeeping address must be an admin account, not a buyer or seller. + +**Doc Claim:** Actors list includes 'User (seller/buyer connecting Trezor)'. Steps 1-4 are attributed to the generic 'User'. + +**Code Reality:** POST /api/trezor/register uses only authenticateToken (no role restriction), so any role can register. But operation-message and verify-operation are admin-only, meaning the Trezor used to authorize releases must belong to an admin account. The registrationAddress matched during verify-operation is from the admin's TrezorAccount. + +**UAT Impact:** QA must clarify and test: (1) Can a buyer/seller register a Trezor and, if so, what does it enable? (2) Verify that an admin-registered Trezor address is what the safekeeping guard validates against — not a buyer's registered address. + +#### M114. Upsert behavior on re-registration not documented + +**Description:** The backend POST /api/trezor/register performs an upsert: if the user already has a TrezorAccount, it updates xpub, basePath, and deviceLabel but preserves nextAddressIndex and the existing addresses array via $setOnInsert. This means re-registering with a new xpub keeps old derived address records pointing to the previous xpub, which could cause address/xpub mismatches in accounting. + +**Doc Claim:** Step 6 says 'Backend stores userId, xpub fingerprint, xpub, base derivation path, registrationAddress, next address index, and issued address records.' No mention of update/upsert behavior or what happens on re-registration. + +**Code Reality:** notableLogic: 're-registering (POST /register) updates xpub/basePath/label on the existing TrezorAccount document but preserves nextAddressIndex and the addresses array via $setOnInsert.' + +**UAT Impact:** QA should test: register with xpub-A, issue a deposit address, re-register with xpub-B, then call GET /api/trezor/account and POST /api/trezor/addresses/next to verify whether old addresses remain and whether new addresses are derived from xpub-B while old records reference xpub-A. + +#### M115. Purpose field valid values not documented but are enumerated in the schema + +**Description:** The doc notes that POST /api/trezor/addresses/next accepts a 'purpose' field but only shows 'deposit' as an example and explicitly flags it as unclear. The backend status values enumerate TrezorAccount.addresses[].purpose as: deposit, release, refund, other — meaning all four are valid schema values. + +**Doc Claim:** Step 7: 'POST /api/trezor/addresses/next (purpose: deposit, paymentId)'. DocFlag: 'only deposit shown as example — unclear what other values are valid.' + +**Code Reality:** statusValues: 'TrezorAccount.addresses[].purpose: deposit, release, refund, other' — four defined enum values. + +**UAT Impact:** QA should test POST /api/trezor/addresses/next with each purpose value (deposit, release, refund, other) and confirm all are accepted. Also test an invalid purpose value to confirm rejection. + +### Domain: Delivery + +#### M116. Seller marks shipped via PUT /delivery, not PATCH /:id with {status:'delivery'} + +**Description:** The documentation step 2 states the frontend sends 'PATCH /api/marketplace/purchase-requests/:id with {status: delivery}' when the seller clicks 'Mark as shipped'. The actual frontend action is updateDelivery which calls PUT /api/marketplace/purchase-requests/:id/delivery. The controller's updateDeliveryInfo (PUT /delivery) is the shipping endpoint — it sets shippedAt, updates delivery date/time fields, and advances status to 'delivery' when the current status is in [processing, payment, delivery, confirming]. + +**Doc Claim:** Step 2: 'Frontend sends PATCH /api/marketplace/purchase-requests/:id with {status: delivery}'. + +**Code Reality:** Frontend action updateDelivery calls PUT /marketplace/purchase-requests/:id/delivery. The marketplaceController.updateDeliveryInfo method at controllerRoutes.ts line 103 handles this endpoint and conditionally sets status='delivery'. The generic PATCH /:id endpoint also exists but does not trigger delivery-code generation. + +**UAT Impact:** QA must verify: seller calling PUT /delivery with valid delivery date advances status to 'delivery' and sets shippedAt. Test that PATCH /:id with {status:'delivery'} does NOT auto-generate a delivery code or emit delivery-specific socket events. + +#### M117. confirm-delivery endpoint is buyer-only, not admin-only, and has a buyer auth check + +**Description:** The documentation is ambiguous about who can trigger confirm-delivery, stating in docFlags that it is 'unclear which authorization model (admin only vs. buyer self-service)'. The controller implementation confirms it is buyer self-service: it checks request.status === 'delivery' and does not restrict to admin. There is no admin-only guard — any authenticated user can call it as long as they know the request ID, because the controller's confirmDelivery method (line 782) does not verify that the caller is the buyer of the request. + +**Doc Claim:** API table lists 'PATCH /:id/confirm-delivery — Buyer fast-track confirm (no code)'. docFlags say authorization model is unclear. + +**Code Reality:** marketplaceController.confirmDelivery (line 782) checks: (1) dispute gate via isReleaseBlockedById, (2) status must be 'delivery'. It does NOT verify the caller is the buyer — any authenticated user can confirm delivery on any request in 'delivery' status. Sets deliveryConfirmed=true and deliveryConfirmedAt, transitions to 'delivered'. + +**UAT Impact:** QA must test: (1) seller can incorrectly call PATCH /confirm-delivery and it succeeds — this is a security gap; (2) buyer can call it and confirm delivery without a code; (3) confirm that PurchaseRequestService.ts:631-641 notifyDeliveryConfirmed is NOT called from this endpoint (the doc says it should be). + +#### M118. notifyDeliveryConfirmed called via socket events in DeliveryService, not PurchaseRequestService:631-641 + +**Description:** The documentation states 'Backend calls notifyDeliveryConfirmed for both buyer and seller (PurchaseRequestService.ts:631-641)' after successful code verification. In reality, DeliveryService.verifyDeliveryCode handles notifications directly by creating NotificationService records for both buyer and seller and emitting 'delivery-confirmed' + 'buyer-confirmed-delivery' socket events. There is no call to PurchaseRequestService at line 631-641 from the verification path. + +**Doc Claim:** Step 13: 'Backend calls notifyDeliveryConfirmed for both buyer and seller (PurchaseRequestService.ts:631-641)'. + +**Code Reality:** DeliveryService.verifyDeliveryCode (lines 180-212) sends in-app notifications to buyer and seller via NotificationService.createNotification. It emits 'delivery-confirmed' to request-{id} room and 'buyer-confirmed-delivery' to user-{sellerId} room. No call to PurchaseRequestService. + +**UAT Impact:** QA must verify: after successful code verification, in-app notification appears for buyer AND seller. The 'delivery-confirmed' socket event fires on the request room. Confirm buyer-confirmed-delivery fires on the seller's user room. + +#### M119. delivery-code-generated socket event broadcasts raw code to entire request room including seller + +**Description:** DeliveryService.generateDeliveryCode emits 'delivery-code-generated' with the raw 6-digit code in the payload to the room request-{id}. Both buyer and seller are in this room. The documentation flags the security concern on the buyer notification side but the socket broadcast exposes the code to everyone in the room — including the seller — before physical handoff, defeating the purpose of the code. + +**Doc Claim:** Socket events section: 'delivery-code-generated → room request-{id} (payload: code, expiresAt)'. Security note only mentions buyer notification side. + +**Code Reality:** DeliveryService.ts line 55: global.io.to('request-{requestId}').emit('delivery-code-generated', { requestId, code, expiresAt, timestamp }). The full 6-digit code is in the payload to all room subscribers (buyer + seller + any admin listeners). + +**UAT Impact:** QA/Security: verify that seller-side socket listener receives the delivery code via the 'delivery-code-generated' event payload. This means a malicious seller with socket access could intercept the code. Test whether a rate-limit or brute-force lockout exists on the verify endpoint (it does not — no attempt limit in code). + +#### M120. No regenerate delivery code endpoint exists in the backend + +**Description:** The frontend action regenerateDeliveryCode calls POST /marketplace/purchase-requests/:id/delivery-code/regenerate. This endpoint does not exist in either routes.ts or controllerRoutes.ts. The frontend has a catch fallback that calls generateDeliveryCode instead (POST /generate). DeliveryService.regenerateDeliveryCode() exists as a method but is not exposed via any route. + +**Doc Claim:** Edge cases section: 'POST /:id/delivery-code regenerates a new 6-digit value, invalidates the old one, and re-notifies; access should be restricted to admin/seller to avoid abuse'. + +**Code Reality:** No backend route for /delivery-code/regenerate exists. The frontend falls back silently to /delivery-code/generate on 404. The DeliveryService.regenerateDeliveryCode method exists (line 342) but is unreachable via HTTP. + +**UAT Impact:** QA must confirm: POST /delivery-code/regenerate returns 404. The frontend silently falls back to generate — verify this actually creates a new code and whether the old code is properly invalidated (the fallback skips the regenerateDeliveryCode method's invalidation step). + +#### M121. No backend endpoints for /delivery-code/attempts and /delivery/stats + +**Description:** The frontend actions getDeliveryAttempts and getDeliveryStats call /marketplace/purchase-requests/:id/delivery-code/attempts and /delivery/stats respectively. Neither endpoint is registered anywhere in the backend. Delivery attempt data is stored in deliveryInfo.deliveryAttempts[] in MongoDB but there is no HTTP route to read it. Similarly, /delivery/stats has no backend handler. + +**Doc Claim:** Not documented in the flow doc (these are from the frontend actions inventory). + +**Code Reality:** No route for /delivery-code/attempts or /delivery/stats exists in routes.ts, controllerRoutes.ts, or any other backend file. DeliveryService has no method to aggregate stats. Failed attempt codes are stored to deliveryInfo.deliveryAttempts[] by logDeliveryAttempt() but no route exposes them. + +**UAT Impact:** QA must confirm both endpoints return 404. Any UI that calls these will display no data or an error silently. + +#### M122. All six delivery-code frontend actions have no UI surface in any dashboard page + +**Description:** The frontend defines generateDeliveryCode, verifyDeliveryCode, getDeliveryCode, checkDeliveryCodeStatus, regenerateDeliveryCode, and getDeliveryAttempts in src/actions/delivery.ts, but no dashboard page under /dashboard/* imports or invokes them. The delivery-code-verification.tsx and step-4-waiting-for-confirmation.tsx components reference the axios endpoints directly, while the step-5-receive-goods.tsx component uses them, but there is no dedicated delivery management page and no code-generation button wired to generateDeliveryCode from the actions file. + +**Doc Claim:** The flow doc references 'frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx or buyer-steps/step-5-receive-goods.tsx' as the UI for code entry. + +**Code Reality:** src/actions/delivery.ts is imported only by the types file. The step components use axiosInstance directly rather than the actions layer. No dashboard page provides a code generation button, delivery attempt history, or delivery statistics view. + +**UAT Impact:** QA must manually navigate to the step-5-receive-goods buyer step component and verify that: code entry UI exists, code submission reaches the correct /delivery-code/verify endpoint, and the POST is made by the seller (logged-in as seller) not the buyer. + +#### M123. confirm-delivery does not call notifyDeliveryConfirmed and emits different socket event than documented + +**Description:** The controller's confirmDelivery endpoint (PATCH /confirm-delivery) sets deliveryConfirmed=true, transitions status to 'delivered', and emits 'purchase-request-update' with eventType='status-changed'. It does NOT emit the 'delivery-confirmed' or 'buyer-confirmed-delivery' socket events that the document lists, nor does it call any notification service. The documented socket event 'purchase-request-update (status-changed)' is correct, but the absence of delivery-specific notifications means the buyer fast-track path is silent to the seller. + +**Doc Claim:** Socket events section: 'purchase-request-update (status-changed) → room request-{id} on delivery→delivered transition'. Implied that notifyDeliveryConfirmed runs for both paths. + +**Code Reality:** marketplaceController.confirmDelivery emits only 'purchase-request-update' with status-changed payload. No notification is sent to seller via NotificationService. The 'delivery-confirmed' and 'buyer-confirmed-delivery' events are only emitted by DeliveryService.verifyDeliveryCode (the code path), not the fast-track confirm-delivery path. + +**UAT Impact:** QA must test the fast-track confirm-delivery path: confirm seller receives NO 'buyer-confirmed-delivery' socket event and NO in-app notification. The seller will not be proactively informed when buyer uses the no-code path. + +--- + +## Minor / Info Findings + +### Domain: Authentication + +- **Sign-up view hardcodes password: '' when calling signUp() — password field missing from form** — The jwt-sign-up-view.tsx onSubmit handler calls signUp({ ..., password: '', ... }) with an empty string (line 191, with comment 'You might need to add password field to form'). The signUp action sends this empty password to POST /api/auth/register. The backend ignores it (password is set at verif... +- **Google sign-in also filters by status:active — soft-deleted users get 404, not a distinct error** — The Google OAuth Flow doc describes the sign-in 404 case as 'User missing during sign-in → 404' and the 409 case as 'User already exists during sign-up'. It does not mention that googleSignIn also queries with status:'active'. A user who registered via Google and was soft-deleted will receive 404... +- **Passkey challenge TTL is 5 minutes in code, but doc cites it as part of a 60-second timeout** — The doc mentions the passkey registration challenge has a 'timeout: 60000' in the returned object (which is the WebAuthn browser timeout). The actual in-memory challenge expiry used for server-side validation is 300,000ms (5 minutes), set in passkeyService.ts:68 and 81. These serve different purp... +- **No UI path to verify POST /api/auth/reset-password (legacy token-based variant)** — The Password Reset Flow doc lists POST /api/auth/reset-password as a legacy token-based variant and the frontend actions table includes a resetPassword action that calls this endpoint. However no view component wires to it — the reset-password and update-password views only use the code-based flo... +- **Login increments rate-limit counter before rate-limit reset on success — counter is transient during valid login** — On a successful login, the counter increments to 1 (via checkLoginAttempts) and is then deleted (via resetLoginAttempts). A user who has 4 prior failures can still log in on the 5th attempt because checkLoginAttempts returns allowed:true when count < 5 and then increments to 5. On success the cou... +- **signUp action sends password to /auth/register but view always passes empty string** — The signUp action signature accepts a password parameter and forwards it to the register endpoint. The sign-up view always passes password:'' (jwt-sign-up-view.tsx:191). The backend stores this in TempVerification.password which is unused (the real password comes from verify-email-code). There is... +### Domain: Purchase Request + +- **Delivery Confirmation doc lists two separate socket emits ('delivery-code-generated' and 'delivery-update'); backend only emits one event name** — The Delivery Confirmation socket events section lists both 'delivery-code-generated' and 'delivery-update' as distinct events emitted to room request-{id}. The backend routes.ts delivery code generation handler (lines 2770-2777) does not emit any socket event directly; it returns the code in the ... +- **Delivery Confirmation doc: PATCH /confirm-delivery listed as buyer fast-track but backend auth does not restrict to buyer only** — The Delivery Confirmation Flow lists PATCH /:id/confirm-delivery as a buyer fast-track. The backend controllerRoutes.ts line 114 registers it with only authenticateToken (no role or ownership check visible in the route definition). The doc is ambiguous about authorization model. A separate edge c... +- **Delivery code management has no dashboard page despite all six actions being defined** — Six delivery-related frontend actions are defined in src/actions/delivery.ts (generateDeliveryCode, verifyDeliveryCode, getDeliveryCode, checkDeliveryCodeStatus, regenerateDeliveryCode, getDeliveryAttempts). The generate and verify functions are used inline within step components (step-5-receive-... +- **Workflow steps endpoint defined and backed but never called from the request detail page** — GET /marketplace/purchase-requests/:id/workflow-steps is registered on the backend (both routers) and the frontend defines getWorkflowSteps in marketplace.ts. However, the frontend request detail views (buyer-request-details-view.tsx, seller-request-details-view.tsx, admin-request-details-view.ts... +- **Backend 'transaction-completed' socket event to buyer and seller rooms is undocumented** — The backend socket events list notes 'Emit → room user-{buyerId}: transaction-completed' and 'Emit → room user-{sellerId}: transaction-completed' when request reaches 'completed'. These events are not mentioned in any of the four documented flows. +- **No brute-force protection for 6-digit delivery code verification is documented or visible in backend code** — The delivery code is 6 digits (1,000,000 possible values). The backend verify endpoint (routes.ts lines 2790-2847) has no visible rate limiting, lockout counter, or attempt tracking. The doc flags this as a security gap but marks it as unresolved. +- **Backend registers PATCH /purchase-requests/:id in routes.ts (legacy) in addition to the controller PATCH /status** — The legacy routes.ts registers a general PATCH /purchase-requests/:id that updates any field (line 1007). The controllerRoutes.ts registers a dedicated PATCH /purchase-requests/:id/status for status changes only. Both are active. The documented flows only describe the /status endpoint. +- **POST /api/marketplace/purchase-requests/:id/final-approval creates a dummy payment for testing if no payment exists** — The final-approval endpoint in routes.ts (lines 1561-1592) contains logic that creates a dummy Payment document when no real payment is found and the request is in 'delivered' or 'delivery' status. This testing backdoor is not documented anywhere and bypasses the payment integrity check in produc... +### Domain: Seller Offer + +- **Doc step 6 says backend responds '200 { offer }' but createOffer throws duplicate error as generic 400, not 409** — The SellerOfferService.createOffer() catches the duplicate-offer case and throws a Persian error. The route handler in routes.ts (lines 1185-1195) catches errors and returns 409 only when error.message includes 'already has an offer' (the exact string from line 74 of SellerOfferService). However ... +- **Doc says validUntil=0 is rejected by schema validator at creation time; schema has no such validator** — The doc edge-cases section states 'validUntil in the past at creation → schema-level validator should reject'. The SellerOffer Mongoose schema (SellerOffer.ts lines 90-92) defines validUntil as a plain Date with no custom validator checking whether it is in the future. A past date will be accepte... +- **HTTP status code for withdrawing a non-pending offer is undocumented** — The doc flags section acknowledges 'The doc does not specify what HTTP status code is returned when a seller tries to withdraw a non-pending offer'. The withdrawOffer() service method returns null when the offer is not in pending status (findOneAndUpdate returns null). The only route that can tri... +- **Doc references seller marketplace dashboard at /dashboard/seller/marketplace with offer cards, but no dedicated seller dashboard page is confirmed in frontend** — The flow step 1 says 'Seller opens /dashboard/seller/marketplace; page hits GET /api/marketplace/purchase-requests?sellerId={me}'. The missingFrontendFeatures list confirms 'No dedicated dashboard page for a seller to view and manage all their submitted offers — offer interactions are only embedd... +### Domain: Payment + +- **DePay flow references non-existent 'sellerOfferId' field in decentralized/save body** — The DePay flow step 4 mentions sending 'sellerOfferId' to the save endpoint. The API docs list the body as: purchaseRequestId, buyerId, sellerId?, amount, currency?, transactionHash?, network?, token?, walletAddress?, metadata?. There is no sellerOfferId in the documented or implemented body sche... +- **SHKeeper flow references POST /api/payment/shkeeper/create — actual implemented intent path is /shkeeper/intents** — The SHKeeper flow step 2 documents 'Frontend POSTs POST /api/payment/shkeeper/create'. The backend API docs and backend code list POST /api/payment/shkeeper/intents as the current intent creation endpoint. The /create path is also defined in the frontend axios config (endpoints.payments.shkeeper.... +- **payout-completed socket event is emitted by backend but no frontend handler exists** — The backend socket events list 'payout-completed (server→client, user-{sellerId} room): emitted after admin wallet payout to seller'. No frontend code was found listening for the payout-completed event. The seller dashboard will not receive a real-time notification when the admin pays out via the... +- **payment-received socket event emitted by Web3 verify — no frontend listener found** — The backend socket events list 'payment-received (server→client, user-{sellerId} room): emitted when buyer completes Web3 payment verify'. No frontend component was found with socket.on('payment-received'). The seller will not receive real-time notification of a completed DePay/Web3 payment. +- **SHKeeper flow step 32: checkout page polls GET /api/payment/shkeeper/status/:paymentId — this endpoint is absent from the entire codebase** — The SHKeeper flow explicitly states the frontend checkout page polls this endpoint alongside socket subscription. The endpoint is absent from both the backend code and the API docs. The frontend has no axios endpoint definition for it and no component code calling it. The actual mechanism is sock... +- **Backend escrowState values 'releasable' and 'releasing' not documented in either flow** — The backend status values list includes escrowState:releasable and escrowState:releasing. Neither the SHKeeper flow nor the DePay flow documentation mentions these intermediate escrow states. UI components that render escrow state labels need to handle these values or they will display as unknown... +- **PurchaseRequest status 'pending_payment' not documented in either payment flow** — The backend status values include PurchaseRequest:pending_payment as a distinct status. Neither the SHKeeper nor DePay flow documents this status or the transition into it. It is unclear when a request enters pending_payment vs the documented 'payment' status. +- **SHKeeper flow webhook response documented as 'success: true' — backend actually returns 202 Accepted** — The SHKeeper flow step 31 and the API docs entry for POST /api/payment/shkeeper/webhook both say the response body is 'success: true'. The SHKeeper flow also explicitly states 'Respond 202 Accepted (SHKeeper retries on non-2xx)'. The actual status code is 202, not 200. The response body shape may... +- **Sweep cron auto-start behaviour and derived-destination sweep endpoints not covered by any flow document** — The backend notable logic documents a sweep cron (DERIVED_DESTINATION_SWEEP_AUTOSTART=true) and the API docs list multiple /api/payment/derived-destinations endpoints for managing HD-wallet derived destination addresses. Neither the DePay flow nor the SHKeeper flow references this infrastructure.... +### Domain: Dispute + +- **Flow doc says dispute chat opening message uses sellerId from 'explicit data.sellerId → selectedOffer.sellerId → first preferredSellerIds' but chat system message is sent with buyerId as senderId** — Flow step 5 describes a system message 'اختلاف جدید ایجاد شد: {reason}' with type system. The DisputeService creates this message with senderId set to buyerId, not a system actor ID. This means the chat thread's first message is attributed to the buyer, not a neutral system account, which could c... +- **Dispute statistics page does not exist as a standalone route** — The getDisputeStatistics action and the backend endpoint both exist. The list view (dispute-list-view.tsx) calls getDisputeStatistics and uses the counts inline to populate tab badges, but there is no dedicated /dashboard/disputes/statistics page or admin analytics view that surfaces the full sta... +- **Flow hardcoded resolve action in detail view — admin has no choice, always sends action=refund** — The DisputeDetailsView.handleResolve in /frontend/src/sections/dispute/view/dispute-details-view.tsx hardcodes action: 'refund' when the 'حل اختلاف' button is clicked from the top-level actions area. The AdminActionsPanel component correctly offers a dropdown. But the detail view has a second res... +- **No endpoint or flow step documented for dispute reassignment (admin handover)** — If an assigned admin becomes unavailable, there is no endpoint to change dispute.adminId to a new admin. POST /api/disputes/:id/assign checks if adminId is already set but does not block re-assignment — it will overwrite. This is not documented anywhere and the doc docFlags section calls it out a... +- **Frontend has no action for POST /api/disputes/:purchaseRequestId/raise or GET /api/disputes/:purchaseRequestId/status** — The releaseHold dispute routes (raise, status) are implemented in the backend but there are no corresponding API calls in /frontend/src/actions/dispute.ts. The frontend cannot raise a hold-dispute or query release-block status from the escrow layer. +- **Dispute model has a messages sub-array that is never used** — IDispute interface and the Mongoose schema include a messages array (senderId, content, timestamp, isRead, attachments). All chat communication goes through the Chat model referenced via chatId. The messages field on the Dispute document is declared but never written to or read in any service or ... +- **Dispute model has no uniqueness constraint on (purchaseRequestId, status) — duplicate disputes are possible** — The doc's edgeCases section calls out that there is no uniqueness constraint preventing multiple open disputes for the same purchase request. Confirmed in the Dispute model schema: only single-field and compound indexes on status+priority, adminId+status. No unique index on purchaseRequestId with... +### Domain: Chat + +- **Flow doc CREATE CHAT body includes relatedTo field; backend API does not accept it at POST /api/chat** — Flow doc step 1 shows the frontend posting { type: 'direct', participantIds: [...], relatedTo: { type: 'PurchaseRequest', id } }. The API docs for POST /api/chat do not list relatedTo as an accepted body field. The purchase-request-linked chat is handled by the dedicated POST /api/chat/purchase-r... +- **PATCH /api/chat/:id/archive toggles archived state — unarchive path is undocumented** — The flow doc docFlags note that the unarchive path (Archived → Active) is mentioned in the state diagram but has no API endpoint or narrative step. The backend PATCH /api/chat/:id/archive toggles the isArchived flag (archive and unarchive via the same endpoint). This is not documented in the flow... +- **addParticipants frontend sends { participants } but backend expects { userId } (single user)** — The frontend addParticipants action (chat.ts line 425) sends { participants: string[] } (an array) as the body. The API docs document POST /api/chat/:id/participants with body { userId: string } — a single user. The backend note also describes it as adding a participant (singular). There is a mis... +- **GET /api/chat/stats endpoint exists but has no dedicated dashboard UI** — The backend exposes GET /api/chat/stats returning totalChats, unreadChats, and totalUnreadMessages. The frontend getChatStats action exists (chat.ts line 534) and the axios endpoint is configured (endpoints.chat.stats). However, the frontend actions audit notes there is no chat statistics dashboa... +- **getChatInfo returns only first 50 messages — not all messages — undocumented truncation** — The backend GET /api/chat/:id/info reuses getChatMessages with page=1, limit=50. The flow doc does not mention this truncation. Consumers relying on getChatInfo to load full conversation history will silently receive only the first 50 messages, with no pagination metadata indicating more messages... +- **Backend enforces 5000-character message content limit — not documented in flow doc** — The backend enforces a 5000-character maximum on message content at both the Mongoose schema and controller validation levels. The flow doc does not mention this constraint. The frontend has no visible character counter or validation, meaning users can type long messages and only discover the lim... +- **File uploads stored under uploads/chat/ with anonymous access — security concern not surfaced in flow doc** — The flow doc edge cases note mentions 'sensitive attachments are unprotected — any user with the URL can fetch them'. The backend stores files under uploads/chat/ on disk. This is a known security gap. The flow doc flags it only as a recommendation callout, not as current behavior explicitly stat... +- **Direct chat participant count validation: exactly 1 external participantId required — not documented** — The backend createChat controller validates that for direct chats, exactly 1 external participantId must be supplied (caller is auto-appended to make 2 total). If more or fewer are given, validation fails. The flow doc does not document this constraint, which could cause unexpected 400 errors for... +### Domain: Notification + +- **Doc lists referral-signup socket event; backend also emits referral-reward which is undocumented** — The flow doc lists 'referral-signup → user-{referrerId}' as a socket event. The actual backend socket events also include 'referral-reward → user-{referrerId}' emitted when a referral reward is credited. This event is not in the doc's socket events table. The socket context does not expose a list... +- **90-day TTL auto-deletion of notifications is not documented** — The backend applies a MongoDB TTL index on Notification.createdAt with expireAfterSeconds = 7,776,000 (90 days), which hard-deletes old notifications automatically. This is not mentioned anywhere in the flow doc, edge cases, or status values. Users and QA may be surprised when old notifications d... +- **pending_payment and seller_paid statuses have no notification templates** — The PurchaseRequest model includes status values 'pending_payment' and 'seller_paid'. The backend's notifyRequestStatusChanged function handles states: pending, active, received_offers, in_negotiation, payment, processing, delivery, delivered, confirming, completed, cancelled — but not 'pending_p... +- **level-up and referral-signup socket events have no persistence path documented** — The socket events table lists level-up and referral-signup as emitted events, but the flow narrative never explains whether these create entries in the Notification collection. The notable logic confirms level-up comes from PointsService.addPoints and referral-signup from authController, but it i... +- **Doc says frontend uses React Query cache for notifications; actual implementation uses useState** — The flow doc step 6 says 'prepends the entry into the React Query notifications cache'. The actual frontend implementation (notification-context.tsx and use-notifications.ts) uses plain React useState for the notifications array and unreadCount. There is no React Query (TanStack Query) usage for ... +- **getNotifications action passes userId as a query param; backend authenticates by token** — The frontend actions/notification.ts passes userId as a query param to GET /notifications and GET /notifications/unread-count. The backend derives the authenticated user from the JWT token, not from a query param. Sending a userId query param that differs from the token user may be silently ignor... +### Domain: Points/Referral + +- **activeReferrals meaning changed: doc says 'never incremented', code now counts all referred users (not active buyers)** — The Referral Flow doc flags activeReferrals as 'defined in schema but no code path currently increments it'. In reality, PointsService.processReferralReward (line 409) does set activeReferrals — but it counts ALL users with referredBy = referrer._id, not only those who have made a purchase. This ... +- **Self-referral prevention is absent from both the code and the doc's recommended fix** — The doc flags self-referral as missing and recommends adding a guard in verifyEmailWithCode and googleSignUp. Inspection of authController.ts referral attribution logic (the two referral-signup emit sites at lines 704 and 1132) shows no self-referral check. Any user who obtains their own code and... +- **Point expiry: expiresAt field exists in model but no expiry enforcement exists in PointsService** — PointTransaction has a sparse-indexed expiresAt field suggesting an expiry system was planned. PointsService has no cron job, TTL index, or expiry enforcement code. The 'expire' type in the transaction enum exists but is never created by any service method. This gap is noted in the backend code a... +- **Referral link uses NEXT_PUBLIC_API_URL (backend URL) as base, not a frontend marketing URL** — The Referral Flow doc states the share URL is https://amn.gg/r/{code} and that the backend GET /r/:code redirects to ${FRONTEND_URL}/auth/jwt/sign-up?ref={code}. The actual frontend code in points-invite-friends.tsx builds the share link as `${NEXT_PUBLIC_API_URL}/r/${referralCode}` — pointing to... +### Domain: User Management + +- **API doc says GET /api/user/wallet-address returns only walletAddress; backend returns type and provider too** — The API doc for GET /api/user/wallet-address states the response is '{ success, data: { walletAddress: 0x... | null } }'. The actual backend also returns the wallet type (evm/ton) and provider fields. +- **API doc says PATCH /api/user/wallet-address only supports EVM; backend supports both EVM and TON** — The API doc for PATCH /api/user/wallet-address describes only EVM semantics (0x-prefixed 40-hex address, EIP-191 signature). The actual backend also handles TON wallet addresses (regex validated) with optional TonProof verification, setting walletProofVerified and walletProofTimestamp when proof ... +- **No dedicated frontend page for user dependencies view** — The getUserDependencies action exists and the backend endpoint returns dependency counts (templates, requests as buyer/seller, payments, chats). However, there is no dedicated frontend page at /dashboard/user/[id]/dependencies to surface this data to admins before deletion. +- **Soft-delete vs hard-delete behavior not verified by frontend flow** — The frontend deleteUser function calls the legacy /users/admin/:id DELETE (hard delete via findByIdAndDelete), but the comment in user.ts says 'soft delete'. The new controller at /api/user/admin/:userId does a soft delete (status='deleted'). If the intent is soft-delete, the frontend is calling ... +- **Legacy /users/admin/* routes coexist with new /user/admin/* routes without deprecation notice** — Both sets of admin routes are mounted and reachable simultaneously. The documentation does not indicate which is canonical or provide a deprecation timeline for the legacy routes. This creates ambiguity for frontend developers and QA about which routes to target. +- **PUT /api/users/profile (legacy update) not surfaced in frontend actions** — The backend provides PUT /api/users/profile as a legacy alias for updating the current user's profile. The frontend does not have a dedicated action for this endpoint. Profile updates go through the auth flow (endpoints.auth.updateProfile) or the new /api/user/profile route, not this legacy path. +### Domain: Admin Operations + +- **Dispute assign and resolve doc claims admin middleware; backend enforces in controller** — POST /api/disputes/:id/assign is documented as requiring role=admin. The backend registers it with authenticateToken only; the admin check is inside the controller. POST /api/disputes/:id/resolve (DisputeController) is similarly controller-enforced. This is the same pattern as cleanup-pending and... +- **No admin UI for dispute statistics despite frontend action existing** — A getDisputeStatistics action is defined in src/actions/dispute.ts calling GET /api/disputes/statistics. The missingFrontendFeatures list explicitly notes 'No admin page for dispute statistics'. No page exists under /dashboard/admin/ or /dashboard/disputes/ that displays these KPIs. +- **No admin UI for user statistics endpoint** — GET /api/users/admin/stats is defined in the API doc and in the axios endpoints config (endpoints.users.admin.stats). The missingFrontendFeatures list confirms no UI page exists for it. No frontend action calls this endpoint. +- **AML runtime configuration is not persisted — server restart silently reverts admin changes** — PATCH /api/admin/settings/aml updates process.env.TRANSACTION_SAFETY_AML_PROVIDER and process.env.AML_CHECK_COST_USD at runtime only. This behavior is documented in backend notableLogic but is not mentioned in the API doc, and there is no frontend warning. An admin who sets the AML provider via t... +### Domain: Trezor Safekeeping + +- **No socket events emitted for Trezor registration or address issuance** — The documented flow has no socket events section (listed as empty array). The backend socket EMITTED events list does not include any trezor-specific event (no trezor-registered, no address-issued events). This confirms the doc's own flag — no real-time feedback is provided; the frontend must pol... +- **Per-operation nonce for replay prevention not documented** — The backend notable logic states that operation signature verification uses a per-operation nonce to prevent replay attacks. The documented flow describes only the operation payload fields (operation, paymentId, transactionHash, amount, currency, provider) with no mention of a nonce field or how ... +- **Canonical message construction details not documented (stable JSON key order, ethers.getAddress normalization)** — The backend applies stable JSON key ordering and ethers.getAddress (EIP-55 checksum) normalization to the operation payload before building the canonical message. This is required for deterministic signing — if a frontend constructs the payload in a different key order or uses a non-checksummed a... +### Domain: Delivery + +- **Doc lists 'delivery' status precondition as requiring PATCH /:id {status:'delivery'}, but multiple paths set this status** — The documentation implies a single entry point (PATCH /:id with body {status:'delivery'}) sets the delivery status. In reality at least four separate code paths can set status='delivery': (1) PUT /delivery via updateDeliveryInfo, (2) POST /purchase-requests/:id/update-delivery (legacy), (3) PATCH... +- **Dual-router conflict: delivery-code endpoints exist in both routers with different authorization** — The delivery code routes (generate, verify, get, status) are registered in both marketplaceRouter (routes.ts) and the controller (marketplaceController.ts), but the controller's GET /delivery-code only allows the buyer while routes.ts allows both buyer and seller (preferredSellerIds + selectedOff... +- **No rate-limiting or brute-force protection on verify-delivery code endpoint** — The 6-digit delivery code has a 1-in-900,000 guess probability per attempt. The backend records failed attempts to deliveryInfo.deliveryAttempts[] but does not enforce any rate limit, lockout threshold, or attempt count maximum. A malicious actor could attempt all 900,000 combinations. The doc fl... +- **regenerateDeliveryCode creates a second deliveryCode field entry rather than updating in place** — The documentation says regeneration 'invalidates the old one'. DeliveryService.regenerateDeliveryCode (line 342) first sets deliveryCodeUsed=true on the existing record, then calls generateDeliveryCode which overwrites the single deliveryInfo.deliveryCode field using $set. Since both the invalida... +- **Simulated payment bypass (SIM_ prefix) allows reaching 'delivery' status without real escrow** — The backend POST /payments/verify treats any paymentHash starting with 'SIM_' or any short 0x hash as automatically verified. This backdoor exists in production code and allows the delivery flow to be entered without a real on-chain transaction, meaning the escrow is never actually funded. The de... + +--- + +## Doc Update Priorities + +The following documents need updating most urgently. Extracted from the Executive Summary Section 6. + +### Immediate (Block UAT if Not Corrected) + +1. **Delivery Flow** (`Delivery Confirmation Flow` doc) — Swap all actor references (buyer↔seller) for code generation and verification; replace all documented endpoint paths with actual paths (`/delivery-code/generate`, `/delivery-code/verify`); remove the non-existent `/verify-delivery` and bare `/delivery-code` POST entries. +2. **Passkey Flow** (`Authentication Flow` doc) — Remove all stub/simulated-public-key language; replace with accurate description of `@simplewebauthn/server` integration; remove the false refresh-token gap edge case. +3. **Dispute Resolve Schema** (`Dispute Flow` doc) — Replace `decision: buyer|seller|split` + `refundAmount` with `action: refund|replacement|compensation|warning_seller|ban_seller|no_action` + `amount` + `notes`; correct dispute categories; replace `under_review` with `in_progress`. +4. **Seller Offer Endpoints** (`Seller Offer Flow` doc) — Replace all three wrong GET paths; replace `POST /api/marketplace/offers` with `POST /purchase-requests/:id/offers`; remove the non-existent withdraw route. +5. **Payment DePay Flow** (`DePay/Web3 Payment Flow` doc) — Replace `/decentralized/create` with `/decentralized/save` everywhere; correct verify path to include `:paymentId` param; remove `/shkeeper/status/:paymentId` polling step. +6. **Notification Endpoints** (`Notification Flow` doc) — Replace `POST /api/notifications/read-all` with `PATCH /notifications/mark-all-read`; replace `POST /api/notifications/mark-read` with `PATCH /notifications/:id/read`; add `unread-count-update` to socket events; remove fictional `notification-read` event. +7. **Admin Auth Gaps** — Add explicit warning that `fetch-tx`, `auto-fetch-missing`, and `debug` payment endpoints currently have no authentication and are exploitable without credentials. + +### High Priority (Correct Before Handing to Integration Teams) + +8. **Purchase Request Status Enum** — Add `pending_payment` and `active` to all status lists; remove `finalized` and `archived` if not present in frontend types. +9. **Password Reset Code Length** — Correct all `8-digit` references to `6-digit` in backend API notable logic and `authController.ts` comment. +10. **Points Redeem Body Schema** — Replace `amount`/`purpose` with `pointsToUse`/`purchaseRequestId`; correct response shape to `{ transaction, discount, remainingPoints }`. +11. **Delivery Role Clarification** — Confirm `confirm-delivery` authorization model; add note that any authenticated user can currently call it (authorization gap pending fix). +12. **PointTransaction Type Enum** — Remove `refund` from status values list; valid types are `earn | spend | expire` only. + +### Standard Priority (Before Final Doc Release) + +13. Add `pending_payment` and `seller_paid` to notification templates gap documentation. +14. Document 90-day TTL auto-deletion of notifications. +15. Document chat rate limits (20 msgs/min, 15-minute edit window, 5000-char limit). +16. Document `escrowState: releasable` and `escrowState: releasing` values. +17. Document AML settings runtime-only persistence (changes lost on restart). +18. Add `unarchive` behavior to chat archive endpoint documentation (toggle semantics). +19. Document `markAsRead` with empty `messageIds` marks all messages as read. +20. Add `GET /api/trezor/account` and `POST /api/trezor/verify-operation` to Trezor API table. diff --git a/09 - Audits/UAT Comprehensive Test Plan - 2026-05-29.md b/09 - Audits/UAT Comprehensive Test Plan - 2026-05-29.md new file mode 100644 index 0000000..0e84137 --- /dev/null +++ b/09 - Audits/UAT Comprehensive Test Plan - 2026-05-29.md @@ -0,0 +1,8133 @@ +# Comprehensive UAT Test Plan — 2026-05-29 + +> **Generated from:** Doc vs Code Audit — 2026-05-29 +> **Total Test Cases:** 513 +> **Scope:** 9 test domains covering all platform flows + +## How to Use This Document + +**Priority levels:** +- **P0 — Launch Blocker:** Must pass before any production deploy. These test cases cover critical paths, security gates, and data integrity. A single P0 failure blocks release. +- **P1 — Important:** Core features that directly affect user experience and transaction correctness. Should pass before go-live; each failure needs a clear mitigation plan. +- **P2 — Should Test:** Secondary features and edge cases. Failures are documented but do not block launch if a workaround exists. +- **P3 — Nice to Have:** Low-risk edge cases, admin utilities, and enhancement coverage. Test if time allows. + +**Using test cases:** +1. Read the Preconditions section before starting each test. +2. Execute steps in order; record the actual result. +3. If a step produces an unexpected result, log the finding with: domain, test ID, step number, expected vs actual, and any API response bodies. +4. For tests marked with a `relatedFindings` note, cross-reference the Doc vs Code Audit Report for the root cause before filing a bug. + +## Test Execution Order (by Risk) + +Execute domains in this order to unblock dependent flows and surface blockers earliest: + +| Phase | Domain | Rationale | +|-------|--------|-----------| +| **Phase 1** | Authentication & Registration | Prerequisite for all other flows. Must be stable before testing anything else. | +| **Phase 2** | Purchase Request & Escrow Lifecycle | Core escrow state machine that gates delivery, payment, and dispute flows. | +| **Phase 3** | Seller Offer & Negotiation | Feeds into purchase-request status progression. | +| **Phase 4** | Payments (DePay, SHKeeper, Request Network) | Test in staging with SIM_ bypass confirmed. Escalate unauth debug endpoints immediately. | +| **Phase 5** | Disputes | All socket events are absent — focus on CRUD and privilege escalation bugs. | +| **Phase 6** | Chat & Notification | Test file upload endpoint mismatch, archive verb, and mark-all-read path. | +| **Phase 7** | Points / Referral | Most missing UI pages — limit UAT to API-level for redemption, levels, history. | +| **Phase 8** | Trezor Safekeeping | No frontend — API-only via curl/Postman. Confirm `TREZOR_SAFEKEEPING_REQUIRED=false` in staging. | +| **Phase 9** | Admin Operations | Depends on user/status management fixes from Phase 1. | + +--- + +## Domain Test Cases + +### Authentication & Registration + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| AUTH-001 | Successful email/password login for active, verified user | P0 | — | +| AUTH-002 | Login with wrong password returns 401 | P0 | — | +| AUTH-003 | Login with non-existent email returns 401 | P1 | — | +| AUTH-004 | Login blocked for unverified email — redirect to verify page | P0 | — | +| AUTH-005 | Login blocked for soft-deleted account | P1 | — | +| AUTH-006 | Rate limiter locks account after 5 total login attempts (not just failures) | P0 | — | +| AUTH-007 | Rate limiter: 5th attempt with correct password succeeds and resets counter | P1 | — | +| AUTH-008 | Rate limit counter survives backend restart | P1 | — | +| AUTH-009 | Login refreshes lastLoginAt in MongoDB | P2 | — | +| AUTH-010 | Refresh token is appended to user.refreshTokens[] on login | P1 | — | +| AUTH-011 | Redis session creation failure does not block login | P1 | — | +| AUTH-012 | Login request times out after 60 seconds via AbortController | P2 | — | +| AUTH-013 | Login fails gracefully when browser is offline | P1 | — | +| AUTH-014 | Login fails gracefully when localStorage is unavailable | P2 | — | +| AUTH-015 | toJSON() strips sensitive fields from login response | P0 | — | +| AUTH-016 | Axios interceptor retries with refreshed token on 401 | P0 | — | +| AUTH-017 | Axios interceptor does NOT trigger token refresh on 403 (email not verified) | P0 | — | +| AUTH-018 | Refresh token not in user.refreshTokens[] is rejected | P0 | — | +| AUTH-019 | Stale refresh token is invalidated after legitimate rotation | P0 | — | +| AUTH-020 | Socket.IO joins correct room based on user role after login | P1 | — | +| AUTH-021 | Password reset request returns generic 200 for unknown email (no enumeration) | P0 | — | +| AUTH-022 | Password reset request sends 6-digit code (not 8-digit) | P0 | — | +| AUTH-023 | Password reset with valid 6-digit code succeeds | P0 | — | +| AUTH-024 | Password reset with expired code returns 400 | P1 | — | +| AUTH-025 | Password reset rejects non-6-digit code format | P1 | — | +| AUTH-026 | reset-password-with-code accepts weak passwords (no complexity validation) | P0 | — | +| AUTH-027 | Password reset invalidates all existing sessions | P0 | — | +| AUTH-028 | Multiple parallel password reset requests — only the latest code is valid | P2 | — | +| AUTH-029 | Password reset on soft-deleted account returns generic 200 (no email sent) | P1 | — | +| AUTH-030 | Legacy token-based reset endpoint (POST /api/auth/reset-password) enforces password complexity | P2 | — | +| AUTH-031 | Google sign-up creates new user with isEmailVerified=true and correct role | P0 | — | +| AUTH-032 | Google sign-up returns 409 when email already exists | P0 | — | +| AUTH-033 | Google sign-in succeeds for existing active user | P0 | — | +| AUTH-034 | Google sign-in returns 404 when user does not exist | P0 | — | +| AUTH-035 | Google sign-in returns 404 for soft-deleted account (not a distinct error) | P1 | — | +| AUTH-036 | Google sign-in with invalid or expired Google token returns 401 | P0 | — | +| AUTH-037 | Google sign-in back-fills missing avatar | P2 | — | +| AUTH-038 | Google sign-up with valid referral code triggers referral attribution | P1 | — | +| AUTH-039 | Google popup blocked by browser surfaces a user-facing error | P2 | — | +| AUTH-040 | Passkey registration challenge issued to authenticated user | P0 | — | +| AUTH-041 | Passkey registration completes and stores real COSE public key | P0 | — | +| AUTH-042 | Passkey registration rejects forged attestation | P0 | — | +| AUTH-043 | Passkey authentication succeeds and returns tokens | P0 | — | +| AUTH-044 | Passkey-issued refresh token is persisted to user.refreshTokens[] and accepted by refresh endpoint | P0 | — | +| AUTH-045 | Passkey challenge expires after 5 minutes server-side | P1 | — | +| AUTH-046 | Passkey authentication with unknown credential ID returns 404 | P1 | — | +| AUTH-047 | Passkey authentication on browser without WebAuthn support shows localized error | P2 | — | +| AUTH-048 | User cancels biometric prompt during passkey authentication | P2 | — | +| AUTH-049 | Passkey list and delete flow | P1 | — | +| AUTH-050 | Passkey counter is incremented on each successful authentication | P1 | — | +| AUTH-051 | Account deletion from UI reaches DELETE /api/auth/account (not DELETE /user/profile) | P0 | — | +| AUTH-052 | Change password endpoint is reachable via direct API call | P1 | — | +| AUTH-053 | No change password UI exists in the dashboard | P2 | — | +| AUTH-054 | Sign-up form does not display a password field | P1 | — | +| AUTH-055 | Full registration flow: register → verify email code → set password → login | P0 | — | +| AUTH-056 | Logout invalidates session and removes refresh token | P0 | — | +| AUTH-057 | GET /api/auth/profile returns current user data | P1 | — | +| AUTH-058 | Passkey authentication challenge endpoint is accessible without authentication | P1 | — | +| AUTH-059 | Passkey registration challenge requires authentication | P1 | — | +| AUTH-060 | changePassword and resetPassword wipe user.refreshTokens[] forcing re-login on all devices | P0 | — | +| AUTH-061 | Passkey registration challenge endpoint does not require challenge uniqueness across in-flight requests | P2 | — | +| AUTH-062 | Passkey challenge verified on a different backend instance fails (in-memory store limitation) | P1 | — | +| AUTH-063 | Access tokens remain valid after password reset until natural expiry | P1 | — | +| AUTH-064 | Password reset code logging does not appear in production logs | P0 | — | +| AUTH-065 | Google OAuth .backup file with hardcoded client ID is not deployed to production | P1 | — | +| AUTH-066 | Telegram auth rejects stale auth_date | P1 | — | +| AUTH-067 | Telegram auth rejects replayed initData | P1 | — | +| AUTH-068 | Telegram auth creates new user with isNewUser:true when no TelegramLink exists | P1 | — | +| AUTH-069 | Passkey authentication replay does not succeed (counter enforcement) | P0 | — | +| AUTH-070 | Rate limit window resets after 15 minutes | P1 | — | + +#### AUTH-001 — Successful email/password login for active, verified user + +**Priority:** P0 + +**Steps:** +1. Ensure a user account exists with status=active and isEmailVerified=true. +2. Navigate to /auth/jwt/sign-in. +3. Enter valid email and correct password, click Sign in. +4. Observe the response and resulting navigation. + +**Expected Result:** 200 OK is returned. Both accessToken and refreshToken are written to localStorage under keys 'accessToken' and 'refreshToken'. User is redirected to the dashboard. Subsequent API requests include Authorization: Bearer header. + +#### AUTH-002 — Login with wrong password returns 401 + +**Priority:** P0 + +**Steps:** +1. Ensure a user account exists with status=active and isEmailVerified=true. +2. POST /api/auth/login with correct email and incorrect password. + +**Expected Result:** 401 Invalid credentials is returned. No tokens are issued. Redis login-attempt counter is incremented. + +#### AUTH-003 — Login with non-existent email returns 401 + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/login with an email address that does not exist in MongoDB. + +**Expected Result:** 401 Invalid credentials is returned. Response does not reveal whether the email exists (no enumeration). + +#### AUTH-004 — Login blocked for unverified email — redirect to verify page + +**Priority:** P0 + +**Steps:** +1. Ensure a user account exists with status=active and isEmailVerified=false. +2. POST /api/auth/login with valid credentials for this user. + +**Expected Result:** 403 EMAIL_NOT_VERIFIED with needsVerification:true is returned. Frontend redirects user to /auth/jwt/verify?email=. + +**Related Findings:** +- Axios interceptor only handles 401, not 403 — AUTH-028 covers interceptor behavior for this 403 + +#### AUTH-005 — Login blocked for soft-deleted account + +**Priority:** P1 + +**Steps:** +1. Ensure a user account exists with status=deleted. +2. POST /api/auth/login with valid credentials for this account. + +**Expected Result:** 401 Invalid credentials is returned (account is excluded by findOne({ status:'active' }) query). No distinct 'account deleted' error message is surfaced. + +#### AUTH-006 — Rate limiter locks account after 5 total login attempts (not just failures) + +**Priority:** P0 + +**Steps:** +1. Reset Redis login-attempt counter for the test email. +2. POST /api/auth/login 3 times with incorrect password. +3. POST /api/auth/login once with correct password (success — counter resets to 0). +4. POST /api/auth/login 5 more times with incorrect password. + +**Expected Result:** The 5th incorrect attempt in the second sequence returns 429 TOO_MANY_ATTEMPTS. Confirm that on step 4 the counter was reset (user can log in again), then confirm the subsequent 5 failures lock the account again. Note: the counter increments on every attempt (including correct-credential attempts) before the password check; reset only occurs on successful full login. + +**Related Findings:** +- Login rate limit counts all attempts, not only failures — doc says '5 failures' + +#### AUTH-007 — Rate limiter: 5th attempt with correct password succeeds and resets counter + +**Priority:** P1 + +**Steps:** +1. Make 4 consecutive failed login attempts for the same email. +2. Immediately make a 5th attempt with the correct password. + +**Expected Result:** The 5th attempt succeeds (200 OK with tokens). Counter is reset to 0 in Redis. A 6th attempt with wrong password starts a fresh 15-minute window. + +**Related Findings:** +- Login increments rate-limit counter before rate-limit reset on success — counter is transient during valid login + +#### AUTH-008 — Rate limit counter survives backend restart + +**Priority:** P1 + +**Steps:** +1. Make 3 failed login attempts for the same email. +2. Restart the backend service. +3. Make 2 more failed attempts. + +**Expected Result:** The 5th total attempt (across the restart) returns 429, confirming the counter is stored in Redis and not in application memory. + +#### AUTH-009 — Login refreshes lastLoginAt in MongoDB + +**Priority:** P2 + +**Steps:** +1. Note the current lastLoginAt value for a test user. +2. POST /api/auth/login with valid credentials. +3. Read the user document from MongoDB. + +**Expected Result:** user.lastLoginAt is updated to approximately the current timestamp. + +#### AUTH-010 — Refresh token is appended to user.refreshTokens[] on login + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/login with valid credentials. +2. Read user.refreshTokens[] from MongoDB. + +**Expected Result:** The issued refreshToken is present in user.refreshTokens[]. Multiple logins from different sessions append multiple tokens to the array. + +#### AUTH-011 — Redis session creation failure does not block login + +**Priority:** P1 + +**Steps:** +1. Simulate Redis session store unavailability (e.g., kill sessionService connection while keeping rate-limiter Redis alive). +2. POST /api/auth/login with valid credentials. + +**Expected Result:** Login still returns 200 OK with tokens. The session creation failure is logged server-side but the user receives a successful response. + +#### AUTH-012 — Login request times out after 60 seconds via AbortController + +**Priority:** P2 + +**Steps:** +1. Configure the backend to delay its response beyond 60 seconds (e.g., via a test flag or proxy). +2. Submit the sign-in form. + +**Expected Result:** The frontend AbortController cancels the request after 60 seconds. A timeout error is surfaced to the user rather than the request hanging indefinitely. + +#### AUTH-013 — Login fails gracefully when browser is offline + +**Priority:** P1 + +**Steps:** +1. Set the browser to offline mode (DevTools > Network > Offline). +2. Attempt to submit the sign-in form. + +**Expected Result:** signInWithPassword() detects NetworkUtils.isOnline() === false and throws a typed AuthErrorHandler error before any HTTP request is made. A user-facing error message is displayed. + +#### AUTH-014 — Login fails gracefully when localStorage is unavailable + +**Priority:** P2 + +**Steps:** +1. Block localStorage access (e.g., via browser privacy mode or a custom StorageUtils mock that returns false). +2. Attempt to submit the sign-in form. + +**Expected Result:** StorageUtils.isAvailable() returns false; the request is rejected before it reaches the backend. A user-facing error is displayed. + +#### AUTH-015 — toJSON() strips sensitive fields from login response + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/login with valid credentials. +2. Inspect the response body's user object. + +**Expected Result:** The user object in the response does not contain password, refreshTokens, or any verification code fields. + +#### AUTH-016 — Axios interceptor retries with refreshed token on 401 + +**Priority:** P0 + +**Steps:** +1. Log in and obtain tokens. +2. Manually expire or invalidate the access token in localStorage. +3. Make any authenticated API request from the frontend. + +**Expected Result:** The interceptor detects the 401, calls POST /api/auth/refresh-token, obtains a new access token, and retries the original request transparently. The user is not redirected to login. + +#### AUTH-017 — Axios interceptor does NOT trigger token refresh on 403 (email not verified) + +**Priority:** P0 + +**Steps:** +1. Log in as a user whose email is not verified (or simulate a 403 response from the backend). +2. Observe the frontend behavior when a 403 is received. + +**Expected Result:** The 403 is propagated as an error to the caller — no token refresh attempt is triggered. The user sees an appropriate error message (e.g., redirect to verify email page). + +**Related Findings:** +- Axios interceptor only handles 401, not 403, for token refresh — doc says both + +#### AUTH-018 — Refresh token not in user.refreshTokens[] is rejected + +**Priority:** P0 + +**Steps:** +1. Craft or obtain a valid JWT refresh token that is not present in user.refreshTokens[]. +2. POST /api/auth/refresh-token with this token. + +**Expected Result:** 400 or 401 error is returned. No new tokens are issued. + +#### AUTH-019 — Stale refresh token is invalidated after legitimate rotation + +**Priority:** P0 + +**Steps:** +1. Log in to get an initial refresh token (RT1). +2. POST /api/auth/refresh-token with RT1 to get a new pair (AT2, RT2). +3. POST /api/auth/refresh-token again with the old RT1. + +**Expected Result:** The second use of RT1 returns an error. RT1 is no longer in user.refreshTokens[] after rotation. + +#### AUTH-020 — Socket.IO joins correct room based on user role after login + +**Priority:** P1 + +**Steps:** +1. Log in as a buyer. +2. Monitor Socket.IO events emitted from the dashboard layout. +3. Repeat for a seller account. + +**Expected Result:** Buyer login emits join-user-room and join-buyer-room. Seller login emits join-user-room and join-seller-room. No cross-role room joins occur. + +#### AUTH-021 — Password reset request returns generic 200 for unknown email (no enumeration) + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/request-password-reset with an email address that does not exist in MongoDB. + +**Expected Result:** 200 OK with message 'If an account with this email exists, a password reset code has been sent'. No email is sent. Response is identical to the known-email case. + +#### AUTH-022 — Password reset request sends 6-digit code (not 8-digit) + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/request-password-reset with a valid active user email. +2. Check the received password reset email. + +**Expected Result:** The email contains exactly a 6-digit numeric code. No 8-digit code is delivered. + +**Related Findings:** +- Password reset code is 6 digits, not 8 — backend API doc and controller comment are wrong + +#### AUTH-023 — Password reset with valid 6-digit code succeeds + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/request-password-reset to generate a reset code. +2. Retrieve the code from the email. +3. POST /api/auth/reset-password-with-code with { email, code, password: 'NewPass1' }. + +**Expected Result:** 200 OK 'Password reset successfully'. User can log in with the new password. user.passwordResetCode and user.passwordResetCodeExpires are cleared in MongoDB. user.refreshTokens[] is empty (all sessions invalidated). + +#### AUTH-024 — Password reset with expired code returns 400 + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/request-password-reset to generate a reset code. +2. Wait more than 1 hour (or manually set passwordResetCodeExpires to a past timestamp in MongoDB). +3. POST /api/auth/reset-password-with-code with the expired code. + +**Expected Result:** 400 'Invalid or expired reset code' is returned. + +#### AUTH-025 — Password reset rejects non-6-digit code format + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/reset-password-with-code with { email, code: '12345678', password: 'NewPass1' } (8 digits). +2. Repeat with code: 'abcdef' (non-numeric). + +**Expected Result:** 400 is returned for both attempts due to format validation (/^\d{6}$/) before any DB lookup. + +**Related Findings:** +- Password reset code is 6 digits, not 8 — backend API doc and controller comment are wrong + +#### AUTH-026 — reset-password-with-code accepts weak passwords (no complexity validation) + +**Priority:** P0 + +**Steps:** +1. Generate a valid reset code via POST /api/auth/request-password-reset. +2. POST /api/auth/reset-password-with-code with { email, code, password: '123456' }. +3. Repeat with password: 'aaaaaa'. + +**Expected Result:** 200 OK — both weak passwords are accepted. No complexity validation is enforced on this endpoint. Document this as a known gap versus the token-based reset endpoint which requires uppercase+lowercase+digit. + +**Related Findings:** +- reset-password-with-code has no password complexity validation middleware — reset-password (token) does + +#### AUTH-027 — Password reset invalidates all existing sessions + +**Priority:** P0 + +**Steps:** +1. Log in from two different browser sessions, saving both refresh tokens. +2. Perform a successful password reset via POST /api/auth/reset-password-with-code. +3. Attempt to use both previously stored refresh tokens with POST /api/auth/refresh-token. + +**Expected Result:** Both refresh token calls return 401/400. user.refreshTokens[] is empty in MongoDB after the reset. + +#### AUTH-028 — Multiple parallel password reset requests — only the latest code is valid + +**Priority:** P2 + +**Steps:** +1. POST /api/auth/request-password-reset twice in rapid succession for the same email. +2. Retrieve both codes from email. +3. Try to use the first (older) code with POST /api/auth/reset-password-with-code. + +**Expected Result:** The first code returns 400 'Invalid or expired reset code' because it was overwritten. Only the most recent code is accepted. + +#### AUTH-029 — Password reset on soft-deleted account returns generic 200 (no email sent) + +**Priority:** P1 + +**Steps:** +1. Ensure a user account exists with status=deleted. +2. POST /api/auth/request-password-reset with this account's email. + +**Expected Result:** 200 OK generic message is returned. No email is sent. No passwordResetCode is stored for this account. + +#### AUTH-030 — Legacy token-based reset endpoint (POST /api/auth/reset-password) enforces password complexity + +**Priority:** P2 + +**Steps:** +1. Obtain a valid reset token (if the mechanism to generate one exists). +2. POST /api/auth/reset-password with { token, password: 'weak' } (fails complexity check). +3. Repeat with a password meeting uppercase+lowercase+digit requirement. + +**Expected Result:** Weak password returns 400 validation error. Strong password returns 200 and wipes user.refreshTokens[]. + +**Related Findings:** +- No UI path to verify POST /api/auth/reset-password (legacy token-based variant) + +#### AUTH-031 — Google sign-up creates new user with isEmailVerified=true and correct role + +**Priority:** P0 + +**Steps:** +1. Navigate to /auth/jwt/sign-up. +2. Select role = buyer, click the Google sign-up button. +3. Complete Google consent flow. +4. Inspect the created user in MongoDB. + +**Expected Result:** User is created with isEmailVerified=true, status=active, role=buyer, and no password field. profile.avatar is set from the Google picture URL. Access and refresh tokens are returned and stored in localStorage. + +#### AUTH-032 — Google sign-up returns 409 when email already exists + +**Priority:** P0 + +**Steps:** +1. Ensure a user account already exists with the email address of the Google account being used. +2. Attempt Google sign-up with that Google account. + +**Expected Result:** 409 USER_EXISTS is returned. Frontend prompts the user to sign in instead rather than creating a duplicate account. + +#### AUTH-033 — Google sign-in succeeds for existing active user + +**Priority:** P0 + +**Steps:** +1. Ensure a user exists in MongoDB with status=active and an email matching the Google account. +2. Click the Google sign-in button on /auth/jwt/sign-in. +3. Complete Google consent. + +**Expected Result:** 200 OK with tokens. lastLoginAt is updated. Tokens are stored in localStorage. User is redirected to dashboard. + +#### AUTH-034 — Google sign-in returns 404 when user does not exist + +**Priority:** P0 + +**Steps:** +1. Use a Google account whose email has no matching user in MongoDB. +2. Attempt Google sign-in. + +**Expected Result:** 404 USER_NOT_FOUND is returned. Frontend prompts user to sign up first. + +#### AUTH-035 — Google sign-in returns 404 for soft-deleted account (not a distinct error) + +**Priority:** P1 + +**Steps:** +1. Ensure a user account exists with status=deleted and an email matching a Google account. +2. Attempt Google sign-in with that Google account. + +**Expected Result:** 404 USER_NOT_FOUND is returned (same as non-existent user). No distinct 'account deleted' message is shown. + +**Related Findings:** +- Google sign-in also filters by status:active — soft-deleted users get 404, not a distinct error + +#### AUTH-036 — Google sign-in with invalid or expired Google token returns 401 + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/google/signin with a tampered or expired Google ID token. + +**Expected Result:** 401 INVALID_GOOGLE_TOKEN is returned. No user lookup is performed. + +#### AUTH-037 — Google sign-in back-fills missing avatar + +**Priority:** P2 + +**Steps:** +1. Ensure a user has an empty profile.avatar in MongoDB (signed up via email). +2. That user signs in via Google where the token contains a picture URL. +3. Inspect user.profile.avatar in MongoDB after sign-in. + +**Expected Result:** profile.avatar is updated to the Google picture URL. + +#### AUTH-038 — Google sign-up with valid referral code triggers referral attribution + +**Priority:** P1 + +**Steps:** +1. Obtain a valid referral code from an existing user. +2. Complete Google sign-up with referralCode set to this code. +3. Inspect the referrer's referralStats.totalReferrals in MongoDB. +4. Monitor Socket.IO for a referral-signup event on the referrer's user-${referrerId} channel. + +**Expected Result:** referrer.referralStats.totalReferrals is incremented by 1. referral-signup event is emitted on the referrer's room. + +#### AUTH-039 — Google popup blocked by browser surfaces a user-facing error + +**Priority:** P2 + +**Steps:** +1. Enable popup blocking in the browser. +2. Click the Google sign-in or sign-up button. + +**Expected Result:** GSI throws a client-side error. Frontend catches it and displays a toast or error message indicating the popup was blocked. No unhandled exception occurs. + +#### AUTH-040 — Passkey registration challenge issued to authenticated user + +**Priority:** P0 + +**Steps:** +1. Log in and obtain a valid access token. +2. POST /api/auth/passkey/register/challenge with Bearer token. + +**Expected Result:** 200 OK with { challenge, rpId, userVerification: 'preferred', timeout: 60000 }. Challenge is stored server-side with a 5-minute TTL. + +#### AUTH-041 — Passkey registration completes and stores real COSE public key + +**Priority:** P0 + +**Steps:** +1. Obtain a registration challenge via POST /api/auth/passkey/register/challenge. +2. Complete the WebAuthn registration using a real authenticator (Touch ID, Windows Hello, or hardware key). +3. POST /api/auth/passkey/register with the credential. +4. Inspect the stored passkey in user.passkeys[] in MongoDB. + +**Expected Result:** A new passkey entry is appended to user.passkeys[]. The publicKey field contains a base64url-encoded COSE public key (not the string 'simulated-public-key'). Attestation was cryptographically verified by @simplewebauthn/server. + +**Related Findings:** +- Passkey: attestation stub claim is false — real @simplewebauthn/server is used + +#### AUTH-042 — Passkey registration rejects forged attestation + +**Priority:** P0 + +**Steps:** +1. Obtain a valid registration challenge. +2. Craft a registration response with a tampered or unsigned attestation object. +3. POST /api/auth/passkey/register with the forged credential. + +**Expected Result:** Backend calls verifyRegistrationResponse() from @simplewebauthn/server. The forged attestation fails verification and the passkey is not stored. An appropriate error is returned. + +**Related Findings:** +- Passkey: attestation stub claim is false — real @simplewebauthn/server is used + +#### AUTH-043 — Passkey authentication succeeds and returns tokens + +**Priority:** P0 + +**Steps:** +1. Register a passkey for a test user. +2. Navigate to /auth/jwt/sign-in and click 'Sign in with passkey'. +3. POST /api/auth/passkey/authenticate/challenge (public, no bearer token). +4. Complete biometric prompt in the browser. +5. POST /api/auth/passkey/authenticate with the assertion. + +**Expected Result:** 200 OK with { success: true, userId, user, tokens: { accessToken, refreshToken } }. Tokens are stored in localStorage. User is redirected to dashboard. + +#### AUTH-044 — Passkey-issued refresh token is persisted to user.refreshTokens[] and accepted by refresh endpoint + +**Priority:** P0 + +**Steps:** +1. Sign in via passkey to obtain tokens. +2. Inspect user.refreshTokens[] in MongoDB. +3. POST /api/auth/refresh-token with the passkey-issued refresh token. + +**Expected Result:** The refresh token is present in user.refreshTokens[] immediately after passkey sign-in. The refresh endpoint returns a new token pair without error. + +**Related Findings:** +- Passkey: refresh tokens ARE persisted to user.refreshTokens[] — doc claims they are not + +#### AUTH-045 — Passkey challenge expires after 5 minutes server-side + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/passkey/authenticate/challenge to get a challenge. +2. Wait more than 5 minutes without using it. +3. POST /api/auth/passkey/authenticate with a response to the expired challenge. + +**Expected Result:** 'Invalid or expired challenge' error is returned. Note: the server-side TTL is 5 minutes (300,000 ms), not 60 seconds. + +**Related Findings:** +- Passkey challenge TTL is 5 minutes in code, but doc cites it as part of a 60-second timeout + +#### AUTH-046 — Passkey authentication with unknown credential ID returns 404 + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/passkey/authenticate with an assertion whose id does not match any passkey in any user document. + +**Expected Result:** 404 'Passkey not found' is returned. No tokens are issued. + +#### AUTH-047 — Passkey authentication on browser without WebAuthn support shows localized error + +**Priority:** P2 + +**Steps:** +1. Stub or disable navigator.credentials in the browser. +2. Navigate to /auth/jwt/sign-in and click 'Sign in with passkey'. + +**Expected Result:** Frontend throws a localized error (in Farsi: 'WebAuthn در این مرورگر پشتیبانی نمی‌شود') before issuing any challenge request to the backend. + +#### AUTH-048 — User cancels biometric prompt during passkey authentication + +**Priority:** P2 + +**Steps:** +1. Initiate passkey authentication. +2. When the biometric prompt appears, cancel or dismiss it. + +**Expected Result:** Browser throws NotAllowedError. Frontend displays a 'Cancelled' toast. No error is thrown to the console. No challenge is consumed server-side. + +#### AUTH-049 — Passkey list and delete flow + +**Priority:** P1 + +**Steps:** +1. Register two passkeys for the same user. +2. GET /api/auth/passkey/list — verify both appear. +3. DELETE /api/auth/passkey/:passkeyId for one of them. +4. GET /api/auth/passkey/list again. + +**Expected Result:** After deletion, only one passkey remains in the list. The deleted passkey can no longer be used for authentication. + +#### AUTH-050 — Passkey counter is incremented on each successful authentication + +**Priority:** P1 + +**Steps:** +1. Register a passkey and note the initial counter value (0) in user.passkeys[]. +2. Authenticate with the passkey. +3. Inspect user.passkeys[].counter in MongoDB. + +**Expected Result:** counter is incremented by 1 after each successful authentication. + +#### AUTH-051 — Account deletion from UI reaches DELETE /api/auth/account (not DELETE /user/profile) + +**Priority:** P0 + +**Steps:** +1. Log in as a test user. +2. Navigate to the account deletion UI (if present) or call the deleteAccount frontend action. +3. Monitor outgoing network requests using browser DevTools. +4. Inspect MongoDB for the test user after the action. + +**Expected Result:** The HTTP request is sent to DELETE /api/auth/account with the user's password in the request body. The account's status is set to 'deleted' in MongoDB. A DELETE /user/profile request is NOT made (that path returns 404). + +**Related Findings:** +- deleteAccount frontend action calls DELETE /user/profile which has no backend route + +#### AUTH-052 — Change password endpoint is reachable via direct API call + +**Priority:** P1 + +**Steps:** +1. Log in and obtain a valid access token. +2. POST /api/auth/change-password with { currentPassword, newPassword } meeting complexity requirements. +3. Attempt to log in with the old password and then with the new password. + +**Expected Result:** 200 OK is returned. Login with the old password fails. Login with the new password succeeds. All existing sessions are invalidated (user.refreshTokens[] is cleared). + +**Related Findings:** +- changePassword action is defined but never wired to any page or UI component + +#### AUTH-053 — No change password UI exists in the dashboard + +**Priority:** P2 + +**Steps:** +1. Log in as any user. +2. Navigate through all /dashboard/* pages. +3. Look for a 'Change Password' form or link. + +**Expected Result:** No change password form is found in the UI. This is a known missing feature — document finding for product team. + +**Related Findings:** +- changePassword action is defined but never wired to any page or UI component + +#### AUTH-054 — Sign-up form does not display a password field + +**Priority:** P1 + +**Steps:** +1. Navigate to /auth/jwt/sign-up. +2. Inspect the rendered form fields. + +**Expected Result:** No password input is visible. The form collects email, name, role, and optionally referral code. Password is set at the verify-email-code step. + +**Related Findings:** +- Sign-up view hardcodes password: '' when calling signUp() — password field missing from form + +#### AUTH-055 — Full registration flow: register → verify email code → set password → login + +**Priority:** P0 + +**Steps:** +1. POST /api/auth/register with { email, firstName, lastName, role }. +2. Retrieve the 6-digit verification code from the registration email. +3. POST /api/auth/verify-email-code with { email, code, password: 'SecurePass1' }. +4. POST /api/auth/login with { email, password: 'SecurePass1' }. + +**Expected Result:** Registration returns 200. Email verification marks isEmailVerified=true and sets the hashed password. Login returns 200 with tokens. + +#### AUTH-056 — Logout invalidates session and removes refresh token + +**Priority:** P0 + +**Steps:** +1. Log in to obtain tokens. +2. POST /api/auth/logout with Bearer access token. +3. Attempt POST /api/auth/refresh-token with the previously issued refresh token. + +**Expected Result:** Logout returns 200. The subsequent refresh attempt fails because the token has been removed from user.refreshTokens[]. The Redis session is deleted. + +#### AUTH-057 — GET /api/auth/profile returns current user data + +**Priority:** P1 + +**Steps:** +1. Log in to obtain an access token. +2. GET /api/auth/profile with Authorization: Bearer . + +**Expected Result:** 200 OK with the user's profile data. Sensitive fields (password, refreshTokens) are not included in the response. + +#### AUTH-058 — Passkey authentication challenge endpoint is accessible without authentication + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/passkey/authenticate/challenge with no Authorization header. + +**Expected Result:** 200 OK with a challenge object. No authentication token is required for this public endpoint. + +#### AUTH-059 — Passkey registration challenge requires authentication + +**Priority:** P1 + +**Steps:** +1. POST /api/auth/passkey/register/challenge with no Authorization header. + +**Expected Result:** 401 Unauthorized. The registration challenge endpoint requires a valid Bearer token. + +#### AUTH-060 — changePassword and resetPassword wipe user.refreshTokens[] forcing re-login on all devices + +**Priority:** P0 + +**Steps:** +1. Log in from two browser sessions (Session A and Session B) and note both refresh tokens. +2. From Session A, POST /api/auth/change-password with valid current and new passwords. +3. From Session B, attempt POST /api/auth/refresh-token with Session B's refresh token. + +**Expected Result:** Session B's refresh token is rejected. user.refreshTokens[] is empty in MongoDB after the password change. + +#### AUTH-061 — Passkey registration challenge endpoint does not require challenge uniqueness across in-flight requests + +**Priority:** P2 + +**Steps:** +1. Send two simultaneous POST /api/auth/passkey/register/challenge requests from the same authenticated user. +2. Attempt to complete registration using the first challenge after the second has been generated. + +**Expected Result:** Both challenges are stored independently. Registration with either challenge succeeds within the 5-minute TTL. There is no race condition that invalidates the first challenge when the second is issued. + +#### AUTH-062 — Passkey challenge verified on a different backend instance fails (in-memory store limitation) + +**Priority:** P1 + +**Steps:** +1. In a multi-instance deployment, obtain a passkey challenge from instance A. +2. Submit the assertion to instance B (route to it via load balancer). + +**Expected Result:** Instance B cannot find the challenge in its in-memory storedChallenges Map and returns 'Invalid or expired challenge'. Document this as a known production readiness issue (must migrate to Redis). + +#### AUTH-063 — Access tokens remain valid after password reset until natural expiry + +**Priority:** P1 + +**Steps:** +1. Log in and obtain an access token (AT1). +2. Perform a password reset via POST /api/auth/reset-password-with-code. +3. Immediately use AT1 to call GET /api/auth/profile. + +**Expected Result:** GET /api/auth/profile succeeds with AT1 (JWT is stateless; the old access token remains valid until its TTL expires). Only the refresh token flow is blocked. Document as a known security limitation. + +#### AUTH-064 — Password reset code logging does not appear in production logs + +**Priority:** P0 + +**Steps:** +1. Trigger a password reset on a staging/production-equivalent environment. +2. Check the server-side application logs. + +**Expected Result:** The 6-digit reset code is NOT printed in plain text in logs. If it is present, file as a critical security finding. + +#### AUTH-065 — Google OAuth .backup file with hardcoded client ID is not deployed to production + +**Priority:** P1 + +**Steps:** +1. Check the deployed frontend bundle for the presence of google-oauth.ts.backup. +2. Search the bundle for any hardcoded Google client ID that matches the production client ID. + +**Expected Result:** google-oauth.ts.backup is not present in the production build. No hardcoded client IDs from backup files appear in the deployed bundle. + +**Related Findings:** +- frontend/src/auth/services/google-oauth.ts.backup is checked into the repo with a hard-coded client ID + +#### AUTH-066 — Telegram auth rejects stale auth_date + +**Priority:** P1 + +**Steps:** +1. Craft a Telegram auth payload with an auth_date older than the accepted threshold. +2. POST /api/auth/telegram with this payload. + +**Expected Result:** Request is rejected with an appropriate error. No user session is created. + +#### AUTH-067 — Telegram auth rejects replayed initData + +**Priority:** P1 + +**Steps:** +1. Capture a valid Telegram Mini App initData payload. +2. POST /api/auth/telegram with the same initData a second time after a delay. + +**Expected Result:** The second request is rejected as a replay. No duplicate session is created. + +#### AUTH-068 — Telegram auth creates new user with isNewUser:true when no TelegramLink exists + +**Priority:** P1 + +**Steps:** +1. Use a Telegram account that has no existing TelegramLink record in MongoDB. +2. POST /api/auth/telegram with a valid payload. + +**Expected Result:** A new user is created with a nullable email and a TelegramLink record. The response includes isNewUser:true. + +#### AUTH-069 — Passkey authentication replay does not succeed (counter enforcement) + +**Priority:** P0 + +**Steps:** +1. Register a passkey and authenticate once (counter becomes 1). +2. Capture the assertion from the first authentication. +3. Attempt to POST /api/auth/passkey/authenticate with the same captured assertion again. + +**Expected Result:** The replayed assertion is rejected. The authenticatorData counter has already been incremented; a replay with an old or equal counter value is blocked. + +#### AUTH-070 — Rate limit window resets after 15 minutes + +**Priority:** P1 + +**Steps:** +1. Make 5 failed login attempts to trigger 429 TOO_MANY_ATTEMPTS. +2. Wait 15 minutes for the Redis TTL to expire. +3. Attempt login with correct credentials. + +**Expected Result:** After 15 minutes, the rate limit counter has expired. Login with correct credentials succeeds. + +--- + +### Purchase Request & Escrow Lifecycle + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| PURCHASE_REQUEST-001 | Buyer completes full purchase request wizard and submits successfully | P0 | — | +| PURCHASE_REQUEST-002 | Duplicate submission within 5 minutes is rejected with Persian error message | P1 | — | +| PURCHASE_REQUEST-003 | Attachment upload uses correct scoped endpoint /purchase-requests/:id/attachments, not /files/upload | P0 | — | +| PURCHASE_REQUEST-004 | Preferred sellers typeahead fetches from /api/marketplace/sellers, not /api/users/sellers | P0 | — | +| PURCHASE_REQUEST-005 | preferredSellerIds containing invalid ObjectIds are silently dropped and request becomes public | P2 | — | +| PURCHASE_REQUEST-006 | Empty cleaned preferredSellerIds with no 'all' in original payload results in isPublic=true | P2 | — | +| PURCHASE_REQUEST-007 | Description minimum is 5 characters per frontend schema, not 20 as documented | P1 | — | +| PURCHASE_REQUEST-008 | Urgency field accepts 'urgent' as a fourth valid value | P1 | — | +| PURCHASE_REQUEST-009 | Statuses 'pending_payment' and 'active' are valid and handled by status-based UI branches | P0 | — | +| PURCHASE_REQUEST-010 | updatePurchaseRequest uses PUT but backend registers PATCH — method mismatch causes 404 | P0 | — | +| PURCHASE_REQUEST-011 | General PATCH /purchase-requests/:id cannot be used to bypass status progression whitelist | P0 | — | +| PURCHASE_REQUEST-012 | Buyer cancellation after payment (status=processing or later) is blocked | P0 | — | +| PURCHASE_REQUEST-013 | Public purchase request appears in real time on all connected seller dashboards via 'new-purchase-request' to 'sellers' room | P1 | — | +| PURCHASE_REQUEST-014 | Private purchase request is NOT broadcast to sellers outside preferredSellerIds via 'sellers' room | P1 | — | +| PURCHASE_REQUEST-015 | Cancellation emits 'purchase-request-update' with eventType='status-changed', not 'request-cancelled' | P1 | — | +| PURCHASE_REQUEST-016 | Seller receives real-time events via 'join-seller-room' in addition to 'join-request-room' | P1 | — | +| PURCHASE_REQUEST-017 | getMarketplaceStats returns 404 and no production UI depends on it | P1 | — | +| PURCHASE_REQUEST-018 | searchPurchaseRequests using /purchase-requests/search returns 404; search must use query params on list endpoint | P1 | — | +| PURCHASE_REQUEST-019 | transaction-completed socket event fires to buyer and seller when request reaches 'completed' status | P1 | — | +| PURCHASE_REQUEST-020 | Invalid status transition via PATCH returns 400 'Invalid status progression' | P1 | — | +| PURCHASE_REQUEST-021 | Invalid category ObjectId in purchase request creation returns 400 | P2 | — | +| PURCHASE_REQUEST-022 | Notification fan-out failure for an individual seller does not prevent request creation | P2 | — | +| PURCHASE_REQUEST-023 | Workflow steps endpoint returns accurate step data for buyer and seller roles | P3 | — | +| SELLER_OFFER-001 | Seller creates offer via correct scoped endpoint POST /purchase-requests/:id/offers | P0 | — | +| SELLER_OFFER-002 | Duplicate offer from same seller on same request returns correct error code | P1 | — | +| SELLER_OFFER-003 | POST /api/marketplace/offers (flat path) and GET /api/marketplace/offers/request/:requestId return 404 | P0 | — | +| SELLER_OFFER-004 | Buyer offer listing uses GET /purchase-requests/:id/offers sorted by createdAt descending | P1 | — | +| SELLER_OFFER-005 | Seller offer withdrawal via PUT /offers/:id/status — no dedicated /withdraw endpoint exists | P0 | — | +| SELLER_OFFER-006 | Accepted offer status can be overwritten to 'withdrawn' via PUT /offers/:id/status — pending-only guard is not enforced | P0 | — | +| SELLER_OFFER-007 | Setting SellerOffer status to 'active' throws Mongoose ValidationError | P1 | — | +| SELLER_OFFER-008 | Offer can be created against a PurchaseRequest in 'active' status | P1 | — | +| SELLER_OFFER-009 | select-offer cascade corrupts withdrawn/rejected offers — status filter missing | P0 | — | +| SELLER_OFFER-010 | select-offer does not send notifications to winning or losing sellers | P0 | — | +| SELLER_OFFER-011 | Buyer receives 'new-offer' socket event on buyer-{buyerId} room when seller submits proposal | P1 | — | +| SELLER_OFFER-012 | Offer update method mismatch: frontend uses PUT, backend registers PATCH | P0 | — | +| SELLER_OFFER-013 | offer edit on accepted offer is not guarded — price change after payment is possible | P1 | — | +| SELLER_OFFER-014 | validUntil in the past is accepted by backend (no schema validator) | P2 | — | +| SELLER_OFFER-015 | Seller has no withdraw offer UI — verify withdrawal is only testable via direct API call | P1 | — | +| SELLER_OFFER-016 | Seller offer history page /dashboard/seller/marketplace/offers does not exist — notification links are broken | P1 | — | +| SELLER_OFFER-017 | Purchase request status transitions correctly from pending to received_offers after first offer | P1 | — | +| SELLER_OFFER-018 | Offer submission against a closed request (status not pending/active/received_offers) returns 400 | P1 | — | +| SELLER_OFFER-019 | Offer with price amount of 0 or negative is rejected by Mongoose validator | P2 | — | +| NEGOTIATION-001 | First negotiation chat message triggers purchase request status flip to in_negotiation | P1 | — | +| NEGOTIATION-002 | Status regression from in_negotiation to received_offers is blocked | P1 | — | +| NEGOTIATION-003 | Non-participant sending a message to a chat returns 403 | P0 | — | +| NEGOTIATION-004 | Buyer without ownership of the request cannot counter-offer via offer edit endpoint | P0 | — | +| NEGOTIATION-005 | Offer edit emits purchase-request-update with eventType='offer-updated' to request room | P1 | — | +| NEGOTIATION-006 | Orphan chat is reused when buyer reopens negotiation without paying | P2 | — | +| ESCROW-001 | Payment intent is created and checkout block is rendered correctly | P0 | — | +| ESCROW-002 | Payment funded state: escrowState='funded' and Payment.status='completed' are set after safety provider approval | P0 | — | +| ESCROW-003 | Transaction Safety Provider rejection transitions payment to Failed, not Funded | P0 | — | +| ESCROW-004 | Dispute opened during Funded state transitions escrowState to DisputeHold and blocks release | P0 | — | +| ESCROW-005 | Release flow: admin builds instruction, signer executes, admin confirms with txHash | P0 | — | +| ESCROW-006 | Refund follows the same instruction/confirmation pattern as release with correct ledger entry type | P0 | — | +| ESCROW-007 | Payment intent expiry or buyer cancellation before funding transitions to Cancelled | P1 | — | +| ESCROW-008 | PAYMENT_LEDGER_ENFORCEMENT=false creates custody risk — release uses raw Payment.status | P0 | — | +| ESCROW-009 | Simulated payment bypass (SIM_ prefix) allows escrow flow without real on-chain transaction | P0 | — | +| ESCROW-010 | Refund triggered during active dispute must go through resolution, not bypass dispute hold | P0 | — | +| ESCROW-011 | GET /api/payment/:id endpoint — confirm which router serves it and response shape | P1 | — | +| ESCROW-012 | Failed release retried from Failed state transitions back to Releasing | P1 | — | +| DELIVERY-001 | Seller marks shipped via PUT /purchase-requests/:id/delivery, not PATCH /:id with {status:'delivery'} | P0 | — | +| DELIVERY-002 | Buyer generates delivery code via POST /delivery-code/generate; only buyer can call this endpoint | P0 | — | +| DELIVERY-003 | Seller verifies delivery code via POST /delivery-code/verify; only seller can call this endpoint | P0 | — | +| DELIVERY-004 | POST /delivery-code (bare path) and POST /verify-delivery both return 404 | P0 | — | +| DELIVERY-005 | Buyer fast-track confirm-delivery (PATCH /confirm-delivery) transitions to 'delivered' without a code | P1 | — | +| DELIVERY-006 | Any authenticated user can call PATCH /confirm-delivery — no buyer-ownership check | P0 | — | +| DELIVERY-007 | After seller verifies code: buyer receives in-app notification and 'delivery-confirmed' event fires on request room | P1 | — | +| DELIVERY-008 | delivery-code-generated socket event broadcasts raw 6-digit code to entire request room including seller | P0 | — | +| DELIVERY-009 | POST /delivery-code/regenerate returns 404; frontend falls back to /generate and old code is invalidated | P1 | — | +| DELIVERY-010 | Wrong delivery code returns 400, expired code returns 400, already-used code returns 400 | P1 | — | +| DELIVERY-011 | No rate-limiting on verify-delivery — brute force 10+ consecutive wrong codes without lockout | P1 | — | +| DELIVERY-012 | GET /delivery-code returns 403 for seller due to dual-router controller conflict | P1 | — | +| DELIVERY-013 | Payment confirmation via PATCH /payments/:paymentId sets purchase request status to 'delivery' directly | P1 | — | +| DELIVERY-014 | getDeliveryAttempts and getDeliveryStats return 404 with no visible error in UI | P2 | — | +| DELIVERY-015 | Both delivery paths (code verification and fast-track confirm) independently transition to 'delivered' | P1 | — | +| DELIVERY-016 | Final-approval dummy payment backdoor is disabled or guarded in production | P0 | — | +| DELIVERY-017 | Delivery step components use axiosInstance directly rather than actions layer — verify correct endpoint usage | P2 | — | +| DELIVERY-018 | Buyer never confirms delivery — status stays 'delivery' indefinitely with no auto-release | P2 | — | +| DELIVERY-019 | Regeneration after generateDeliveryCode failure leaves request with no valid code | P2 | — | + +#### PURCHASE_REQUEST-001 — Buyer completes full purchase request wizard and submits successfully + +**Priority:** P0 + +**Steps:** +1. Log in as a buyer account +2. Click 'New request' in the dashboard sidebar +3. Confirm navigation to /dashboard/request/new +4. Step 1: Enter a title between 5 and 200 characters and a description of at least 5 characters, then select a valid category from the dropdown populated by GET /api/marketplace/categories +5. Step 2: Optionally add product link, size, color, quantity, and key/value specifications +6. Step 3: Set a valid min/max budget in USDT, select urgency 'medium', leave preferred sellers as 'all' +7. Step 4: Review summary; optionally attach a file via POST /api/marketplace/purchase-requests/:id/attachments; click Publish +8. Observe the network request to POST /api/marketplace/purchase-requests +9. Observe redirect to /dashboard/buyer/requests/{id} + +**Expected Result:** Backend responds 201 with the new purchase request. Buyer is redirected to the request detail page. The request document in MongoDB has status='pending' and isPublic=true. + +#### PURCHASE_REQUEST-002 — Duplicate submission within 5 minutes is rejected with Persian error message + +**Priority:** P1 + +**Steps:** +1. Log in as a buyer +2. Submit a purchase request with a specific title and description +3. Within 5 minutes, attempt to submit a second request with the identical title and description from the same buyer account +4. Inspect the HTTP response from POST /api/marketplace/purchase-requests + +**Expected Result:** Backend returns HTTP 400. The error message is the Persian string 'درخواست مشابه در ۵ دقیقه گذشته ایجاد شده است'. No second document is created in MongoDB. + +**Related Findings:** +- Purchase Request Flow edge case: duplicate submission within 5 minutes + +#### PURCHASE_REQUEST-003 — Attachment upload uses correct scoped endpoint /purchase-requests/:id/attachments, not /files/upload + +**Priority:** P0 + +**Steps:** +1. Log in as a buyer and navigate to /dashboard/request/new +2. Complete all wizard steps +3. On the Review step, attach a file +4. Intercept the outgoing network request for the file upload +5. Inspect the URL of the upload request + +**Expected Result:** The upload request is sent to POST /api/marketplace/purchase-requests/:id/attachments. No request is made to POST /api/files/upload. Backend returns a successful response with attachment metadata. + +**Related Findings:** +- endpoint-wrong: doc says POST /api/files/upload; actual is POST /api/marketplace/purchase-requests/:id/attachments + +#### PURCHASE_REQUEST-004 — Preferred sellers typeahead fetches from /api/marketplace/sellers, not /api/users/sellers + +**Priority:** P0 + +**Steps:** +1. Log in as a buyer and navigate to Step 3 of the purchase request wizard +2. Type a seller name in the preferred sellers typeahead field +3. Intercept all network requests triggered by the typeahead +4. Verify the URL of the seller search request + +**Expected Result:** The typeahead sends GET /api/marketplace/sellers. No request is made to GET /api/users/sellers. Seller results are returned and displayed correctly. + +**Related Findings:** +- doc-wrong: step 4 documents GET /api/users/sellers; actual is GET /api/marketplace/sellers + +#### PURCHASE_REQUEST-005 — preferredSellerIds containing invalid ObjectIds are silently dropped and request becomes public + +**Priority:** P2 + +**Steps:** +1. POST /api/marketplace/purchase-requests directly with preferredSellerIds containing one valid sellerId and one invalid string (e.g. 'INVALID_ID') +2. Inspect the created document in MongoDB + +**Expected Result:** The invalid ObjectId is silently dropped. The request is created without error. isPublic reflects whether any valid seller IDs remain after sanitization. + +**Related Findings:** +- Purchase Request Flow edge case: invalid ObjectIds in preferredSellerIds silently dropped + +#### PURCHASE_REQUEST-006 — Empty cleaned preferredSellerIds with no 'all' in original payload results in isPublic=true + +**Priority:** P2 + +**Steps:** +1. POST /api/marketplace/purchase-requests with a preferredSellerIds array containing only invalid ObjectIds (no 'all' value) +2. After all IDs are dropped during sanitization, inspect the created document + +**Expected Result:** All invalid IDs are dropped. Because no valid sellers remain and 'all' was not specified, isPublic is set to true (open marketplace fallback). Request is created successfully. + +**Related Findings:** +- Purchase Request Flow edge case: empty cleaned preferredSellerIds sets isPublic=true + +#### PURCHASE_REQUEST-007 — Description minimum is 5 characters per frontend schema, not 20 as documented + +**Priority:** P1 + +**Steps:** +1. Navigate to /dashboard/request/new +2. In Step 1, enter a title and a description of exactly 7 characters +3. Attempt to proceed to Step 2 +4. Observe frontend validation +5. Submit the full form with the 7-character description +6. Inspect the backend response + +**Expected Result:** Frontend accepts a 7-character description without validation error (schema minimum is 5, not 20). Backend also accepts it. Request is created successfully. + +**Related Findings:** +- doc-wrong: description documented as 20-2000 chars; frontend schema enforces 5 chars minimum + +#### PURCHASE_REQUEST-008 — Urgency field accepts 'urgent' as a fourth valid value + +**Priority:** P1 + +**Steps:** +1. POST /api/marketplace/purchase-requests with urgency='urgent' +2. Alternatively, inspect the frontend wizard urgency dropdown for a fourth 'urgent' option +3. Submit and observe backend response + +**Expected Result:** Backend accepts urgency='urgent'. The created document stores urgency='urgent'. Frontend wizard shows all four options: low, medium, high, urgent. + +**Related Findings:** +- doc-wrong: urgency documented as low/medium/high only; 'urgent' is a valid fourth value + +#### PURCHASE_REQUEST-009 — Statuses 'pending_payment' and 'active' are valid and handled by status-based UI branches + +**Priority:** P0 + +**Steps:** +1. Create a purchase request and manually set its status to 'pending_payment' in MongoDB +2. Load the buyer request detail page for this request +3. Observe which stepper step or UI branch is rendered +4. Repeat with status='active' + +**Expected Result:** The frontend renders without crashing for both 'pending_payment' and 'active' statuses. The correct workflow step component is displayed. Status-based visibility rules apply correctly. + +**Related Findings:** +- status-mismatch: backend includes pending_payment and active statuses absent from documentation + +#### PURCHASE_REQUEST-010 — updatePurchaseRequest uses PUT but backend registers PATCH — method mismatch causes 404 + +**Priority:** P0 + +**Steps:** +1. Log in as a buyer with an existing editable purchase request +2. Trigger the frontend action that calls updatePurchaseRequest (e.g. edit the request title) +3. Intercept the outgoing HTTP request +4. Observe the HTTP method used +5. Check the backend response status code + +**Expected Result:** Frontend sends PUT /marketplace/purchase-requests/:id. Backend has only PATCH on this path. Verify whether the request succeeds (method mismatch may cause 404 or 405). Document the actual HTTP status returned. + +**Related Findings:** +- flow-incomplete: frontend uses PUT; backend registers PATCH /purchase-requests/:id + +#### PURCHASE_REQUEST-011 — General PATCH /purchase-requests/:id cannot be used to bypass status progression whitelist + +**Priority:** P0 + +**Steps:** +1. Authenticate as a buyer with a request in 'pending' status +2. Send PATCH /api/marketplace/purchase-requests/:id with body { status: 'completed' } (non-adjacent status jump) +3. Observe the backend response + +**Expected Result:** Backend returns HTTP 400 with message 'Invalid status progression'. The status is not updated to 'completed'. The general PATCH endpoint enforces the same progression guard as the /status endpoint. + +**Related Findings:** +- doc-missing: general PATCH endpoint in routes.ts (legacy) updates any field without status guard + +#### PURCHASE_REQUEST-012 — Buyer cancellation after payment (status=processing or later) is blocked + +**Priority:** P0 + +**Steps:** +1. Create a purchase request and advance it to 'processing' status +2. Log in as the buyer and attempt to cancel the request via PATCH /:id/status with { status: 'cancelled' } +3. Observe the backend response + +**Expected Result:** Backend returns HTTP 400 'Invalid status progression'. The request remains in 'processing' status. Buyer cannot cancel without going through the Dispute Flow. + +**Related Findings:** +- Purchase Request Flow edge case: cancel after payment blocked by STATUS_PROGRESSION_ORDER + +#### PURCHASE_REQUEST-013 — Public purchase request appears in real time on all connected seller dashboards via 'new-purchase-request' to 'sellers' room + +**Priority:** P1 + +**Steps:** +1. Connect two seller clients to Socket.IO and join the 'sellers' room +2. Log in as a buyer and publish a purchase request with isPublic=true +3. Observe socket events received on both seller clients + +**Expected Result:** Both seller clients receive the 'new-purchase-request' socket event (not 'new-notification' to per-user rooms as documented). The payload contains the new request data. Both sellers see the request appear in their marketplace listing in real time. + +**Related Findings:** +- socket-mismatch: backend emits 'new-purchase-request' to 'sellers' room; doc describes per-seller 'new-notification' to user-{sellerId} + +#### PURCHASE_REQUEST-014 — Private purchase request is NOT broadcast to sellers outside preferredSellerIds via 'sellers' room + +**Priority:** P1 + +**Steps:** +1. Connect three seller clients to Socket.IO +2. Create a purchase request with isPublic=false and exactly one seller in preferredSellerIds +3. Observe which seller clients receive events + +**Expected Result:** The 'new-purchase-request' event is not broadcast to the shared 'sellers' room for private requests. Only the preferred seller receives a targeted notification. Sellers outside preferredSellerIds receive no event. + +**Related Findings:** +- socket-mismatch: per-seller room vs shared sellers room behavior for private requests + +#### PURCHASE_REQUEST-015 — Cancellation emits 'purchase-request-update' with eventType='status-changed', not 'request-cancelled' + +**Priority:** P1 + +**Steps:** +1. Create a purchase request in 'pending' status +2. Connect buyer and seller to Socket.IO and subscribe to relevant rooms +3. Cancel the request via PATCH /:id/status with { status: 'cancelled' } +4. Inspect all socket events received on both buyer and seller connections + +**Expected Result:** Neither buyer nor seller receives a 'request-cancelled' socket event. Both receive 'purchase-request-update' with eventType='status-changed' and the new status='cancelled'. Any frontend component listening for 'request-cancelled' will NOT be triggered. + +**Related Findings:** +- socket-mismatch: 'request-cancelled' event not emitted; cancellation uses 'purchase-request-update' with status-changed + +#### PURCHASE_REQUEST-016 — Seller receives real-time events via 'join-seller-room' in addition to 'join-request-room' + +**Priority:** P1 + +**Steps:** +1. Log in as a seller and navigate to the marketplace listing page +2. Inspect outgoing Socket.IO events from the seller client +3. Log in as a buyer and submit a purchase request targeting this seller +4. Observe socket events arriving on the seller client + +**Expected Result:** Seller client emits 'join-seller-room' on component mount. Seller receives seller-specific events (new offers, payment events) via the seller room. Events for request-specific updates arrive on the request room after joining it. + +**Related Findings:** +- socket-mismatch: 'join-seller-room' and 'join-buyer-room' undocumented but used by frontend + +#### PURCHASE_REQUEST-017 — getMarketplaceStats returns 404 and no production UI depends on it + +**Priority:** P1 + +**Steps:** +1. Authenticated as any user, send GET /api/marketplace/purchase-requests/stats +2. Load each main dashboard page and inspect network requests +3. Check for any visible error state or broken widget + +**Expected Result:** GET /api/marketplace/purchase-requests/stats returns HTTP 404. No dashboard page makes this call in production. No visible error is shown to the user. + +**Related Findings:** +- no-backend: getMarketplaceStats endpoint does not exist in backend + +#### PURCHASE_REQUEST-018 — searchPurchaseRequests using /purchase-requests/search returns 404; search must use query params on list endpoint + +**Priority:** P1 + +**Steps:** +1. Send GET /api/marketplace/purchase-requests/search?q=test +2. Observe the response +3. Then send GET /api/marketplace/purchase-requests?search=test +4. Observe the response + +**Expected Result:** The /search sub-path returns 404. The list endpoint with query parameters returns filtered results. Any search UI must use the query-param form. + +**Related Findings:** +- no-backend: /purchase-requests/search endpoint does not exist + +#### PURCHASE_REQUEST-019 — transaction-completed socket event fires to buyer and seller when request reaches 'completed' status + +**Priority:** P1 + +**Steps:** +1. Connect buyer and seller clients to Socket.IO and join their respective user rooms +2. Advance a purchase request to 'completed' status via the admin or backend +3. Observe socket events on both buyer and seller connections + +**Expected Result:** Both buyer (room user-{buyerId}) and seller (room user-{sellerId}) receive the 'transaction-completed' socket event. Any completion toast, modal, or redirect in the frontend is triggered. + +**Related Findings:** +- doc-missing: 'transaction-completed' socket event not documented in any flow + +#### PURCHASE_REQUEST-020 — Invalid status transition via PATCH returns 400 'Invalid status progression' + +**Priority:** P1 + +**Steps:** +1. Create a purchase request in 'pending' status +2. Send PATCH /api/marketplace/purchase-requests/:id with body { status: 'seller_paid' } (non-adjacent jump) +3. Observe the response + +**Expected Result:** Backend returns HTTP 400 with message 'Invalid status progression'. Status remains 'pending'. + +#### PURCHASE_REQUEST-021 — Invalid category ObjectId in purchase request creation returns 400 + +**Priority:** P2 + +**Steps:** +1. POST /api/marketplace/purchase-requests with categoryId set to 'not-a-valid-objectid' +2. Observe the response + +**Expected Result:** Backend returns HTTP 400 due to Mongoose ObjectId validation failure. No document is created. + +**Related Findings:** +- Purchase Request Flow edge case: invalid category ObjectId → 400 + +#### PURCHASE_REQUEST-022 — Notification fan-out failure for an individual seller does not prevent request creation + +**Priority:** P2 + +**Steps:** +1. Simulate a notification service failure for one seller (e.g., mock the notification function to throw for a specific seller) +2. Submit a purchase request that targets multiple sellers +3. Observe the backend response and the MongoDB document + +**Expected Result:** Backend returns 201 with the created request. The failed notification is logged. The successfully notified sellers receive their notifications. The request is created regardless of the partial notification failure. + +**Related Findings:** +- Purchase Request Flow edge case: notification fan-out failure for individual seller is logged but does not fail request + +#### PURCHASE_REQUEST-023 — Workflow steps endpoint returns accurate step data for buyer and seller roles + +**Priority:** P3 + +**Steps:** +1. Create a purchase request and advance it to 'processing' status +2. As a buyer, call GET /api/marketplace/purchase-requests/:id/workflow-steps +3. As a seller, call the same endpoint +4. Compare the returned step data with what the frontend stepper renders locally + +**Expected Result:** Both calls return HTTP 200 with role-appropriate workflow step data. The step data is consistent with what the stepper component renders. The endpoint is reachable and correct even though the frontend does not currently call it. + +**Related Findings:** +- no-frontend: getWorkflowSteps defined but never called from detail page components + +#### SELLER_OFFER-001 — Seller creates offer via correct scoped endpoint POST /purchase-requests/:id/offers + +**Priority:** P0 + +**Steps:** +1. Log in as a seller +2. Navigate to /dashboard/seller/marketplace and select a purchase request in 'pending' status +3. Fill in the proposal form (title, description, price, delivery time) +4. Submit the proposal +5. Intercept the network request and inspect the URL and method + +**Expected Result:** Frontend sends POST /api/marketplace/purchase-requests/:id/offers where :id is the purchaseRequestId. No request is sent to POST /api/marketplace/offers (flat path). Backend returns 200 with the populated offer object. + +**Related Findings:** +- endpoint-wrong: doc lists POST /api/marketplace/offers; actual is POST /api/marketplace/purchase-requests/:id/offers + +#### SELLER_OFFER-002 — Duplicate offer from same seller on same request returns correct error code + +**Priority:** P1 + +**Steps:** +1. Log in as a seller and submit an offer on a purchase request +2. Without withdrawing the first offer, attempt to submit a second offer on the same request from the same seller +3. Observe the HTTP response code and message + +**Expected Result:** Backend returns an error (expected 400 per doc, may return 409 or 500 due to wrapped Persian error message). The frontend toast displays a user-readable error. No second offer document is created. + +**Related Findings:** +- doc-wrong: duplicate offer error may return 500 instead of 409/400 due to Persian error wrapping + +#### SELLER_OFFER-003 — POST /api/marketplace/offers (flat path) and GET /api/marketplace/offers/request/:requestId return 404 + +**Priority:** P0 + +**Steps:** +1. Send POST /api/marketplace/offers with a valid offer payload +2. Send GET /api/marketplace/offers/request/{requestId} +3. Send GET /api/marketplace/offers/seller/{sellerId} +4. Observe the HTTP response codes + +**Expected Result:** All three documented-but-nonexistent flat paths return HTTP 404. The correct endpoints are POST /purchase-requests/:id/offers and GET /purchase-requests/:id/offers. + +**Related Findings:** +- endpoint-wrong: documented flat offer endpoints do not exist in backend + +#### SELLER_OFFER-004 — Buyer offer listing uses GET /purchase-requests/:id/offers sorted by createdAt descending + +**Priority:** P1 + +**Steps:** +1. Create a purchase request with multiple offers submitted at different times +2. Log in as the buyer and navigate to /dashboard/buyer/requests/:id +3. Observe the offer cards displayed and their order +4. Also call GET /api/marketplace/purchase-requests/:id/offers directly + +**Expected Result:** Offers are displayed in descending creation order (newest first). The API returns all offers for the request. Each offer card shows seller name, avatar, rating, price, ETA, and notes. + +**Related Findings:** +- endpoint-wrong: documented GET /offers/request/:requestId does not exist; correct path is GET /purchase-requests/:id/offers + +#### SELLER_OFFER-005 — Seller offer withdrawal via PUT /offers/:id/status — no dedicated /withdraw endpoint exists + +**Priority:** P0 + +**Steps:** +1. Log in as a seller with a pending offer +2. Attempt POST /api/marketplace/offers/:id/withdraw +3. Observe the 404 response +4. Then send PUT /api/marketplace/offers/:id/status with body { status: 'withdrawn' } +5. Observe the response and the updated offer status + +**Expected Result:** POST /offers/:id/withdraw returns 404. PUT /offers/:id/status with status='withdrawn' succeeds and sets the offer to 'withdrawn'. There is no frontend withdraw button to test (UI gap confirmed). + +**Related Findings:** +- no-backend: POST /offers/:id/withdraw endpoint does not exist; withdrawal uses PUT /offers/:id/status + +#### SELLER_OFFER-006 — Accepted offer status can be overwritten to 'withdrawn' via PUT /offers/:id/status — pending-only guard is not enforced + +**Priority:** P0 + +**Steps:** +1. Create a purchase request and have a seller submit an offer +2. Accept the offer so its status becomes 'accepted' +3. As the same seller, send PUT /api/marketplace/offers/:id/status with body { status: 'withdrawn' } +4. Observe the response and resulting offer status + +**Expected Result:** The backend does not enforce a pending-only guard on the status route. The accepted offer's status is updated to 'withdrawn'. This is a data integrity bug — document the actual behavior. + +**Related Findings:** +- no-backend: withdrawOffer() service not called by any route; PUT /offers/:id/status has no status guard + +#### SELLER_OFFER-007 — Setting SellerOffer status to 'active' throws Mongoose ValidationError + +**Priority:** P1 + +**Steps:** +1. Attempt to create or update a SellerOffer with status='active' via the API +2. Observe the backend response + +**Expected Result:** Backend returns a validation error. The SellerOffer schema only accepts 'pending', 'accepted', 'rejected', 'withdrawn'. Status 'active' is not a valid SellerOffer status and must not be used in test cases. + +**Related Findings:** +- status-mismatch: SellerOffer 'active' status documented but absent from schema enum + +#### SELLER_OFFER-008 — Offer can be created against a PurchaseRequest in 'active' status + +**Priority:** P1 + +**Steps:** +1. Create a purchase request and set its status to 'active' in MongoDB +2. Log in as a seller and submit an offer on this request via POST /purchase-requests/:id/offers +3. Observe the backend response + +**Expected Result:** Backend accepts the offer creation. SellerOfferService.createOffer allows PurchaseRequest.status in ['pending', 'active', 'received_offers']. Offer is created with status='pending'. + +**Related Findings:** +- flow-incomplete: createOffer allows 'active' PurchaseRequest status; doc only mentions pending/received_offers + +#### SELLER_OFFER-009 — select-offer cascade corrupts withdrawn/rejected offers — status filter missing + +**Priority:** P0 + +**Steps:** +1. Create a purchase request with two offers: one already 'withdrawn' and one 'pending' +2. Call POST /purchase-requests/:id/select-offer to accept the pending offer +3. Inspect the status of the previously withdrawn offer in MongoDB + +**Expected Result:** The withdrawn offer's status should remain 'withdrawn'. Actual: the select-offer route's updateMany has no status filter, so the withdrawn offer may be overwritten to 'rejected'. Document the actual behavior as a data integrity regression. + +**Related Findings:** +- flow-incomplete: select-offer updateMany has no status filter; corrupts already-withdrawn/rejected offers + +#### SELLER_OFFER-010 — select-offer does not send notifications to winning or losing sellers + +**Priority:** P0 + +**Steps:** +1. Create a purchase request with two pending seller offers +2. Connect both sellers to Socket.IO +3. Call POST /purchase-requests/:id/select-offer to select one offer +4. Observe socket events and in-app notifications on both seller accounts + +**Expected Result:** The winning seller does NOT receive a 'seller-offer-update' event or notifyOfferAccepted notification via this path. The losing seller does NOT receive notifyOfferRejected. Only 'purchase-request-update' with eventType='offer-selected' is emitted to the request room. Document the gap between documented and actual notification behavior. + +**Related Findings:** +- flow-incomplete: select-offer path sends no per-seller notifications or socket events + +#### SELLER_OFFER-011 — Buyer receives 'new-offer' socket event on buyer-{buyerId} room when seller submits proposal + +**Priority:** P1 + +**Steps:** +1. Log in as a buyer with an open purchase request +2. Connect buyer client to Socket.IO and join buyer-{buyerId} room +3. Have a seller submit an offer on the request +4. Observe socket events on the buyer connection + +**Expected Result:** Buyer receives the 'new-offer' event directly on room buyer-{buyerId} in addition to the 'new-notification' event. Frontend use-marketplace-socket.ts listener for 'new-offer' is triggered. The offer count badge updates in real time. + +**Related Findings:** +- socket-mismatch: 'new-offer' event to buyer-{buyerId} room not documented; emitted by marketplaceController + +#### SELLER_OFFER-012 — Offer update method mismatch: frontend uses PUT, backend registers PATCH + +**Priority:** P0 + +**Steps:** +1. Log in as a seller with an existing pending offer +2. Edit the offer price or ETA from the seller proposal form +3. Intercept the network request for the update +4. Observe the HTTP method (PUT vs PATCH) and the response status + +**Expected Result:** Frontend sends PUT /marketplace/offers/:id. Backend registers PATCH /offers/:id. Verify whether the request is accepted (method mismatch may result in 404). Document the actual HTTP status — if 404, this is a regression blocking offer edits. + +**Related Findings:** +- endpoint-wrong: frontend uses PUT; backend registers PATCH /offers/:id + +#### SELLER_OFFER-013 — offer edit on accepted offer is not guarded — price change after payment is possible + +**Priority:** P1 + +**Steps:** +1. Create a purchase request, submit an offer, and accept it so offer status is 'accepted' +2. As the seller, attempt to edit the offer price via the update endpoint +3. Observe whether the backend rejects the update + +**Expected Result:** Ideally the backend rejects updates to accepted offers. Per the known gap, updateOffer does not enforce status check. Verify the actual behavior and document whether price is mutated post-acceptance. + +**Related Findings:** +- Negotiation Flow edge case: counter on accepted offer currently allowed; recommended hardening not implemented + +#### SELLER_OFFER-014 — validUntil in the past is accepted by backend (no schema validator) + +**Priority:** P2 + +**Steps:** +1. Submit a seller offer with validUntil set to yesterday's date +2. Observe the backend response +3. Check the offer status in MongoDB immediately after creation + +**Expected Result:** Backend accepts the offer without validation error (no min-date validator on schema). Offer is created with status='pending'. The cron job (markExpiredOffersAsWithdrawn) will eventually flip it to 'withdrawn' — not immediate. + +**Related Findings:** +- doc-wrong: validUntil in past documented to be rejected by schema validator; no such validator exists + +#### SELLER_OFFER-015 — Seller has no withdraw offer UI — verify withdrawal is only testable via direct API call + +**Priority:** P1 + +**Steps:** +1. Log in as a seller with an active pending offer +2. Navigate all seller dashboard pages and look for a 'Withdraw offer' button +3. Attempt to withdraw via direct API: PUT /api/marketplace/offers/:id/status with { status: 'withdrawn' } + +**Expected Result:** No withdraw button exists in any frontend UI. The API call with status='withdrawn' is the only available path. Confirm there is no route /dashboard/seller/marketplace/offers/{offerId} page. + +**Related Findings:** +- no-frontend: no withdraw offer action or UI exists in the frontend + +#### SELLER_OFFER-016 — Seller offer history page /dashboard/seller/marketplace/offers does not exist — notification links are broken + +**Priority:** P1 + +**Steps:** +1. Navigate directly to /dashboard/seller/marketplace/offers +2. Observe the page response +3. Trigger a notification that links to this page (e.g. offer accepted notification) +4. Click the notification action URL + +**Expected Result:** The URL /dashboard/seller/marketplace/offers produces a 404 or redirect-to-not-found. Notification action URLs pointing to this path are broken. Seller offer history is inaccessible. + +**Related Findings:** +- no-frontend: no seller My Offers page; GET /offers/seller/:sellerId also has no backend route + +#### SELLER_OFFER-017 — Purchase request status transitions correctly from pending to received_offers after first offer + +**Priority:** P1 + +**Steps:** +1. Create a purchase request with status='pending' +2. Log in as a seller and submit an offer on this request +3. Observe the purchase request status in MongoDB after offer creation + +**Expected Result:** After the first offer is created, purchase request status automatically transitions to 'received_offers'. Subsequent offers do not change the status again. + +#### SELLER_OFFER-018 — Offer submission against a closed request (status not pending/active/received_offers) returns 400 + +**Priority:** P1 + +**Steps:** +1. Create a purchase request and advance it to 'payment' status +2. Log in as a seller and attempt to submit an offer on this request +3. Observe the backend response + +**Expected Result:** Backend returns HTTP 400 with Persian error 'این درخواست دیگر برای پیشنهاد باز نیست'. No offer is created. + +**Related Findings:** +- Seller Offer Flow edge case: purchase request not open → 400 + +#### SELLER_OFFER-019 — Offer with price amount of 0 or negative is rejected by Mongoose validator + +**Priority:** P2 + +**Steps:** +1. Submit a seller offer with price.amount set to 0 +2. Submit another with price.amount set to -10 +3. Observe the backend response for each + +**Expected Result:** Both attempts return HTTP 400 due to Mongoose schema validation on price.amount. No offer documents are created. + +**Related Findings:** +- Seller Offer Flow edge case: price = 0 or negative → Mongoose validator rejects + +#### NEGOTIATION-001 — First negotiation chat message triggers purchase request status flip to in_negotiation + +**Priority:** P1 + +**Steps:** +1. Create a purchase request in 'received_offers' status with an existing offer +2. Log in as the buyer and click 'Chat with seller' on the offer card +3. Confirm POST /api/chat is called to find-or-create the negotiation chat +4. Send the first message in the chat +5. Observe the purchase request status after the message is sent + +**Expected Result:** Purchase request status transitions from 'received_offers' to 'in_negotiation'. A 'purchase-request-update' socket event with eventType='status-changed' is emitted to room request-{id}. The status change is persisted in MongoDB. + +**Related Findings:** +- Negotiation Flow docFlag: in_negotiation trigger ambiguous (backend hook vs. manual frontend PATCH) + +#### NEGOTIATION-002 — Status regression from in_negotiation to received_offers is blocked + +**Priority:** P1 + +**Steps:** +1. Advance a purchase request to 'in_negotiation' status +2. Attempt to PATCH status back to 'received_offers' +3. Observe the backend response + +**Expected Result:** Backend returns HTTP 400 'Invalid status progression'. Status remains 'in_negotiation'. The isValidStatusProgression guard prevents regression. + +**Related Findings:** +- Negotiation Flow edge case: status regression attempt blocked by isValidStatusProgression + +#### NEGOTIATION-003 — Non-participant sending a message to a chat returns 403 + +**Priority:** P0 + +**Steps:** +1. Create a negotiation chat between a buyer and seller +2. Log in as a third user (another seller or buyer not in the chat) +3. Attempt to POST /api/chat/:chatId/messages as the non-participant +4. Observe the backend response + +**Expected Result:** Backend returns HTTP 403 'User is not a participant in this chat'. No message is created. + +**Related Findings:** +- Negotiation Flow edge case: sender not a chat participant → 403 + +#### NEGOTIATION-004 — Buyer without ownership of the request cannot counter-offer via offer edit endpoint + +**Priority:** P0 + +**Steps:** +1. Create a purchase request owned by Buyer A with an offer from Seller A +2. Log in as Buyer B (a different buyer) +3. Attempt to PATCH /api/marketplace/offers/:id with new price terms +4. Observe the backend response + +**Expected Result:** Backend returns an authorization error (403 or 401). Buyer B cannot modify an offer on Buyer A's request. + +**Related Findings:** +- Negotiation Flow edge case: counter on offer buyer doesn't own the request for → blocked by controller + +#### NEGOTIATION-005 — Offer edit emits purchase-request-update with eventType='offer-updated' to request room + +**Priority:** P1 + +**Steps:** +1. Connect buyer client to Socket.IO and join room request-{id} +2. As the seller, edit the offer price via PATCH /api/marketplace/offers/:id +3. Observe the socket event on the buyer's connection + +**Expected Result:** Buyer receives 'purchase-request-update' with eventType='offer-updated' on room request-{id}. The buyer's offer card refreshes with the new price/ETA. + +**Related Findings:** +- Negotiation Flow: offer edit emits purchase-request-update with offer-updated eventType + +#### NEGOTIATION-006 — Orphan chat is reused when buyer reopens negotiation without paying + +**Priority:** P2 + +**Steps:** +1. Create a negotiation chat between a buyer and seller +2. Do not proceed to payment — let the request remain in 'in_negotiation' status +3. Navigate away and return to the same offer card +4. Click 'Chat with seller' again +5. Observe the chat returned by POST /api/chat + +**Expected Result:** POST /api/chat returns the existing chat (find-or-create matches by participants + relatedTo). No duplicate chat is created. The existing message history is preserved. + +**Related Findings:** +- Negotiation Flow edge case: orphan chat reused when buyer never pays + +#### ESCROW-001 — Payment intent is created and checkout block is rendered correctly + +**Priority:** P0 + +**Steps:** +1. Accept a seller offer as a buyer to trigger the payment flow +2. Observe the call to POST /api/payment/request-network/intents +3. Observe the checkout block rendering at GET /api/payment/request-network/:paymentId/checkout +4. Confirm the buyer can sign on-chain transactions from their wallet + +**Expected Result:** Payment intent is created. Checkout block is rendered with correct RN-compatible transaction data. Buyer wallet is prompted to sign. + +#### ESCROW-002 — Payment funded state: escrowState='funded' and Payment.status='completed' are set after safety provider approval + +**Priority:** P0 + +**Steps:** +1. Complete a payment via the Request Network webhook path +2. Confirm the Transaction Safety Provider validates: tx hash, confirmations, token/recipient/amount match +3. Inspect Payment document in MongoDB after approval + +**Expected Result:** Payment.status='completed' and Payment.escrowState='funded'. FundsLedgerEntry has entries of type 'payment_detected' and 'hold'. No state change occurs before safety provider approval. + +**Related Findings:** +- Escrow Flow step 7: payment only funded after safety approval + +#### ESCROW-003 — Transaction Safety Provider rejection transitions payment to Failed, not Funded + +**Priority:** P0 + +**Steps:** +1. Submit a payment where the Transaction Safety Provider rejects verification (e.g. mock a failed AML check or mismatched amount) +2. Inspect the Payment document status + +**Expected Result:** Payment transitions to Payment.status='failed'. escrowState='failed'. No FundsLedgerEntry hold is created. Funds are not considered escrowed. + +**Related Findings:** +- Escrow Flow edge case: TSP rejects verification → Processing → Failed + +#### ESCROW-004 — Dispute opened during Funded state transitions escrowState to DisputeHold and blocks release + +**Priority:** P0 + +**Steps:** +1. Reach a state where Payment.escrowState='funded' +2. Open a dispute on the purchase request +3. Attempt to call POST /api/payment/:id/release +4. Observe the response + +**Expected Result:** Opening the dispute sets hold fields on the payment. escrowState transitions to a held/disputed state. The release endpoint is blocked. POST /api/payment/:id/release returns an error indicating a dispute hold. + +**Related Findings:** +- Escrow Flow edge case: dispute opened in Funded state → DisputeHold; release gates consult holds + +#### ESCROW-005 — Release flow: admin builds instruction, signer executes, admin confirms with txHash + +**Priority:** P0 + +**Steps:** +1. Reach a state where Payment.escrowState='releasable' +2. Admin calls POST /api/payment/:id/release and receives unsigned instruction +3. Custody signer executes the transaction and returns txHash +4. Admin calls POST /api/payment/:id/release/confirm with txHash +5. Inspect the Payment document + +**Expected Result:** POST /api/payment/:id/release returns unsigned instruction without error. After confirmation, Payment.escrowState='released' and a 'release' ledger entry is appended. + +**Related Findings:** +- Escrow Flow steps 10-15: two-step release instruction/confirmation pattern + +#### ESCROW-006 — Refund follows the same instruction/confirmation pattern as release with correct ledger entry type + +**Priority:** P0 + +**Steps:** +1. Reach a state requiring refund (e.g. dispute resolved for buyer) +2. Admin calls POST /api/payment/:id/refund +3. Confirm the destination is the buyer/refund wallet +4. Admin calls POST /api/payment/:id/refund/confirm with txHash +5. Inspect the Payment document and ledger entries + +**Expected Result:** Refund follows the same two-step pattern. escrowState='refunded'. Ledger entry type is 'refund'. Destination address is the buyer's wallet, not the seller's. + +**Related Findings:** +- Escrow Flow step 16: refund same pattern as release; escrowState='refunded'; entry type='refund' + +#### ESCROW-007 — Payment intent expiry or buyer cancellation before funding transitions to Cancelled + +**Priority:** P1 + +**Steps:** +1. Create a payment intent via POST /api/payment/request-network/intents +2. Allow the intent to expire without the buyer completing payment +3. Inspect the Payment document status + +**Expected Result:** Payment transitions to Payment.status='cancelled'. escrowState='cancelled'. No funds are held. + +**Related Findings:** +- Escrow Flow edge case: payment intent expired or buyer cancels before funding → Cancelled + +#### ESCROW-008 — PAYMENT_LEDGER_ENFORCEMENT=false creates custody risk — release uses raw Payment.status + +**Priority:** P0 + +**Steps:** +1. Confirm the PAYMENT_LEDGER_ENFORCEMENT environment variable is enabled in the production config +2. If accessible, test with enforcement disabled: attempt to release a payment that has no ledger hold entry +3. Observe whether release is permitted + +**Expected Result:** When enforcement is enabled (production default), release eligibility requires a valid ledger entry. With enforcement disabled, release is derived from raw Payment.status, bypassing ledger checks. Confirm the production environment has enforcement enabled. + +**Related Findings:** +- Escrow Flow edge case: PAYMENT_LEDGER_ENFORCEMENT disabled creates custody risk + +#### ESCROW-009 — Simulated payment bypass (SIM_ prefix) allows escrow flow without real on-chain transaction + +**Priority:** P0 + +**Steps:** +1. Submit a payment hash starting with 'SIM_' to the payment verification endpoint +2. Observe whether the payment is accepted as verified +3. Attempt to proceed through the full delivery and escrow release flow using this simulated payment + +**Expected Result:** The SIM_ prefix causes the backend to treat the payment as verified. The full delivery flow can be completed without a real on-chain transaction. Document this backdoor and confirm it is environment-gated or disabled in production. + +**Related Findings:** +- info: SIM_ payment bypass present in production code — dev backdoor + +#### ESCROW-010 — Refund triggered during active dispute must go through resolution, not bypass dispute hold + +**Priority:** P0 + +**Steps:** +1. Open a dispute on a funded payment +2. Attempt to call POST /api/payment/:id/refund without resolving the dispute +3. Observe the backend response + +**Expected Result:** The refund endpoint checks the dispute hold. Refund is blocked while an active dispute is open. Backend returns an error indicating the dispute must be resolved first. + +**Related Findings:** +- Escrow Flow edge case: refund during active dispute must be explicit resolution, not accidental bypass + +#### ESCROW-011 — GET /api/payment/:id endpoint — confirm which router serves it and response shape + +**Priority:** P1 + +**Steps:** +1. As a buyer, call GET /api/payment/:id for a known payment ID +2. As an admin, call the same endpoint +3. Also call GET /api/marketplace/payments/:paymentId +4. Compare the responses + +**Expected Result:** Confirm which path (/api/payment/:id vs /api/marketplace/payments/:paymentId) is served by which router. Document the response shape. Verify buyer and admin views return appropriate data without authorization bypass. + +**Related Findings:** +- doc-wrong: /api/payment/:id may refer to separate router; actual marketplace path is /api/marketplace/payments/:paymentId + +#### ESCROW-012 — Failed release retried from Failed state transitions back to Releasing + +**Priority:** P1 + +**Steps:** +1. Reach a state where escrowState='failed' after a failed release attempt +2. Admin retries via POST /api/payment/:id/release +3. Observe the state transition + +**Expected Result:** Admin can retry a failed release. escrowState transitions from 'failed' to 'releasing'. The release proceeds through the normal instruction/confirmation flow. + +**Related Findings:** +- Escrow Flow edge case: admin retries release from Failed state → Releasing + +#### DELIVERY-001 — Seller marks shipped via PUT /purchase-requests/:id/delivery, not PATCH /:id with {status:'delivery'} + +**Priority:** P0 + +**Steps:** +1. Log in as a seller with a purchase request in 'processing' or 'payment' status +2. Click 'Mark as shipped' in the seller steps UI +3. Intercept the network request +4. Observe the HTTP method and URL + +**Expected Result:** Frontend sends PUT /api/marketplace/purchase-requests/:id/delivery with shipping date/time payload. The status advances to 'delivery' and shippedAt is set. No PATCH /:id with {status:'delivery'} is sent. + +**Related Findings:** +- doc-wrong: doc says PATCH /:id {status:'delivery'}; actual is PUT /:id/delivery via updateDeliveryInfo + +#### DELIVERY-002 — Buyer generates delivery code via POST /delivery-code/generate; only buyer can call this endpoint + +**Priority:** P0 + +**Steps:** +1. Advance a purchase request to 'delivery' status +2. Log in as the buyer and call POST /api/marketplace/purchase-requests/:id/delivery-code/generate +3. Observe the response and the code displayed in step-5-receive-goods component +4. Repeat the call as the seller; observe the response +5. Repeat as an admin; observe the response + +**Expected Result:** Buyer call succeeds: 6-digit code is generated, stored in deliveryInfo.deliveryCode, and returned. Seller call returns 403. Admin call returns 403. The code is NOT auto-generated when the seller marks shipped. + +**Related Findings:** +- critical doc-wrong: buyer generates code; doc says seller/admin generates; seller verifies; doc says buyer verifies + +#### DELIVERY-003 — Seller verifies delivery code via POST /delivery-code/verify; only seller can call this endpoint + +**Priority:** P0 + +**Steps:** +1. Advance to 'delivery' status and have the buyer generate a code +2. Log in as the seller and call POST /api/marketplace/purchase-requests/:id/delivery-code/verify with the correct code +3. Observe the response and purchase request status +4. Repeat as the buyer; observe the 403 +5. Attempt POST /api/marketplace/purchase-requests/:id/verify-delivery; observe 404 + +**Expected Result:** Seller call with correct code succeeds: status transitions to 'delivered', deliveryCodeUsed=true. Buyer call to /delivery-code/verify returns 403. POST to /verify-delivery (documented but nonexistent) returns 404. + +**Related Findings:** +- critical endpoint-wrong: /verify-delivery does not exist; correct path is /delivery-code/verify; actors reversed from doc + +#### DELIVERY-004 — POST /delivery-code (bare path) and POST /verify-delivery both return 404 + +**Priority:** P0 + +**Steps:** +1. Send POST /api/marketplace/purchase-requests/:id/delivery-code with a valid body +2. Send POST /api/marketplace/purchase-requests/:id/verify-delivery with a valid code +3. Observe the HTTP responses + +**Expected Result:** Both documented-but-nonexistent paths return HTTP 404. Only /delivery-code/generate and /delivery-code/verify are valid. + +**Related Findings:** +- critical endpoint-wrong: documented API endpoint paths do not match actual backend routes + +#### DELIVERY-005 — Buyer fast-track confirm-delivery (PATCH /confirm-delivery) transitions to 'delivered' without a code + +**Priority:** P1 + +**Steps:** +1. Advance a purchase request to 'delivery' status without generating a delivery code +2. Log in as the buyer and call PATCH /api/marketplace/purchase-requests/:id/confirm-delivery +3. Observe the status change and socket events emitted + +**Expected Result:** Status transitions to 'delivered'. deliveryConfirmed=true and deliveryConfirmedAt are set. Socket event 'purchase-request-update' with eventType='status-changed' is emitted. The seller does NOT receive 'buyer-confirmed-delivery' notification via this fast-track path. + +**Related Findings:** +- doc-wrong: confirm-delivery does not call notifyDeliveryConfirmed; emits different socket event + +#### DELIVERY-006 — Any authenticated user can call PATCH /confirm-delivery — no buyer-ownership check + +**Priority:** P0 + +**Steps:** +1. Advance a purchase request to 'delivery' status +2. Log in as the seller (not the buyer) and call PATCH /api/marketplace/purchase-requests/:id/confirm-delivery +3. Observe the response and the resulting purchase request status + +**Expected Result:** The backend accepts the call from the seller because there is no buyer-ownership check in confirmDelivery. This is a security gap. Document whether the status transitions to 'delivered' and whether this is exploitable by the seller. + +**Related Findings:** +- doc-wrong: confirm-delivery has no buyer auth check; any authenticated user can call it + +#### DELIVERY-007 — After seller verifies code: buyer receives in-app notification and 'delivery-confirmed' event fires on request room + +**Priority:** P1 + +**Steps:** +1. Advance to 'delivery' status and have buyer generate a code +2. Connect buyer to Socket.IO on room request-{id}; connect seller to user-{sellerId} room +3. As the seller, call POST /delivery-code/verify with the correct code +4. Observe socket events and in-app notifications for both buyer and seller + +**Expected Result:** 'delivery-confirmed' event fires on room request-{id}. 'buyer-confirmed-delivery' event fires on room user-{sellerId}. Both buyer and seller receive in-app notifications via NotificationService. (These events come from DeliveryService, not PurchaseRequestService:631-641 as documented.) + +**Related Findings:** +- doc-wrong: notifyDeliveryConfirmed called from DeliveryService.verifyDeliveryCode, not PurchaseRequestService:631-641 + +#### DELIVERY-008 — delivery-code-generated socket event broadcasts raw 6-digit code to entire request room including seller + +**Priority:** P0 + +**Steps:** +1. Advance to 'delivery' status +2. Connect the seller client to Socket.IO and join room request-{id} +3. As the buyer, call POST /delivery-code/generate +4. Observe socket events received on the seller's connection +5. Specifically look at the payload of 'delivery-code-generated' + +**Expected Result:** The seller receives the 'delivery-code-generated' event with the raw code in the payload. This is a security issue: the seller can see the code before physical handoff and verify it themselves. Document the full payload received. + +**Related Findings:** +- socket-mismatch: delivery-code-generated broadcasts raw code to entire request room including seller + +#### DELIVERY-009 — POST /delivery-code/regenerate returns 404; frontend falls back to /generate and old code is invalidated + +**Priority:** P1 + +**Steps:** +1. Advance to 'delivery' status and generate an initial code +2. Call POST /api/marketplace/purchase-requests/:id/delivery-code/regenerate +3. Observe the 404 response +4. Confirm the frontend silently falls back to POST /delivery-code/generate +5. Verify the old code no longer works for verification + +**Expected Result:** POST /delivery-code/regenerate returns 404. The frontend fallback calls /generate and creates a new code. The old code should be invalidated (verify by attempting to use the old code — if the fallback skips the regenerateDeliveryCode invalidation step, the old code may still work). + +**Related Findings:** +- endpoint-missing: no regenerate delivery code endpoint in backend; frontend catches 404 and falls back + +#### DELIVERY-010 — Wrong delivery code returns 400, expired code returns 400, already-used code returns 400 + +**Priority:** P1 + +**Steps:** +1. Generate a delivery code for a request in 'delivery' status +2. As the seller, call POST /delivery-code/verify with an incorrect code +3. Observe the error response +4. Advance the code's expiry date past 7 days in MongoDB, then call verify with the correct code +5. Mark the code as used in MongoDB, then call verify with the correct code again + +**Expected Result:** Wrong code: HTTP 400 'Invalid delivery code'. Expired code: HTTP 400 'Code expired'. Already-used code: HTTP 400 'Code already used'. Status remains 'delivery' in all three failure cases. + +**Related Findings:** +- Delivery Confirmation Flow edge cases: wrong/expired/already-used code handling + +#### DELIVERY-011 — No rate-limiting on verify-delivery — brute force 10+ consecutive wrong codes without lockout + +**Priority:** P1 + +**Steps:** +1. Generate a delivery code for a request in 'delivery' status +2. As the seller, rapidly submit 15 consecutive POST /delivery-code/verify requests with random wrong codes +3. Observe whether any rate limiting, IP blocking, or lockout is applied + +**Expected Result:** All 15 requests are accepted without any rate limiting or lockout. This confirms the security gap. Failed attempts may be logged to deliveryInfo.deliveryAttempts[] but no threshold is enforced. Document for security remediation. + +**Related Findings:** +- uat-gap: no brute-force protection on verify-delivery code endpoint + +#### DELIVERY-012 — GET /delivery-code returns 403 for seller due to dual-router controller conflict + +**Priority:** P1 + +**Steps:** +1. Advance a purchase request to 'delivery' status and generate a code as the buyer +2. Log in as the selected seller and call GET /api/marketplace/purchase-requests/:id/delivery-code +3. Observe the response +4. Log in as the buyer and call the same endpoint +5. Observe the response + +**Expected Result:** Seller receives 403 because the controller router (mounted first) enforces buyer-only access, overriding the legacy router's seller-access logic. Buyer receives 200 with the code. Document this dual-router conflict. + +**Related Findings:** +- doc-missing: dual-router conflict on delivery-code endpoints — controller buyer-only version takes precedence + +#### DELIVERY-013 — Payment confirmation via PATCH /payments/:paymentId sets purchase request status to 'delivery' directly + +**Priority:** P1 + +**Steps:** +1. Create a purchase request in 'payment' or 'processing' status with a linked payment +2. Call PATCH /api/marketplace/payments/:paymentId with status='completed' +3. Inspect the purchase request status in MongoDB + +**Expected Result:** The payment confirmation route sets PurchaseRequest.status='delivery' directly, potentially bypassing 'processing'. Verify whether 'processing' is skipped. Confirm that delivery code can be generated immediately after this status is set. + +**Related Findings:** +- status-mismatch: PATCH /payments/:paymentId sets status='delivery' on payment confirmation, multiple paths to 'delivery' status + +#### DELIVERY-014 — getDeliveryAttempts and getDeliveryStats return 404 with no visible error in UI + +**Priority:** P2 + +**Steps:** +1. Call GET /api/marketplace/purchase-requests/:id/delivery-code/attempts +2. Call GET /api/delivery/stats +3. Load any UI page that might invoke these actions +4. Observe whether errors are surfaced to the user + +**Expected Result:** Both endpoints return 404. Any UI calling them displays no data but also no unhandled error (errors should be caught silently). No production page should visibly depend on these endpoints. + +**Related Findings:** +- no-backend: /delivery-code/attempts and /delivery/stats have no backend handlers + +#### DELIVERY-015 — Both delivery paths (code verification and fast-track confirm) independently transition to 'delivered' + +**Priority:** P1 + +**Steps:** +1. Path A: advance to 'delivery', buyer generates code, seller verifies code → observe status='delivered' +2. Create a fresh purchase request and advance to 'delivery' again +3. Path B: advance to 'delivery', buyer calls PATCH /confirm-delivery directly → observe status='delivered' +4. Verify neither path blocks the other on separate requests + +**Expected Result:** Both paths independently transition status to 'delivered'. Path A triggers delivery-specific notifications. Path B does not trigger delivery-specific notifications (only status-changed event). Neither path is blocked when the other was already completed on a separate request. + +**Related Findings:** +- status-mismatch: two distinct paths to 'delivered' conflated in documentation + +#### DELIVERY-016 — Final-approval dummy payment backdoor is disabled or guarded in production + +**Priority:** P0 + +**Steps:** +1. Create a purchase request in 'delivered' status with NO linked payment document +2. Call POST /api/marketplace/purchase-requests/:id/final-approval +3. Inspect the MongoDB payments collection for a newly created dummy document +4. Inspect the payment's metadata for createdForFinalApproval=true + +**Expected Result:** In production configuration, the dummy payment creation backdoor should be disabled or guarded. If it is active, the endpoint creates a dummy payment with metadata.createdForFinalApproval=true and proceeds to final approval without a real escrow transaction. Document whether this backdoor is active in the current environment. + +**Related Findings:** +- info: POST /final-approval creates dummy payment for testing if no real payment exists — undocumented backdoor + +#### DELIVERY-017 — Delivery step components use axiosInstance directly rather than actions layer — verify correct endpoint usage + +**Priority:** P2 + +**Steps:** +1. Navigate to the buyer's step-5-receive-goods component (visible when request is in 'delivery' status as a buyer) +2. Observe the network request triggered when the code is displayed or generated +3. Navigate to the seller's delivery-code-verification component +4. Observe the network request when the seller submits a code + +**Expected Result:** step-5-receive-goods triggers POST /delivery-code/generate via direct axiosInstance call (not via delivery.ts actions layer). delivery-code-verification triggers POST /delivery-code/verify as the seller. Both use the correct endpoints. The src/actions/delivery.ts actions file is not called. + +**Related Findings:** +- no-frontend: all six delivery-code actions have no dashboard page; step components use axiosInstance directly + +#### DELIVERY-018 — Buyer never confirms delivery — status stays 'delivery' indefinitely with no auto-release + +**Priority:** P2 + +**Steps:** +1. Advance a purchase request to 'delivery' status +2. Do not call verify-delivery or confirm-delivery +3. Wait for the documented auto-release grace period (48h) +4. Check the purchase request status + +**Expected Result:** Status remains 'delivery' indefinitely. The auto-release timer to 'confirming' is not implemented. Admin intervention is required. Document this gap for the product team. + +**Related Findings:** +- Delivery Confirmation edge case: buyer never confirms → status stays delivery indefinitely; auto-release not built + +#### DELIVERY-019 — Regeneration after generateDeliveryCode failure leaves request with no valid code + +**Priority:** P2 + +**Steps:** +1. Generate an initial delivery code +2. Mock/force a DB failure during the second generateDeliveryCode call within regenerateDeliveryCode +3. Observe the state of deliveryInfo after the failed regeneration attempt +4. Attempt to verify with the original code + +**Expected Result:** The first step of regeneration sets deliveryCodeUsed=true. If the second step fails, the request is left with a used=true code and no new valid code. The original code is rejected ('Code already used'). No valid code exists until the next successful generate call. + +**Related Findings:** +- flow-incomplete: regenerateDeliveryCode has no transaction rollback; failure leaves request with no valid code + +--- + +### Payments (DePay, SHKeeper, Request Network) + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| PAYMENT-001 | DePay: Intent creation endpoint is /save not /create | P0 | Buyer is authenticated, valid purchaseRequestId and sellerOfferId exist, wall... | +| PAYMENT-002 | DePay: Verify endpoint uses :paymentId as path parameter | P0 | A payment intent has been created and a transactionHash is available after on... | +| PAYMENT-003 | DePay: Full happy path — wallet connect, chain switch, approve, transfer, verify | P0 | Buyer has BSC wallet with sufficient USDT and BNB for gas, valid purchase req... | +| PAYMENT-004 | DePay: User refuses chain switch — payment must not proceed | P1 | Buyer wallet is connected to a non-BSC network | +| PAYMENT-005 | DePay: Transaction reverted on-chain — backend sets status=failed | P1 | A payment intent exists (status=pending); a BSC transaction hash for a revert... | +| PAYMENT-006 | DePay: Transaction not yet mined at verification time — backend returns pending | P1 | A payment intent exists; a transaction has been broadcast but not yet include... | +| PAYMENT-007 | DePay: Duplicate transactionHash rejected — sparse index prevents double-spend | P1 | A transaction hash has already been used to verify and complete a payment | +| PAYMENT-008 | DePay: SIM_ hash bypass — simulated tx must not complete payment in staging | P0 | Staging environment is running; a payment intent exists; ability to simulate ... | +| PAYMENT-009 | DePay: Insufficient BNB for gas — wallet rejects, no payment record created | P2 | Buyer wallet has USDT but zero BNB | +| PAYMENT-010 | DePay: sellerOfferId absence from /save body — offer association verification | P1 | DePay checkout flow is functional | +| PAYMENT-011 | DePay: createDePayIntent() action — /payment/depay/intents must not be called in any live flow | P1 | Application is running; browser devtools Network tab open | +| PAYMENT-012 | DePay: Debug endpoint accessible without authentication | P0 | A valid paymentId exists in the database | +| PAYMENT-013 | DePay: auto-fetch-missing endpoint accessible without authentication | P0 | Backend is running | +| PAYMENT-014 | DePay: fetch-tx rechecker uses POST method not GET | P1 | A payment record with a missing transactionHash exists | +| PAYMENT-015 | DePay: payment-received socket event delivered to seller dashboard | P1 | Seller is logged into their dashboard with an active socket connection; a DeP... | +| PAYMENT-016 | SHKeeper: Full happy path — create intent, display QR, receive webhook, cascade to funded | P0 | SHKeeper gateway (pay.amn.gg) is reachable, valid purchaseRequestId and selle... | +| PAYMENT-017 | SHKeeper: Intent creation endpoint — /shkeeper/create vs /shkeeper/intents | P1 | SHKeeper checkout flow is accessible | +| PAYMENT-018 | SHKeeper: Duplicate intent submission reuses existing pending payment — no new wallet allocated | P1 | An active pending Payment already exists for the same purchaseRequestId, sell... | +| PAYMENT-019 | SHKeeper: Webhook HMAC signature validation — invalid signature returns 401 | P0 | Backend production mode or HMAC validation is enabled | +| PAYMENT-020 | SHKeeper: Webhook with missing signature and missing API key returns 202 without processing | P1 | Backend is running | +| PAYMENT-021 | SHKeeper: Duplicate webhook within 10 seconds with identical data is idempotent | P1 | A PAID webhook has been received and processed successfully | +| PAYMENT-022 | SHKeeper: OVERPAID webhook — payment completes, no automatic refund of overage | P1 | A SHKeeper invoice exists | +| PAYMENT-023 | SHKeeper: PARTIAL payment — state held as pending/partial, buyer can top up | P2 | Buyer has sent less than the required amount | +| PAYMENT-024 | SHKeeper: EXPIRED webhook — payment becomes failed/cancelled, buyer can re-initiate | P1 | A pending SHKeeper payment exists that has expired | +| PAYMENT-025 | SHKeeper: Status polling endpoint does not exist — UI transitions via socket only | P0 | SHKeeper checkout is in progress | +| PAYMENT-026 | SHKeeper: payment-created socket event on intent creation — admin dashboard real-time visibility | P1 | Admin dashboard is open with socket connection established | +| PAYMENT-027 | SHKeeper: SHKeeper API unreachable — circuit breaker response and buyer experience | P1 | SHKeeper gateway (pay.amn.gg) is unreachable (simulate by blocking outbound r... | +| PAYMENT-028 | SHKeeper: Wallet address reuse for concurrent identical intents | P2 | Two separate buyer sessions attempting to pay for the same purchaseRequestId,... | +| PAYMENT-029 | SHKeeper: DB disconnection during webhook — 202 returned, no data loss | P2 | Ability to simulate MongoDB disconnection (e.g., stop MongoDB service briefly) | +| PAYMENT-030 | SHKeeper: PaymentCoordinator concurrent update deferral | P2 | Two identical webhook payloads can be sent in rapid succession with different... | +| PAYMENT-031 | Payment stats: 'completed' status not counted as successfulPayments | P1 | At least one SHKeeper payment has been completed (status=completed) and at le... | +| PAYMENT-032 | Payment stats: privilege gap between /api/payment/stats and /api/payment/payments/stats | P0 | A buyer-role JWT token is available; an admin-role JWT token is available | +| PAYMENT-033 | Payment export: non-admin buyer can access /api/payment/export | P0 | A buyer-role JWT token is available | +| PAYMENT-034 | PaymentProvider type mismatch: shkeeper and decentralized payments render correctly in UI | P1 | At least one completed SHKeeper payment and one completed DePay payment exist... | +| PAYMENT-035 | createProviderPaymentIntent: provider=shkeeper routes to correct endpoint | P1 | Any UI component that calls createProviderPaymentIntent with provider='shkeep... | +| PAYMENT-036 | Dispute panel: 'Verify' button calls non-existent /payment/:id/status and returns 404 | P0 | A dispute exists with an associated payment; user is on the dispute payment d... | +| PAYMENT-037 | cancelPayment() action must not be called from any live UI component | P0 | Application is running with devtools open | +| PAYMENT-038 | Request Network payout/release/refund actions return 404 | P0 | Admin is authenticated; at least one Request Network payment exists in a rele... | +| PAYMENT-039 | Stub endpoints return 404 and do not surface broken UI states | P1 | Buyer is authenticated and on the dashboard | +| PAYMENT-040 | escrowState releasable and releasing render correctly in payment detail view | P2 | Ability to set a Payment document's escrowState to 'releasable' and 'releasin... | +| PAYMENT-041 | PurchaseRequest pending_payment status renders correctly on buyer dashboard | P2 | Ability to set a PurchaseRequest to status=pending_payment (via direct DB upd... | +| PAYMENT-042 | payout-completed socket event: seller receives no real-time notification after admin payout | P1 | Seller is logged into their dashboard with active socket connection; admin is... | +| PAYMENT-043 | Webhook response is HTTP 202 not 200 for SHKeeper | P2 | SHKeeper webhook endpoint is accessible | +| PAYMENT-044 | Derived destinations sweep does not interfere with in-flight SHKeeper payments | P2 | DERIVED_DESTINATION_SWEEP_AUTOSTART=true is set; a SHKeeper payment is in pen... | +| PAYMENT-045 | DePay: 1-confirmation threshold is insufficient for large payments | P2 | Backend is running with current 1-confirmation default | +| PAYMENT-046 | DePay: Transfer event log not validated — incorrect recipient or amount could be accepted | P1 | A BSC transaction that has status=0x1 (success) but transfers tokens to a dif... | +| PAYMENT-047 | SHKeeper: walletMonitor fallback completes payment when webhook is lost | P2 | Ability to suppress SHKeeper webhook delivery (block the callback URL or use ... | +| PAYMENT-048 | SHKeeper: simpleAutoWebhook poll-based fallback fires before real webhook | P3 | simpleAutoWebhook polling is enabled; a payment is in pending state | +| PAYMENT-049 | Browser closed before DePay verification — manual reconciliation via fetch-tx endpoint | P2 | Buyer has signed and broadcast the on-chain transfer but closed the browser b... | +| PAYMENT-050 | SHKeeper: external_id not found in DB — orphaned webhook handled gracefully | P3 | Backend is running | + +#### PAYMENT-001 — DePay: Intent creation endpoint is /save not /create + +**Priority:** P0 + +**Preconditions:** Buyer is authenticated, valid purchaseRequestId and sellerOfferId exist, wallet is connected to BSC (chainId 56) + +**Steps:** +1. Open browser devtools Network tab +2. Navigate to checkout step 3 and select the DePay/Web3 payment option +3. Click 'Pay with wallet' +4. Observe the outgoing POST request URL + +**Expected Result:** The request is sent to POST /api/payment/decentralized/save. No request is made to /api/payment/decentralized/create. A 404 must not occur. + +**Related Findings:** +- DePay flow: /api/payment/decentralized/create does not exist; only /save is implemented + +#### PAYMENT-002 — DePay: Verify endpoint uses :paymentId as path parameter + +**Priority:** P0 + +**Preconditions:** A payment intent has been created and a transactionHash is available after on-chain transfer + +**Steps:** +1. Open browser devtools Network tab +2. Complete the on-chain USDT transfer via MetaMask +3. Wait for useWaitForTransactionReceipt to resolve +4. Observe the outgoing POST request for verification + +**Expected Result:** The verification call is POST /api/payment/decentralized/verify/{paymentId} with the paymentId embedded in the URL path and transactionHash in the request body. No request is made to /api/payment/decentralized/verify without a path param. + +**Related Findings:** +- DePay verify path mismatch: step narrative says :paymentId path param; API table says no path param + +#### PAYMENT-003 — DePay: Full happy path — wallet connect, chain switch, approve, transfer, verify + +**Priority:** P0 + +**Preconditions:** Buyer has BSC wallet with sufficient USDT and BNB for gas, valid purchase request with accepted seller offer exists + +**Steps:** +1. Navigate to /dashboard/buyer/requests/{id}, step 3 +2. Click 'Pay with wallet'; WalletConnect modal opens +3. Connect MetaMask wallet currently on Ethereum mainnet (chainId 1) +4. Confirm the chain-switch prompt to BSC (chainId 56) +5. Observe the allowance check — if allowance < amount, approve transaction prompt appears; confirm approve in wallet +6. Wait for approval transaction confirmation +7. Confirm the USDT transfer transaction in wallet +8. Wait for useWaitForTransactionReceipt to return success +9. Observe POST /api/payment/decentralized/verify/:paymentId request +10. Observe response and UI transition + +**Expected Result:** Payment record created with status=pending, then updated to status=completed and escrowState=funded. UI shows 'Payment verified' with a BscScan link. PurchaseRequest transitions to status=payment. Winning SellerOffer status becomes accepted. Losing offers become rejected. Chat is created. Buyer and seller receive notifications. + +#### PAYMENT-004 — DePay: User refuses chain switch — payment must not proceed + +**Priority:** P1 + +**Preconditions:** Buyer wallet is connected to a non-BSC network + +**Steps:** +1. Click 'Pay with wallet' +2. When prompted to switch to BSC, click 'Cancel' or 'Reject' in the wallet popup +3. Observe UI state + +**Expected Result:** UI displays an error indicating BSC is required. No POST to /api/payment/decentralized/save is made. No payment record is created in MongoDB. + +#### PAYMENT-005 — DePay: Transaction reverted on-chain — backend sets status=failed + +**Priority:** P1 + +**Preconditions:** A payment intent exists (status=pending); a BSC transaction hash for a reverted tx (receipt.status === '0x0') is available + +**Steps:** +1. POST /api/payment/decentralized/verify/:paymentId with a known-reverted transactionHash +2. Check the Payment document in MongoDB +3. Observe the API response + +**Expected Result:** API response indicates failure. Payment.status is set to failed. escrowState remains unchanged. No cascade (offer acceptance, chat creation) is triggered. Buyer can retry with a new transaction. + +#### PAYMENT-006 — DePay: Transaction not yet mined at verification time — backend returns pending + +**Priority:** P1 + +**Preconditions:** A payment intent exists; a transaction has been broadcast but not yet included in a block + +**Steps:** +1. Immediately after broadcast (before any block confirmation), POST /api/payment/decentralized/verify/:paymentId with the transactionHash +2. Observe the response body and HTTP status +3. Wait for the block to be mined and retry verification + +**Expected Result:** First call returns status=pending with a message such as 'Transaction not found or still pending'. HTTP 200 or 202 is returned (not 500). Payment.status remains pending. Second call after mining returns status=confirmed and triggers the funded cascade. + +#### PAYMENT-007 — DePay: Duplicate transactionHash rejected — sparse index prevents double-spend + +**Priority:** P1 + +**Preconditions:** A transaction hash has already been used to verify and complete a payment + +**Steps:** +1. Create a second payment intent for a different purchaseRequestId +2. POST /api/payment/decentralized/verify/:newPaymentId with the previously used transactionHash +3. Inspect the response and the new Payment document + +**Expected Result:** The backend detects the duplicate transactionHash (sparse unique index) and returns the existing payment status rather than creating a new completed payment. The second purchase request is not funded. + +#### PAYMENT-008 — DePay: SIM_ hash bypass — simulated tx must not complete payment in staging + +**Priority:** P0 + +**Preconditions:** Staging environment is running; a payment intent exists; ability to simulate a wallet connection failure (disconnect wallet during payment, or mock the connection error) + +**Steps:** +1. Initiate a DePay payment but cause wallet connection to fail mid-flow (e.g., forcibly disconnect MetaMask) +2. Observe whether the frontend generates a SIM_-prefixed transaction hash in the error fallback path +3. If a SIM_ hash is generated, check whether it is submitted to the backend verify endpoint +4. If submitted, check the resulting Payment document status in MongoDB + +**Expected Result:** A SIM_-prefixed hash must NOT result in a Payment record with status=completed in staging or production. The backend should reject or flag SIM_ hashes as invalid. If the current code accepts them, this is a critical security defect requiring an environment guard before launch. + +**Related Findings:** +- Simulated transaction bypass (SIM_ prefix) is active in production frontend code with no environment guard + +#### PAYMENT-009 — DePay: Insufficient BNB for gas — wallet rejects, no payment record created + +**Priority:** P2 + +**Preconditions:** Buyer wallet has USDT but zero BNB + +**Steps:** +1. Connect wallet on BSC +2. Attempt transfer; wallet displays insufficient gas error +3. Observe UI and backend state + +**Expected Result:** Wallet popup shows a gas estimation error or rejects the transaction. No transaction is broadcast. No verify call is made. Payment intent may remain with status=pending. UI shows an actionable error message. + +#### PAYMENT-010 — DePay: sellerOfferId absence from /save body — offer association verification + +**Priority:** P1 + +**Preconditions:** DePay checkout flow is functional + +**Steps:** +1. Open devtools Network tab and intercept the POST /api/payment/decentralized/save request +2. Inspect the full request body payload +3. After payment completes, check the Payment document in MongoDB for the associated offer ID +4. Verify the winning offer is correctly marked accepted in the cascade + +**Expected Result:** The request body does not include sellerOfferId (per API schema). The offer association is established via another mechanism (e.g., purchaseRequestId lookup). The post-verification cascade correctly identifies and accepts the winning seller offer. + +**Related Findings:** +- DePay flow references non-existent 'sellerOfferId' field in decentralized/save body + +#### PAYMENT-011 — DePay: createDePayIntent() action — /payment/depay/intents must not be called in any live flow + +**Priority:** P1 + +**Preconditions:** Application is running; browser devtools Network tab open + +**Steps:** +1. Perform a complete DePay checkout from offer selection through payment verification +2. Search Network tab for any request to /payment/depay/intents +3. Search the frontend bundle or source for live component calls to createDePayIntent() + +**Expected Result:** No request to /payment/depay/intents is observed. The action createDePayIntent() is not invoked by any production UI component. If a request is made, it returns 404. + +**Related Findings:** +- Frontend defines createDePayIntent calling /payment/depay/intents — no such backend route + +#### PAYMENT-012 — DePay: Debug endpoint accessible without authentication + +**Priority:** P0 + +**Preconditions:** A valid paymentId exists in the database + +**Steps:** +1. Send GET /api/payment/payments/{paymentId}/debug with no Authorization header +2. Observe HTTP status code and response body + +**Expected Result:** The endpoint should return 401 Unauthorized when no auth token is provided. If it returns 200 with full payment data, this is a data exposure vulnerability — the auth middleware is missing and must be added before production launch. + +**Related Findings:** +- API docs list auth for /api/payment/payments/:id/debug as 'Bearer JWT' — backend has NO auth middleware + +#### PAYMENT-013 — DePay: auto-fetch-missing endpoint accessible without authentication + +**Priority:** P0 + +**Preconditions:** Backend is running + +**Steps:** +1. Send POST /api/payment/payments/auto-fetch-missing with no Authorization header and an empty JSON body +2. Observe HTTP status code and whether batch blockchain lookups are triggered + +**Expected Result:** The endpoint should return 401 Unauthorized. If it processes the request without a token, this is an unauthenticated state-mutation endpoint that must be protected before launch. + +**Related Findings:** +- API docs list auth for POST /api/payment/payments/auto-fetch-missing as 'Bearer JWT' — backend has NO auth + +#### PAYMENT-014 — DePay: fetch-tx rechecker uses POST method not GET + +**Priority:** P1 + +**Preconditions:** A payment record with a missing transactionHash exists + +**Steps:** +1. Send GET /api/payment/fetch-tx/{paymentId} (as documented in flow) +2. Send POST /api/payment/payments/{paymentId}/fetch-tx (as implemented) +3. Observe responses for both + +**Expected Result:** GET /api/payment/fetch-tx/{paymentId} returns 404. POST /api/payment/payments/{paymentId}/fetch-tx returns 200 and triggers the blockchain lookup. QA tooling and runbooks must reference the POST path. + +**Related Findings:** +- API docs say GET /api/payment/fetch-tx/:paymentId; backend is POST /api/payment/payments/:id/fetch-tx + +#### PAYMENT-015 — DePay: payment-received socket event delivered to seller dashboard + +**Priority:** P1 + +**Preconditions:** Seller is logged into their dashboard with an active socket connection; a DePay payment is in progress + +**Steps:** +1. Open seller dashboard in one browser tab +2. In another tab (buyer), complete the DePay payment verification step +3. Observe the seller tab for any real-time notification or UI update + +**Expected Result:** Ideally the seller receives a real-time 'payment received' notification via the payment-received socket event. If no frontend listener exists, the seller sees no update and must refresh — this is a known gap. Document which behavior occurs. + +**Related Findings:** +- payment-received socket event emitted by Web3 verify — no frontend listener found + +#### PAYMENT-016 — SHKeeper: Full happy path — create intent, display QR, receive webhook, cascade to funded + +**Priority:** P0 + +**Preconditions:** SHKeeper gateway (pay.amn.gg) is reachable, valid purchaseRequestId and sellerOfferId exist, buyer is authenticated + +**Steps:** +1. Navigate to checkout step 3, select SHKeeper/crypto payment method +2. POST /api/payment/shkeeper/create (or observe the actual network call) with purchaseRequestId, sellerOfferId, amount +3. Verify the response contains walletAddress, shkeeperInvoiceId, amount, exchangeRate +4. Observe the QR code rendered for the wallet address +5. Simulate buyer sending the exact USDT amount on-chain to the displayed wallet address +6. SHKeeper detects the deposit and POSTs webhook to /api/payment/shkeeper/webhook with status PAID +7. Observe the webhook response (expect 202) +8. Check Payment.status in MongoDB +9. Check SellerOffer statuses +10. Check PurchaseRequest.status +11. Verify buyer and seller receive notifications +12. Verify seller-offer-update socket event with payload payment-completed is received on seller dashboard + +**Expected Result:** Payment transitions to status=completed, escrowState=funded. Winning SellerOffer becomes accepted. All other offers become rejected. PurchaseRequest becomes status=payment. Chat is created. Both parties notified. Socket events delivered. Webhook acknowledged with 202. + +#### PAYMENT-017 — SHKeeper: Intent creation endpoint — /shkeeper/create vs /shkeeper/intents + +**Priority:** P1 + +**Preconditions:** SHKeeper checkout flow is accessible + +**Steps:** +1. Open browser devtools Network tab +2. Navigate to SHKeeper checkout and initiate payment +3. Observe the exact URL of the POST request for intent creation + +**Expected Result:** Identify definitively whether the frontend calls /api/payment/shkeeper/create or /api/payment/shkeeper/intents. The backend implements /shkeeper/intents as the primary endpoint. Document which path is actually used, and confirm a 404 does not occur on the chosen path. + +**Related Findings:** +- SHKeeper flow references POST /api/payment/shkeeper/create — actual implemented intent path is /shkeeper/intents + +#### PAYMENT-018 — SHKeeper: Duplicate intent submission reuses existing pending payment — no new wallet allocated + +**Priority:** P1 + +**Preconditions:** An active pending Payment already exists for the same purchaseRequestId, sellerOfferId, and buyerId + +**Steps:** +1. Submit a second POST /api/payment/shkeeper/create (or /intents) with identical purchaseRequestId, sellerOfferId, and amount +2. Compare the returned paymentId and walletAddress with the first intent +3. Check MongoDB for duplicate Payment documents + +**Expected Result:** The second call returns the same paymentId and walletAddress as the first. No new Payment document is created. No new SHKeeper API call is made. Only one wallet allocation exists. + +#### PAYMENT-019 — SHKeeper: Webhook HMAC signature validation — invalid signature returns 401 + +**Priority:** P0 + +**Preconditions:** Backend production mode or HMAC validation is enabled + +**Steps:** +1. Craft a POST /api/payment/shkeeper/webhook request with a valid payload but a tampered or incorrect x-shkeeper-signature header +2. Send the request and observe the HTTP response + +**Expected Result:** Backend returns 401 Unauthorized. No payment state is updated. Payment record remains in its current status. + +#### PAYMENT-020 — SHKeeper: Webhook with missing signature and missing API key returns 202 without processing + +**Priority:** P1 + +**Preconditions:** Backend is running + +**Steps:** +1. POST /api/payment/shkeeper/webhook with a valid payload but no x-shkeeper-signature and no X-Shkeeper-Api-Key header +2. Observe response code +3. Check whether payment state was modified + +**Expected Result:** Backend returns 202 Accepted without processing the webhook. Payment state is not changed. This is the documented no-retry-storm behavior. + +#### PAYMENT-021 — SHKeeper: Duplicate webhook within 10 seconds with identical data is idempotent + +**Priority:** P1 + +**Preconditions:** A PAID webhook has been received and processed successfully + +**Steps:** +1. Resend the exact same webhook payload (same status, balance_fiat, paid, external_id) within 10 seconds +2. Observe the response +3. Check whether payment processing cascade ran twice + +**Expected Result:** Second webhook returns 202 immediately without re-running the cascade. No duplicate offer-acceptance or duplicate notifications are sent. + +#### PAYMENT-022 — SHKeeper: OVERPAID webhook — payment completes, no automatic refund of overage + +**Priority:** P1 + +**Preconditions:** A SHKeeper invoice exists + +**Steps:** +1. POST /api/payment/shkeeper/webhook with status=OVERPAID for an existing Payment +2. Observe Payment.status and Payment.escrowState +3. Check whether any refund action is initiated + +**Expected Result:** Payment transitions to status=completed, escrowState=funded — identical to PAID. No automatic refund is triggered. The overage amount is retained by the platform. Admin dashboard should show the overpaid amount for manual review. + +#### PAYMENT-023 — SHKeeper: PARTIAL payment — state held as pending/partial, buyer can top up + +**Priority:** P2 + +**Preconditions:** Buyer has sent less than the required amount + +**Steps:** +1. POST /api/payment/shkeeper/webhook with status=PARTIAL +2. Observe Payment.status and Payment.escrowState +3. Observe whether the checkout UI remains open for additional payment +4. Send a second top-up transfer to bring total to the required amount +5. Observe final PAID webhook processing + +**Expected Result:** After PARTIAL webhook: Payment.status=pending, Payment.escrowState=partial. Checkout page remains active. After final PAID webhook: Payment completes normally. + +#### PAYMENT-024 — SHKeeper: EXPIRED webhook — payment becomes failed/cancelled, buyer can re-initiate + +**Priority:** P1 + +**Preconditions:** A pending SHKeeper payment exists that has expired + +**Steps:** +1. POST /api/payment/shkeeper/webhook with status=EXPIRED +2. Observe Payment.status and Payment.escrowState +3. Attempt to create a new payment intent for the same purchaseRequestId and sellerOfferId +4. Verify the duplicate-guard creates a fresh intent (not reusing expired one) + +**Expected Result:** Payment becomes status=failed, escrowState=cancelled. A new intent can be created since the old one is no longer pending. New wallet is allocated. + +#### PAYMENT-025 — SHKeeper: Status polling endpoint does not exist — UI transitions via socket only + +**Priority:** P0 + +**Preconditions:** SHKeeper checkout is in progress + +**Steps:** +1. Open browser devtools Network tab +2. Complete a SHKeeper payment from QR display through webhook receipt +3. Search for any GET /api/payment/shkeeper/status/{paymentId} request +4. Observe the mechanism by which the checkout UI transitions to 'Payment received' + +**Expected Result:** No GET /api/payment/shkeeper/status/:paymentId request is made (endpoint does not exist). The UI transitions solely via socket event reception (payment-update event). If a polling call is observed, it will return 404. + +**Related Findings:** +- SHKeeper flow documents GET /api/payment/shkeeper/status/:paymentId — endpoint does not exist +- SHKeeper flow step 32: checkout page polls GET /api/payment/shkeeper/status/:paymentId — this endpoint is absent from the entire codebase + +#### PAYMENT-026 — SHKeeper: payment-created socket event on intent creation — admin dashboard real-time visibility + +**Priority:** P1 + +**Preconditions:** Admin dashboard is open with socket connection established + +**Steps:** +1. Open admin dashboard payments view +2. In a separate browser session, create a SHKeeper payment intent as a buyer +3. Observe admin dashboard for real-time appearance of the new pending payment + +**Expected Result:** If payment-created is emitted by the SHKeeper create handler, the new payment appears on the admin dashboard immediately. If not emitted (per backend socket docs which only attribute it to admin-payout and Request Network), the admin must refresh to see the new payment. Document actual behavior. + +**Related Findings:** +- SHKeeper flow documents 'payment-created' as emitted on intent creation — backend only emits it after admin-payout and Request Network pay-in + +#### PAYMENT-027 — SHKeeper: SHKeeper API unreachable — circuit breaker response and buyer experience + +**Priority:** P1 + +**Preconditions:** SHKeeper gateway (pay.amn.gg) is unreachable (simulate by blocking outbound requests or using an invalid API key) + +**Steps:** +1. Initiate SHKeeper checkout +2. Observe the frontend response when the backend cannot reach SHKeeper +3. Check whether a demo fallback URL is returned to the frontend +4. Inspect the Sentry error log + +**Expected Result:** Backend gracefully handles the SHKeeper API failure. The buyer sees a meaningful error message (not a 500 crash). If a demo fallback URL is returned, it must be clearly identified as non-functional. A Sentry error should be logged. + +#### PAYMENT-028 — SHKeeper: Wallet address reuse for concurrent identical intents + +**Priority:** P2 + +**Preconditions:** Two separate buyer sessions attempting to pay for the same purchaseRequestId, same amount, same token, same network simultaneously + +**Steps:** +1. Simultaneously create two SHKeeper intents from two different buyer accounts with identical amount/token/network/requestId +2. Compare the wallet addresses returned to each session +3. Observe which session is associated with the cached wallet + +**Expected Result:** Both sessions receive the same wallet address (cache hit). The duplicate-guard ensures only one Payment document exists. The first payer's transaction triggers the cascade. The second transaction is excess and not automatically refunded. + +#### PAYMENT-029 — SHKeeper: DB disconnection during webhook — 202 returned, no data loss + +**Priority:** P2 + +**Preconditions:** Ability to simulate MongoDB disconnection (e.g., stop MongoDB service briefly) + +**Steps:** +1. Disconnect MongoDB +2. POST /api/payment/shkeeper/webhook with a PAID payload +3. Observe HTTP response code +4. Reconnect MongoDB +5. Check whether the payment was processed or needs reconciliation + +**Expected Result:** Backend returns 202 Accepted (SHKeeper does not retry). Payment state is NOT updated. The webhook is effectively lost (no DLQ). Admin must manually reconcile via the fetch-tx endpoint or by replaying the webhook. + +#### PAYMENT-030 — SHKeeper: PaymentCoordinator concurrent update deferral + +**Priority:** P2 + +**Preconditions:** Two identical webhook payloads can be sent in rapid succession with different timing + +**Steps:** +1. Send two PAID webhooks for the same payment with a very small time gap (< 1 second) +2. Observe responses for both +3. Check final Payment state in MongoDB + +**Expected Result:** One webhook processes normally. The second is deferred by PaymentCoordinator and returns 202 with 'coordinator skipped update'. Final payment state reflects a single completed update. No duplicate cascades occur. + +#### PAYMENT-031 — Payment stats: 'completed' status not counted as successfulPayments + +**Priority:** P1 + +**Preconditions:** At least one SHKeeper payment has been completed (status=completed) and at least one payment has status=confirmed + +**Steps:** +1. GET /api/payment/stats (or /api/payment/payments/stats depending on admin role) +2. Note the successfulPayments count +3. Count Payment documents with status=completed in MongoDB directly +4. Count Payment documents with status=confirmed in MongoDB directly +5. Compare all three values + +**Expected Result:** The successfulPayments figure in the stats response equals the count of confirmed payments only. Completed payments are excluded. Admin dashboards showing this metric may undercount successful transactions — document the discrepancy for business stakeholders. + +**Related Findings:** +- 'completed' status is not counted as successful in payment stats aggregate — only 'confirmed' is + +#### PAYMENT-032 — Payment stats: privilege gap between /api/payment/stats and /api/payment/payments/stats + +**Priority:** P0 + +**Preconditions:** A buyer-role JWT token is available; an admin-role JWT token is available + +**Steps:** +1. GET /api/payment/stats with a buyer JWT — observe status code and response body +2. GET /api/payment/payments/stats with a buyer JWT — observe status code +3. GET /api/payment/payments/stats with an admin JWT — observe status code and response body + +**Expected Result:** GET /api/payment/payments/stats with buyer JWT returns 403 Forbidden (admin-only route). GET /api/payment/stats with buyer JWT — if it returns 200 and exposes aggregated stats, this is a privilege gap that must be resolved. + +**Related Findings:** +- API docs path prefix mismatch: /api/payment/stats vs /api/payment/payments/stats + +#### PAYMENT-033 — Payment export: non-admin buyer can access /api/payment/export + +**Priority:** P0 + +**Preconditions:** A buyer-role JWT token is available + +**Steps:** +1. GET /api/payment/export with a buyer JWT +2. Observe HTTP status code and whether payment data is returned +3. GET /api/payment/payments/export with a buyer JWT +4. Compare responses + +**Expected Result:** GET /api/payment/payments/export with buyer JWT returns 403 (admin-gated). GET /api/payment/export with buyer JWT — if it returns 200 with payment data for all users, this is a privilege escalation vulnerability requiring an admin guard to be added to the controller-pattern route. + +**Related Findings:** +- API docs path prefix mismatch: /api/payment/export vs /api/payment/payments/export + +#### PAYMENT-034 — PaymentProvider type mismatch: shkeeper and decentralized payments render correctly in UI + +**Priority:** P1 + +**Preconditions:** At least one completed SHKeeper payment and one completed DePay payment exist in the database + +**Steps:** +1. Log in as admin and navigate to the payments list view +2. Locate a SHKeeper payment (provider=shkeeper) and a DePay payment (provider=decentralized or other) +3. Inspect the provider label, payment type badge, and any provider-specific action buttons for each +4. Check for any 'unknown' or blank provider labels +5. Navigate to the payment detail view for each + +**Expected Result:** Both shkeeper and decentralized payments display correct labels and all UI elements. No TypeScript runtime errors occur from unhandled PaymentProvider switch cases. Provider-based conditional rendering does not fall through to a default/unknown state. + +**Related Findings:** +- PaymentProvider type in frontend excludes 'shkeeper' and 'decentralized' — only 'request.network', 'test', 'other' + +#### PAYMENT-035 — createProviderPaymentIntent: provider=shkeeper routes to correct endpoint + +**Priority:** P1 + +**Preconditions:** Any UI component that calls createProviderPaymentIntent with provider='shkeeper' is accessible + +**Steps:** +1. Open browser devtools Network tab +2. Trigger the SHKeeper checkout path that goes through createProviderPaymentIntent +3. Observe the outgoing POST request URL + +**Expected Result:** The request is sent to /api/payment/shkeeper/intents (or /shkeeper/create, whichever is the correct backend path). The request must NOT go to /api/payment/request-network/intents. If it does, the routing bug in getProviderIntentEndpoint() is confirmed and must be fixed. + +**Related Findings:** +- createProviderPaymentIntent always routes to request-network/intents regardless of provider argument + +#### PAYMENT-036 — Dispute panel: 'Verify' button calls non-existent /payment/:id/status and returns 404 + +**Priority:** P0 + +**Preconditions:** A dispute exists with an associated payment; user is on the dispute payment details card + +**Steps:** +1. Navigate to a dispute case in the admin or buyer/seller dashboard +2. Open the payment details card component +3. Open browser devtools Network tab +4. Click the 'Verify' button on the payment details card +5. Observe the outgoing HTTP request URL and response + +**Expected Result:** Ideally, the verify action calls a valid payment status endpoint. Per the known finding, getPaymentStatus() calls /payment/{id}/status which does not exist — expect a 404. This must be identified as a broken feature requiring the endpoint to be implemented or the action to be updated to call an existing endpoint (e.g., GET /api/payment/:id). + +**Related Findings:** +- Frontend calls GET /payment/:id/status and POST /payment/:id/confirm — neither endpoint exists on backend + +#### PAYMENT-037 — cancelPayment() action must not be called from any live UI component + +**Priority:** P0 + +**Preconditions:** Application is running with devtools open + +**Steps:** +1. Perform common user flows: checkout cancellation, navigating away from checkout, closing payment modal +2. Search Network tab for any DELETE request to /api/payment/{id} +3. Search frontend source for components that import and call cancelPayment from actions/payment.ts + +**Expected Result:** No DELETE /api/payment/{id} request is observed in normal flows. The action-layer cancelPayment is not called from any live component. If called, it returns 404. The local web3-provider state reset (not the HTTP action) is the only cancel mechanism in use. + +**Related Findings:** +- Frontend calls DELETE /payment/:id to cancel payment — no DELETE route exists + +#### PAYMENT-038 — Request Network payout/release/refund actions return 404 + +**Priority:** P0 + +**Preconditions:** Admin is authenticated; at least one Request Network payment exists in a releasable state + +**Steps:** +1. As admin, navigate to the payment management panel for a Request Network payment +2. Attempt to initiate a payout via the admin UI +3. Observe the network request to /api/payment/request-network/:id/payout/initiate +4. Attempt release and refund operations similarly + +**Expected Result:** All four endpoints (/payout/initiate, /payout/confirm, /release/confirm, /refund/confirm) return 404. Admin payout, release, and refund for Request Network payments are currently non-functional. This is a critical gap that must be resolved before these admin operations can be used. + +**Related Findings:** +- Frontend actions for Request Network payout/release/refund confirm point to non-existent routes + +#### PAYMENT-039 — Stub endpoints return 404 and do not surface broken UI states + +**Priority:** P1 + +**Preconditions:** Buyer is authenticated and on the dashboard + +**Steps:** +1. Navigate through all buyer dashboard sections +2. Monitor Network tab for requests to: /payment/history, /payment/methods, /payment/validate, /payment/transactions, /payment/escrow/balance +3. If any of these requests are made, check whether UI shows empty state, error state, or crashes + +**Expected Result:** None of the stub endpoints are called from live dashboard components. If called, each returns 404 and the UI degrades gracefully (shows empty state or hides the section) rather than displaying an error or crashing. + +**Related Findings:** +- Multiple frontend stub endpoints have no backend implementation: /payment/history, /payment/methods, /payment/validate, /payment/transactions, /payment/escrow/balance + +#### PAYMENT-040 — escrowState releasable and releasing render correctly in payment detail view + +**Priority:** P2 + +**Preconditions:** Ability to set a Payment document's escrowState to 'releasable' and 'releasing' directly in MongoDB (or via admin API) + +**Steps:** +1. Set a completed Payment's escrowState to 'releasable' in MongoDB +2. Navigate to the payment detail view for that payment in both admin and seller dashboards +3. Observe the escrow status label +4. Repeat with escrowState = 'releasing' + +**Expected Result:** Both releasable and releasing display as meaningful, human-readable labels (not blank, 'unknown', or raw enum strings). No TypeScript errors occur. + +**Related Findings:** +- Backend escrowState values 'releasable' and 'releasing' not documented in either flow + +#### PAYMENT-041 — PurchaseRequest pending_payment status renders correctly on buyer dashboard + +**Priority:** P2 + +**Preconditions:** Ability to set a PurchaseRequest to status=pending_payment (via direct DB update or by identifying which flow triggers it) + +**Steps:** +1. Set a PurchaseRequest.status to 'pending_payment' +2. Navigate to the buyer dashboard requests list +3. Open the affected request detail view +4. Observe the displayed status label and available actions + +**Expected Result:** The pending_payment status is displayed with a meaningful label. The UI does not show a blank status or fall through to an unexpected state. Available actions are appropriate for a payment-in-progress state. + +**Related Findings:** +- PurchaseRequest status 'pending_payment' not documented in either payment flow + +#### PAYMENT-042 — payout-completed socket event: seller receives no real-time notification after admin payout + +**Priority:** P1 + +**Preconditions:** Seller is logged into their dashboard with active socket connection; admin is ready to perform a payout + +**Steps:** +1. Open seller dashboard in one browser +2. Open admin payout panel in another browser +3. Admin initiates and completes a wallet payout to the seller +4. Observe seller dashboard for any real-time notification or status change +5. Check browser console for any socket event reception + +**Expected Result:** Seller receives no real-time payout-completed notification (no frontend socket.on listener exists). Seller must manually refresh to see the updated payment status. Document this as a UX gap. + +**Related Findings:** +- payout-completed socket event is emitted by backend but no frontend handler exists + +#### PAYMENT-043 — Webhook response is HTTP 202 not 200 for SHKeeper + +**Priority:** P2 + +**Preconditions:** SHKeeper webhook endpoint is accessible + +**Steps:** +1. POST /api/payment/shkeeper/webhook with a valid PAID payload and correct signature +2. Observe the HTTP response status code + +**Expected Result:** Response is HTTP 202 Accepted. Not HTTP 200. SHKeeper's retry mechanism is not triggered since 202 is a 2xx response. + +**Related Findings:** +- SHKeeper flow webhook response documented as 'success: true' — backend actually returns 202 Accepted + +#### PAYMENT-044 — Derived destinations sweep does not interfere with in-flight SHKeeper payments + +**Priority:** P2 + +**Preconditions:** DERIVED_DESTINATION_SWEEP_AUTOSTART=true is set; a SHKeeper payment is in pending state with funds on the allocated wallet address + +**Steps:** +1. Create a SHKeeper payment intent — a wallet address is allocated +2. Before the buyer sends payment, check whether a derived destination entry is created for this wallet +3. Wait for or trigger the sweep cron +4. Observe whether the sweep job attempts to move funds from the allocated wallet before payment is confirmed +5. Send buyer payment and observe whether the webhook still processes correctly + +**Expected Result:** The sweep cron must not sweep funds from wallets that have in-flight pending payments. Payment completion should not be affected by sweep timing. If sweep runs before confirmation, this is a critical race condition. + +**Related Findings:** +- Sweep cron auto-start behaviour and derived-destination sweep endpoints not covered by any flow document + +#### PAYMENT-045 — DePay: 1-confirmation threshold is insufficient for large payments + +**Priority:** P2 + +**Preconditions:** Backend is running with current 1-confirmation default + +**Steps:** +1. Identify the confirmation depth setting in BSCTransactionVerifier +2. Complete a DePay payment and observe how many confirmations are required before status becomes completed +3. For a simulated high-value payment (> $10,000 USD equivalent), confirm whether the same 1-confirmation threshold applies + +**Expected Result:** Current behavior: 1 confirmation triggers completed status regardless of amount. Document that this does not meet the recommended >= 12 confirmations for large amounts. Flag as a configuration gap requiring per-amount thresholds before handling high-value transactions. + +#### PAYMENT-046 — DePay: Transfer event log not validated — incorrect recipient or amount could be accepted + +**Priority:** P1 + +**Preconditions:** A BSC transaction that has status=0x1 (success) but transfers tokens to a different address or a different amount is available + +**Steps:** +1. Create a payment intent for amount X to escrow address A +2. Craft or obtain a real BSC transaction that succeeded (receipt.status=0x1) but transferred tokens to a different address or a different amount +3. POST /api/payment/decentralized/verify/:paymentId with this transaction hash +4. Observe whether the verification succeeds + +**Expected Result:** Ideally the backend validates the Transfer event log (from, to, value) and rejects mismatched transactions. Per known gap, only receipt.status is currently checked, so a malicious or incorrect tx may be accepted. This must be confirmed and hardened before accepting large payments. + +#### PAYMENT-047 — SHKeeper: walletMonitor fallback completes payment when webhook is lost + +**Priority:** P2 + +**Preconditions:** Ability to suppress SHKeeper webhook delivery (block the callback URL or use a test environment where webhooks are disabled) + +**Steps:** +1. Create a SHKeeper payment intent +2. Buyer sends on-chain transfer to the allocated wallet address +3. Ensure no SHKeeper webhook is delivered +4. Wait for walletMonitor on-chain watcher to detect the deposit +5. Observe Payment status and cascade + +**Expected Result:** walletMonitor detects the on-chain transfer and flips Payment to completed/funded. The full cascade (offer acceptance, notifications, socket events) runs via this fallback path. Buyer transitions to awaiting-delivery state. + +#### PAYMENT-048 — SHKeeper: simpleAutoWebhook poll-based fallback fires before real webhook + +**Priority:** P3 + +**Preconditions:** simpleAutoWebhook polling is enabled; a payment is in pending state + +**Steps:** +1. Create a SHKeeper payment intent +2. Buyer completes on-chain transfer +3. Observe whether simpleAutoWebhook polls SHKeeper and creates a synthetic webhook event before the real one arrives +4. Confirm that the payment transitions correctly and that simpleAutoWebhook.removePayment is called after success + +**Expected Result:** simpleAutoWebhook polls SHKeeper and triggers completion if real webhook is delayed. After successful processing, the payment is removed from the polling list. No duplicate processing occurs if the real webhook arrives shortly after. + +#### PAYMENT-049 — Browser closed before DePay verification — manual reconciliation via fetch-tx endpoint + +**Priority:** P2 + +**Preconditions:** Buyer has signed and broadcast the on-chain transfer but closed the browser before the verify call completed + +**Steps:** +1. Simulate: create intent, broadcast tx, then clear the session without calling /verify +2. As admin, identify the pending Payment document with a known transactionHash +3. Call POST /api/payment/payments/{paymentId}/fetch-tx +4. Observe whether the blockchain lookup is triggered and whether Payment transitions to completed + +**Expected Result:** POST /api/payment/payments/{paymentId}/fetch-tx successfully retrieves the on-chain receipt and updates the Payment to status=completed with the correct transactionHash. The full cascade runs. Buyer and seller receive notifications. + +#### PAYMENT-050 — SHKeeper: external_id not found in DB — orphaned webhook handled gracefully + +**Priority:** P3 + +**Preconditions:** Backend is running + +**Steps:** +1. POST /api/payment/shkeeper/webhook with a valid signature but an external_id that does not exist in the payments collection +2. Observe the response and server logs + +**Expected Result:** Backend returns 202 Accepted with a rate-limited log entry. No error is thrown. No payment state is created or modified. This prevents retry storms from orphaned webhooks created during testing. + +--- + +### Disputes + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| DISPUTE-001 | Buyer creates a dispute with all required fields and valid evidence upload | P0 | Authenticated buyer with a funded purchase request that has a resolved seller... | +| DISPUTE-002 | Seller creates a dispute against a buyer | P1 | Authenticated seller with a purchase request where they are the selected seller. | +| DISPUTE-003 | Admin assigns themselves to a pending dispute (pick-up flow) | P0 | A dispute exists in status='pending'. Admin JWT available. | +| DISPUTE-004 | Admin resolves a dispute with action=refund | P0 | Dispute exists in status='in_progress' with an assigned admin. Admin JWT avai... | +| DISPUTE-005 | Admin resolves a dispute with each valid action value | P1 | Five separate in_progress disputes with assigned admin. | +| DISPUTE-006 | Admin resolves dispute using legacy doc field names (decision, refundAmount) — must be rejected or silently ignored | P1 | Dispute in status='in_progress'. Admin JWT. | +| DISPUTE-007 | SECURITY: Buyer token can change dispute status via PATCH — privilege escalation | P0 | A dispute exists in status='in_progress'. Buyer JWT (non-admin). | +| DISPUTE-008 | SECURITY: Buyer token can resolve a dispute via POST /api/disputes/:id/resolve | P0 | A dispute in status='in_progress'. Buyer JWT (non-admin). | +| DISPUTE-009 | SECURITY: Buyer self-assigns as admin via POST /api/disputes/:id/assign | P0 | A pending dispute. Buyer JWT (non-admin). | +| DISPUTE-010 | SECURITY: Unauthenticated user cannot access any dispute endpoint | P0 | No JWT token. | +| DISPUTE-011 | Route shadowing: POST /api/disputes/:purchaseRequestId/resolve routes to correct handler | P0 | A purchase request ID that is NOT a valid Dispute _id. A dispute _id that is ... | +| DISPUTE-012 | Route shadowing: GET /api/disputes/:purchaseRequestId/status resolves correctly | P0 | A purchaseRequestId with a known dispute hold status. | +| DISPUTE-013 | Dispute resolve does NOT automatically change escrow/payment state | P0 | Purchase request with funded escrow (Payment.escrowState='funded'). Dispute i... | +| DISPUTE-014 | Admin adds evidence to an in-progress dispute | P1 | Dispute in status='in_progress'. Admin JWT. Evidence file URL from /api/files... | +| DISPUTE-015 | Buyer adds evidence after dispute creation | P1 | Dispute in status='pending' or 'in_progress'. Buyer JWT. | +| DISPUTE-016 | Evidence upload endpoint requires authentication | P1 | No JWT token. | +| DISPUTE-017 | Admin updates dispute status to intermediate states | P1 | Dispute in status='in_progress'. Admin JWT. | +| DISPUTE-018 | Status field returns 'in_progress' after assign — not 'under_review' as documented | P1 | Pending dispute. Admin JWT. | +| DISPUTE-019 | Dispute creation with invalid category 'fraud' is rejected | P1 | Buyer JWT with valid purchase request. | +| DISPUTE-020 | Dispute creation with each valid category value succeeds | P1 | Six separate purchase requests with funded escrow. Buyer JWT. | +| DISPUTE-021 | Newly created dispute timeline has exactly one entry: dispute_created | P1 | Buyer JWT with valid purchase request. | +| DISPUTE-022 | Dispute messages field on Dispute document is always empty | P2 | A dispute with active chat communication. | +| DISPUTE-023 | Duplicate disputes can be created for the same purchase request | P2 | A purchase request with status that allows disputes. Buyer JWT. | +| DISPUTE-024 | Dispute creation fails with 400 when purchase request does not exist | P1 | Buyer JWT. | +| DISPUTE-025 | Dispute creation succeeds when purchase request has no identifiable seller (orphan request) | P2 | A purchase request with no selectedOfferId and no preferredSellerIds. Buyer JWT. | +| DISPUTE-026 | Admin dashboard dispute list is sorted by priority descending then createdAt descending | P1 | Multiple disputes with different priorities and creation times. Admin JWT. | +| DISPUTE-027 | GET /api/disputes/statistics returns correct counts for pending, in_progress, resolved | P1 | Known counts of disputes in each status. Admin JWT. | +| DISPUTE-028 | Statistics endpoint omits waiting_response, rejected, and closed status counts | P2 | At least one dispute in each of: waiting_response, rejected, closed. Admin JWT. | +| DISPUTE-029 | Statistics response does not include avgResolutionHours despite API docs claiming it | P2 | At least one resolved dispute with a known resolution time. Admin JWT. | +| DISPUTE-030 | No real-time socket notification is received by buyer when a dispute is created | P1 | Buyer connected to Socket.IO. Seller connected to Socket.IO. | +| DISPUTE-031 | No real-time socket notification fires on admin assignment | P1 | Buyer and seller connected to Socket.IO. Dispute in pending status. | +| DISPUTE-032 | No real-time socket notification fires on dispute resolution | P1 | Buyer and seller connected to Socket.IO. Dispute in in_progress. | +| DISPUTE-033 | Dispute chat opening system message is attributed to buyer, not a system account | P2 | A newly created dispute with an associated Chat. | +| DISPUTE-034 | Detail view 'حل اختلاف' button always submits action=refund regardless of admin intent | P2 | Admin logged into the frontend. Dispute in in_progress on the detail view. | +| DISPUTE-035 | Admin reassigns a dispute already assigned to another admin | P2 | Dispute in in_progress assigned to admin A. Admin B JWT. | +| DISPUTE-036 | Dispute on unfunded order is accepted but has no monetary impact | P2 | A purchase request where Payment.escrowState != 'funded'. Buyer JWT. | +| DISPUTE-037 | Dispute past responseDeadline (48h) has no automated escalation | P2 | A dispute where responseDeadline is in the past (manipulate via DB or wait). ... | +| DISPUTE-038 | Dispute past hard deadline (7d) has no automated closure | P2 | A dispute where deadline (7d) is in the past. | +| DISPUTE-039 | GET /api/disputes/:id returns correct dispute for the requesting user | P1 | Dispute belonging to buyer A. Buyer B JWT (unrelated user). | +| DISPUTE-040 | GET /api/disputes returns only disputes relevant to the requesting user | P1 | Multiple disputes for different buyers. Buyer A JWT. | +| DISPUTE-041 | Frontend dispute statistics tab — byCategory and byPriority data is silently unused | P3 | Admin logged into frontend with disputes in multiple categories and priorities. | +| DISPUTE-042 | No frontend action exists for POST /api/disputes/:purchaseRequestId/raise | P2 | Admin or buyer in the frontend with a purchase request eligible for a hold di... | +| DISPUTE-043 | Transition from resolved to closed — endpoint and actor are not documented | P2 | A dispute in status='resolved'. Admin JWT. | +| DISPUTE-044 | PATCH /api/disputes/:id/status with invalid status value | P2 | A dispute. Admin JWT. | +| DISPUTE-045 | Dispute creation with missing required fields returns validation error | P1 | Buyer JWT. | +| DISPUTE-046 | Dispute creation with invalid priority value is rejected | P1 | Buyer JWT with valid purchase request. | +| DISPUTE-047 | Race condition: two admins attempt to assign the same pending dispute simultaneously | P2 | A dispute in status='pending'. Two admin JWTs (admin A and admin B). | +| DISPUTE-048 | Admin resolves a dispute that is still in pending status (no admin assigned) | P2 | Dispute in status='pending'. Admin JWT. | +| DISPUTE-049 | Admin attempts to re-resolve an already resolved dispute | P2 | Dispute in status='resolved'. Admin JWT. | +| DISPUTE-050 | Evidence file types accepted: image, screenshot, video, document | P1 | Buyer JWT. Files of each type available. | +| DISPUTE-051 | Seller receives dispute creation system message in the chat | P1 | Buyer and seller both connected. Valid purchase request. | +| DISPUTE-052 | Dispute created by a user who is neither buyer nor seller of the purchase request | P1 | A third-party user JWT (not the buyer or seller of the target purchase request). | + +#### DISPUTE-001 — Buyer creates a dispute with all required fields and valid evidence upload + +**Priority:** P0 + +**Preconditions:** Authenticated buyer with a funded purchase request that has a resolved seller (selectedOfferId populated). Evidence file available for upload. + +**Steps:** +1. Upload evidence file via POST /api/files/upload with a valid buyer JWT. Note returned file URL. +2. POST /api/disputes with body: { purchaseRequestId, reason: 'Product not delivered', description: 'Detailed description', priority: 'high', category: 'delivery_delay', evidence: [{ url, type, name }] }. +3. Inspect the 201 response body. +4. GET /api/disputes/:id to fetch the created dispute. +5. Inspect dispute.timeline array. +6. Inspect dispute.chatId and verify a Chat document exists with the correct participants. + +**Expected Result:** Response status 201. Dispute document has status='pending', responseDeadline approximately now+48h, deadline approximately now+7d. timeline contains exactly one entry with action='dispute_created'. chatId is set and the referenced Chat document contains buyer and seller as participants. evidence array contains the uploaded file reference. + +#### DISPUTE-002 — Seller creates a dispute against a buyer + +**Priority:** P1 + +**Preconditions:** Authenticated seller with a purchase request where they are the selected seller. + +**Steps:** +1. POST /api/disputes with a seller JWT, body: { purchaseRequestId, reason: 'Buyer refusing payment', description: '...', priority: 'medium', category: 'payment_issue' }. +2. Inspect the response. + +**Expected Result:** Dispute created with status='pending'. Seller is listed as the initiator. Buyer and seller are both participants in the associated Chat. + +#### DISPUTE-003 — Admin assigns themselves to a pending dispute (pick-up flow) + +**Priority:** P0 + +**Preconditions:** A dispute exists in status='pending'. Admin JWT available. + +**Steps:** +1. POST /api/disputes/:id/assign with admin JWT and body: { adminId: '' } (or assignToSelf: true). +2. GET /api/disputes/:id to fetch updated dispute. + +**Expected Result:** Response 200. dispute.status='in_progress'. dispute.adminId is set to the admin's user ID. timeline contains a new entry with action='admin_assigned'. The Chat document for the dispute now includes the admin in participants with role='admin'. + +**Related Findings:** +- POST /api/disputes/:id/assign lacks a role guard but flow and docs say admin-only + +#### DISPUTE-004 — Admin resolves a dispute with action=refund + +**Priority:** P0 + +**Preconditions:** Dispute exists in status='in_progress' with an assigned admin. Admin JWT available. + +**Steps:** +1. POST /api/disputes/:id/resolve with admin JWT and body: { action: 'refund', amount: 5000, currency: 'IRR', notes: 'Seller failed to deliver' }. +2. GET /api/disputes/:id. + +**Expected Result:** Response 200. dispute.status='resolved'. dispute.resolution.action='refund', resolution.amount=5000, resolution.resolvedBy=adminId, resolution.resolvedAt is set. dispute.closedAt is set. timeline contains entry with action='dispute_resolved'. + +**Related Findings:** +- API docs describe buyer/seller/split decision model for resolve; code uses refund/replacement/compensation/warning_seller/ban_seller/no_action + +#### DISPUTE-005 — Admin resolves a dispute with each valid action value + +**Priority:** P1 + +**Preconditions:** Five separate in_progress disputes with assigned admin. + +**Steps:** +1. POST /api/disputes/:id/resolve with action='replacement'. +2. POST /api/disputes/:id/resolve with action='compensation'. +3. POST /api/disputes/:id/resolve with action='warning_seller'. +4. POST /api/disputes/:id/resolve with action='ban_seller'. +5. POST /api/disputes/:id/resolve with action='no_action'. +6. Verify each dispute's resolution.action field. + +**Expected Result:** Each call returns 200 and persists the specified action value. No validation errors for any of the six valid action values. + +**Related Findings:** +- API docs describe buyer/seller/split decision model for resolve; code uses refund/replacement/compensation/warning_seller/ban_seller/no_action + +#### DISPUTE-006 — Admin resolves dispute using legacy doc field names (decision, refundAmount) — must be rejected or silently ignored + +**Priority:** P1 + +**Preconditions:** Dispute in status='in_progress'. Admin JWT. + +**Steps:** +1. POST /api/disputes/:id/resolve with body: { decision: 'buyer', refundAmount: 1000, reasoning: 'valid reason' } (as described in API docs). +2. GET /api/disputes/:id and inspect resolution fields. + +**Expected Result:** Either a 400 validation error is returned (preferred), or the call succeeds but dispute.resolution.action is undefined/null because 'decision' is not a recognized field. Document actual behavior. The escrow state must NOT be affected. + +**Related Findings:** +- API docs describe buyer/seller/split decision model for resolve; code uses refund/replacement/compensation/warning_seller/ban_seller/no_action + +#### DISPUTE-007 — SECURITY: Buyer token can change dispute status via PATCH — privilege escalation + +**Priority:** P0 + +**Preconditions:** A dispute exists in status='in_progress'. Buyer JWT (non-admin). + +**Steps:** +1. PATCH /api/disputes/:id/status with buyer JWT and body: { status: 'resolved' }. +2. Note the HTTP response status code. +3. GET /api/disputes/:id to check dispute.status. + +**Expected Result:** EXPECTED (correct): HTTP 403 Forbidden. ACTUAL (current bug): HTTP 200 and dispute.status is updated to 'resolved'. This is a privilege-escalation vulnerability — test must flag if it passes with 200. + +**Related Findings:** +- PATCH /api/disputes/:id/status has no role guard — any authenticated user can change dispute status + +#### DISPUTE-008 — SECURITY: Buyer token can resolve a dispute via POST /api/disputes/:id/resolve + +**Priority:** P0 + +**Preconditions:** A dispute in status='in_progress'. Buyer JWT (non-admin). + +**Steps:** +1. POST /api/disputes/:id/resolve with buyer JWT and body: { action: 'ban_seller', notes: 'test' }. +2. Note HTTP response status. +3. GET /api/disputes/:id. + +**Expected Result:** EXPECTED (correct): HTTP 403 Forbidden. ACTUAL (current bug): HTTP 200 and resolution is persisted including destructive action='ban_seller'. Flag as critical failure if it returns 200. + +**Related Findings:** +- POST /api/disputes/:id/resolve (dashboard) has no role guard — any user can resolve a dispute + +#### DISPUTE-009 — SECURITY: Buyer self-assigns as admin via POST /api/disputes/:id/assign + +**Priority:** P0 + +**Preconditions:** A pending dispute. Buyer JWT (non-admin). + +**Steps:** +1. POST /api/disputes/:id/assign with buyer JWT and body: { assignToSelf: true }. +2. Note HTTP response status. +3. GET /api/disputes/:id and check dispute.adminId. + +**Expected Result:** EXPECTED (correct): HTTP 403 Forbidden. ACTUAL (current bug): HTTP 200 and dispute.adminId is set to the buyer's user ID. Flag as major failure if it returns 200. + +**Related Findings:** +- POST /api/disputes/:id/assign lacks a role guard but flow and docs say admin-only + +#### DISPUTE-010 — SECURITY: Unauthenticated user cannot access any dispute endpoint + +**Priority:** P0 + +**Preconditions:** No JWT token. + +**Steps:** +1. POST /api/disputes (no auth header). +2. GET /api/disputes (no auth header). +3. GET /api/disputes/:id (no auth header). +4. POST /api/disputes/:id/assign (no auth header). +5. PATCH /api/disputes/:id/status (no auth header). +6. POST /api/disputes/:id/resolve (no auth header). +7. POST /api/disputes/:id/evidence (no auth header). + +**Expected Result:** All endpoints return HTTP 401 Unauthorized. + +#### DISPUTE-011 — Route shadowing: POST /api/disputes/:purchaseRequestId/resolve routes to correct handler + +**Priority:** P0 + +**Preconditions:** A purchase request ID that is NOT a valid Dispute _id. A dispute _id that is NOT a valid purchase request ID. Both routers mounted on /api/disputes. + +**Steps:** +1. POST /api/disputes/{purchaseRequestId}/resolve with admin JWT and resolution body { action: 'refund' }. +2. Observe which handler responds: check if the response shape matches a Dispute document resolution (dashboard router) or a hold-release operation (releaseHold router). +3. Separately POST /api/disputes/{disputeId}/resolve and confirm the same. + +**Expected Result:** POST /api/disputes/{purchaseRequestId}/resolve should execute the releaseHold logic (clear escrow hold). Currently due to route shadowing, it will match the dashboard router's POST /:id/resolve first. Document which handler actually fires. Any non-deterministic outcome is a critical failure. + +**Related Findings:** +- Route shadowing: /:purchaseRequestId/raise and /:purchaseRequestId/resolve may collide with /:id routes + +#### DISPUTE-012 — Route shadowing: GET /api/disputes/:purchaseRequestId/status resolves correctly + +**Priority:** P0 + +**Preconditions:** A purchaseRequestId with a known dispute hold status. + +**Steps:** +1. GET /api/disputes/{purchaseRequestId}/status with valid JWT. +2. Observe whether the response is the hold status (releaseHold router) or a 404 from the dashboard router treating the ID as a dispute _id. + +**Expected Result:** Response returns hold status object. If it returns 404 or a Dispute document, the dashboard router is shadowing the route — flag as critical. + +**Related Findings:** +- Route shadowing: /:purchaseRequestId/raise and /:purchaseRequestId/resolve may collide with /:id routes + +#### DISPUTE-013 — Dispute resolve does NOT automatically change escrow/payment state + +**Priority:** P0 + +**Preconditions:** Purchase request with funded escrow (Payment.escrowState='funded'). Dispute in in_progress. Admin JWT. + +**Steps:** +1. Note Payment.escrowState before resolution. +2. POST /api/disputes/:id/resolve with body: { action: 'refund', amount: 5000 }. +3. Query the Payment document for the related purchase request. +4. Check Payment.escrowState. + +**Expected Result:** Payment.escrowState remains 'funded' (unchanged). The dispute status is 'resolved' but no escrow transition occurred. A separate call to the hold-clear endpoint is required. Document this explicitly for ops teams. + +**Related Findings:** +- Resolve dispute does not trigger financial side effects — escrow state is unchanged + +#### DISPUTE-014 — Admin adds evidence to an in-progress dispute + +**Priority:** P1 + +**Preconditions:** Dispute in status='in_progress'. Admin JWT. Evidence file URL from /api/files/upload. + +**Steps:** +1. POST /api/disputes/:id/evidence with admin JWT and body: { url, type: 'image', name: 'screenshot.png' }. +2. GET /api/disputes/:id. + +**Expected Result:** Response 200. dispute.evidence array has one additional entry. dispute.timeline has a new entry with action='evidence_added'. + +#### DISPUTE-015 — Buyer adds evidence after dispute creation + +**Priority:** P1 + +**Preconditions:** Dispute in status='pending' or 'in_progress'. Buyer JWT. + +**Steps:** +1. POST /api/disputes/:id/evidence with buyer JWT and body: { url, type: 'document', name: 'invoice.pdf' }. +2. GET /api/disputes/:id. + +**Expected Result:** Evidence added successfully. timeline entry with action='evidence_added' is appended. Verify whether the service enforces that only dispute participants can add evidence (buyer/seller/admin) — a third-party user should be rejected. + +#### DISPUTE-016 — Evidence upload endpoint requires authentication + +**Priority:** P1 + +**Preconditions:** No JWT token. + +**Steps:** +1. POST /api/files/upload with no auth header and a valid file. +2. Attempt again with an expired token. + +**Expected Result:** Both attempts return 401. Random users cannot pollute the evidence store. + +#### DISPUTE-017 — Admin updates dispute status to intermediate states + +**Priority:** P1 + +**Preconditions:** Dispute in status='in_progress'. Admin JWT. + +**Steps:** +1. PATCH /api/disputes/:id/status with admin JWT and body: { status: 'waiting_response' }. +2. GET /api/disputes/:id. +3. PATCH /api/disputes/:id/status back to 'in_progress'. +4. Verify timeline entries. + +**Expected Result:** Both transitions succeed. timeline has 'status_changed' entries for each transition. dispute.status reflects the latest value. + +**Related Findings:** +- API docs use 'under_review' status; code uses 'in_progress' + +#### DISPUTE-018 — Status field returns 'in_progress' after assign — not 'under_review' as documented + +**Priority:** P1 + +**Preconditions:** Pending dispute. Admin JWT. + +**Steps:** +1. POST /api/disputes/:id/assign with admin JWT. +2. Inspect dispute.status in the response. + +**Expected Result:** dispute.status='in_progress'. If any code path returns 'under_review', it is a bug — that value does not exist in the model enum and would cause filtering to fail. + +**Related Findings:** +- API docs use 'under_review' status; code uses 'in_progress' + +#### DISPUTE-019 — Dispute creation with invalid category 'fraud' is rejected + +**Priority:** P1 + +**Preconditions:** Buyer JWT with valid purchase request. + +**Steps:** +1. POST /api/disputes with body: { ..., category: 'fraud' }. +2. Note response status and body. + +**Expected Result:** HTTP 400 validation error. Valid categories are: product_quality, delivery_delay, wrong_item, payment_issue, seller_behavior, other. 'fraud' is not accepted. + +**Related Findings:** +- Flow doc says dispute categories are delivery/payment/quality/fraud/other; code uses a different enum + +#### DISPUTE-020 — Dispute creation with each valid category value succeeds + +**Priority:** P1 + +**Preconditions:** Six separate purchase requests with funded escrow. Buyer JWT. + +**Steps:** +1. POST /api/disputes with category='product_quality'. +2. POST /api/disputes with category='delivery_delay'. +3. POST /api/disputes with category='wrong_item'. +4. POST /api/disputes with category='payment_issue'. +5. POST /api/disputes with category='seller_behavior'. +6. POST /api/disputes with category='other'. + +**Expected Result:** All six calls return 201 with the correct category value persisted. + +**Related Findings:** +- Flow doc says dispute categories are delivery/payment/quality/fraud/other; code uses a different enum + +#### DISPUTE-021 — Newly created dispute timeline has exactly one entry: dispute_created + +**Priority:** P1 + +**Preconditions:** Buyer JWT with valid purchase request. + +**Steps:** +1. POST /api/disputes with valid body. +2. GET /api/disputes/:id. +3. Inspect dispute.timeline. + +**Expected Result:** dispute.timeline has exactly 1 entry with action='dispute_created'. Zero entries means the pre('save') middleware is not firing. More than one entry indicates an unexpected duplicate write. + +**Related Findings:** +- Dispute timeline initialised twice: pre('save') middleware adds dispute_created, but service also sets timeline: [] + +#### DISPUTE-022 — Dispute messages field on Dispute document is always empty + +**Priority:** P2 + +**Preconditions:** A dispute with active chat communication. + +**Steps:** +1. Send several messages in the dispute chat. +2. GET /api/disputes/:id. +3. Inspect dispute.messages field. + +**Expected Result:** dispute.messages is an empty array or absent. All messages are stored in the Chat document referenced by dispute.chatId. Any non-empty dispute.messages array indicates an unexpected write path. + +**Related Findings:** +- Dispute model has a messages sub-array that is never used + +#### DISPUTE-023 — Duplicate disputes can be created for the same purchase request + +**Priority:** P2 + +**Preconditions:** A purchase request with status that allows disputes. Buyer JWT. + +**Steps:** +1. POST /api/disputes with purchaseRequestId=X (first dispute). +2. POST /api/disputes again with the same purchaseRequestId=X (second dispute). +3. Inspect both responses. + +**Expected Result:** EXPECTED (hardened): Second call returns 409 Conflict. ACTUAL (current): Both calls return 201 and two separate pending disputes are created for the same purchase request. Document this as a data integrity gap. + +**Related Findings:** +- Dispute model has no uniqueness constraint on (purchaseRequestId, status) — duplicate disputes are possible + +#### DISPUTE-024 — Dispute creation fails with 400 when purchase request does not exist + +**Priority:** P1 + +**Preconditions:** Buyer JWT. + +**Steps:** +1. POST /api/disputes with purchaseRequestId='000000000000000000000000' (non-existent ObjectId). + +**Expected Result:** HTTP 400 with error message 'Purchase request not found'. + +#### DISPUTE-025 — Dispute creation succeeds when purchase request has no identifiable seller (orphan request) + +**Priority:** P2 + +**Preconditions:** A purchase request with no selectedOfferId and no preferredSellerIds. Buyer JWT. + +**Steps:** +1. POST /api/disputes with the orphan purchaseRequestId. +2. GET /api/disputes/:id. +3. Inspect dispute.sellerId and the associated Chat participants. + +**Expected Result:** Dispute is created with sellerId=undefined (current behavior). Chat has only the buyer as participant. Document that this creates a mediator-less situation. Recommended: the endpoint should return 400 in this case. + +#### DISPUTE-026 — Admin dashboard dispute list is sorted by priority descending then createdAt descending + +**Priority:** P1 + +**Preconditions:** Multiple disputes with different priorities and creation times. Admin JWT. + +**Steps:** +1. Create disputes with priorities: low, medium, high, urgent (in any order). +2. GET /api/disputes. +3. Inspect the order of returned disputes. + +**Expected Result:** Disputes are returned in order: urgent first, then high, medium, low. Within the same priority, newer disputes appear before older ones. + +#### DISPUTE-027 — GET /api/disputes/statistics returns correct counts for pending, in_progress, resolved + +**Priority:** P1 + +**Preconditions:** Known counts of disputes in each status. Admin JWT. + +**Steps:** +1. Create a known number of disputes in each status. +2. GET /api/disputes/statistics. +3. Compare returned counts against known values. + +**Expected Result:** Statistics response contains correct counts for total, pending, inProgress, resolved. byCategory and byPriority breakdowns are present in the API response. + +**Related Findings:** +- Statistics endpoint omits waiting_response, rejected, and closed from counts + +#### DISPUTE-028 — Statistics endpoint omits waiting_response, rejected, and closed status counts + +**Priority:** P2 + +**Preconditions:** At least one dispute in each of: waiting_response, rejected, closed. Admin JWT. + +**Steps:** +1. Transition disputes to waiting_response, rejected, and closed via admin actions. +2. GET /api/disputes/statistics. +3. Check whether these statuses appear in the response. + +**Expected Result:** Current behavior: waiting_response, rejected, and closed counts are absent from the statistics response. These disputes contribute to 'total' but have no individual count field. Document this as a gap — the frontend tab badges for these statuses will show zero or be absent. + +**Related Findings:** +- Statistics endpoint omits waiting_response, rejected, and closed from counts + +#### DISPUTE-029 — Statistics response does not include avgResolutionHours despite API docs claiming it + +**Priority:** P2 + +**Preconditions:** At least one resolved dispute with a known resolution time. Admin JWT. + +**Steps:** +1. GET /api/disputes/statistics. +2. Check for avgResolutionHours field in the response. + +**Expected Result:** avgResolutionHours is absent from the response. Document the discrepancy from the API docs. + +**Related Findings:** +- Statistics endpoint omits waiting_response, rejected, and closed from counts + +#### DISPUTE-030 — No real-time socket notification is received by buyer when a dispute is created + +**Priority:** P1 + +**Preconditions:** Buyer connected to Socket.IO. Seller connected to Socket.IO. + +**Steps:** +1. Open Socket.IO listener on buyer client for 'new-notification' and 'new-message' events. +2. POST /api/disputes to create a new dispute. +3. Wait 3 seconds for any socket events. + +**Expected Result:** EXPECTED (per docs): new-notification fires to the seller's socket room. ACTUAL (current): No socket event is emitted. Neither buyer nor seller receives a real-time notification. Chat participants do not receive a system message notification. Document all absent events. + +**Related Findings:** +- All Socket.IO events for disputes are unimplemented — every socket claim in docs is a TODO stub + +#### DISPUTE-031 — No real-time socket notification fires on admin assignment + +**Priority:** P1 + +**Preconditions:** Buyer and seller connected to Socket.IO. Dispute in pending status. + +**Steps:** +1. Attach listeners for 'dispute-updated' and 'new-notification' on buyer and seller clients. +2. POST /api/disputes/:id/assign with admin JWT. +3. Wait 3 seconds. + +**Expected Result:** No socket events received. dispute-updated and new-notification are both planned/TODO. Document this gap — neither party is notified when an admin takes over their dispute. + +**Related Findings:** +- All Socket.IO events for disputes are unimplemented — every socket claim in docs is a TODO stub + +#### DISPUTE-032 — No real-time socket notification fires on dispute resolution + +**Priority:** P1 + +**Preconditions:** Buyer and seller connected to Socket.IO. Dispute in in_progress. + +**Steps:** +1. Attach listeners for 'new-notification' and 'dispute-updated' on buyer and seller clients. +2. POST /api/disputes/:id/resolve with admin JWT. +3. Wait 3 seconds. + +**Expected Result:** No socket events received. notifyDisputeResolved is a TODO in the code. Buyer and seller are not notified of the outcome in real time. + +**Related Findings:** +- All Socket.IO events for disputes are unimplemented — every socket claim in docs is a TODO stub + +#### DISPUTE-033 — Dispute chat opening system message is attributed to buyer, not a system account + +**Priority:** P2 + +**Preconditions:** A newly created dispute with an associated Chat. + +**Steps:** +1. POST /api/disputes to create a dispute. +2. Fetch the Chat document referenced by dispute.chatId. +3. Inspect the first message in Chat.messages. +4. Check messages[0].senderId and messages[0].messageType. + +**Expected Result:** messages[0].messageType='system' and messages[0].content contains 'اختلاف جدید ایجاد شد'. However, messages[0].senderId is the buyer's userId (not a neutral system account). If the UI renders system-type messages differently only when senderId is a system account, this message may render as a regular buyer message. + +**Related Findings:** +- Flow doc says dispute chat opening message uses sellerId from resolver chain but chat system message is sent with buyerId as senderId + +#### DISPUTE-034 — Detail view 'حل اختلاف' button always submits action=refund regardless of admin intent + +**Priority:** P2 + +**Preconditions:** Admin logged into the frontend. Dispute in in_progress on the detail view. + +**Steps:** +1. Navigate to the dispute detail view. +2. Locate the 'حل اختلاف' button in the top card actions area (not the AdminActionsPanel). +3. Click the button without setting any action in the AdminActionsPanel. +4. GET /api/disputes/:id after the click. + +**Expected Result:** dispute.resolution.action='refund' and notes='حل شده توسط ادمین' are persisted regardless of any other intended action. The admin has no choice. Document that the top-level button bypasses the AdminActionsPanel's action selector. + +**Related Findings:** +- Flow hardcoded resolve action in detail view — admin has no choice, always sends action=refund + +#### DISPUTE-035 — Admin reassigns a dispute already assigned to another admin + +**Priority:** P2 + +**Preconditions:** Dispute in in_progress assigned to admin A. Admin B JWT. + +**Steps:** +1. POST /api/disputes/:id/assign with admin B JWT and body: { adminId: '' }. +2. GET /api/disputes/:id. + +**Expected Result:** ACTUAL (current): dispute.adminId is silently overwritten with admin B's ID. Status transitions back through in_progress (or stays). Timeline gets a new 'admin_assigned' entry. There is no 403 or 409 preventing reassignment. Document this behavior for ops teams as the workaround for admin handover. + +**Related Findings:** +- No endpoint or flow step documented for dispute reassignment (admin handover) + +#### DISPUTE-036 — Dispute on unfunded order is accepted but has no monetary impact + +**Priority:** P2 + +**Preconditions:** A purchase request where Payment.escrowState != 'funded'. Buyer JWT. + +**Steps:** +1. POST /api/disputes with the unfunded purchaseRequestId. +2. Admin assigns and resolves with action='refund'. +3. Check Payment.escrowState after resolution. + +**Expected Result:** Dispute is created and resolved without error. Payment.escrowState is unchanged from its pre-dispute value. No money moves. Document this as expected behavior and ensure ops teams are aware that resolving disputes on unfunded orders has no financial effect. + +#### DISPUTE-037 — Dispute past responseDeadline (48h) has no automated escalation + +**Priority:** P2 + +**Preconditions:** A dispute where responseDeadline is in the past (manipulate via DB or wait). Admin JWT. + +**Steps:** +1. Set a dispute's responseDeadline to a past timestamp directly in MongoDB. +2. GET /api/disputes/:id. +3. Check dispute.status and dispute.priority. +4. GET /api/disputes/statistics and check if any auto-escalation flag is set. + +**Expected Result:** No automated status change or priority escalation occurs. The dispute remains in its current status. No notification is sent. Document that past-deadline enforcement is not implemented. + +#### DISPUTE-038 — Dispute past hard deadline (7d) has no automated closure + +**Priority:** P2 + +**Preconditions:** A dispute where deadline (7d) is in the past. + +**Steps:** +1. Set a dispute's deadline to a past timestamp in MongoDB. +2. Wait for any scheduled jobs or triggers. +3. GET /api/disputes/:id. + +**Expected Result:** Dispute remains in its current status. No auto-closure or auto-escalation fires. Document this gap. + +#### DISPUTE-039 — GET /api/disputes/:id returns correct dispute for the requesting user + +**Priority:** P1 + +**Preconditions:** Dispute belonging to buyer A. Buyer B JWT (unrelated user). + +**Steps:** +1. GET /api/disputes/:id with buyer B JWT. +2. Note HTTP response status. + +**Expected Result:** HTTP 403 Forbidden or 404 Not Found. Buyer B should not be able to view a dispute they are not a party to. + +#### DISPUTE-040 — GET /api/disputes returns only disputes relevant to the requesting user + +**Priority:** P1 + +**Preconditions:** Multiple disputes for different buyers. Buyer A JWT. + +**Steps:** +1. GET /api/disputes with buyer A JWT. +2. Inspect all returned disputes. + +**Expected Result:** Only disputes where buyer A is the initiator or a party are returned. Admin GET /api/disputes should return all disputes. + +#### DISPUTE-041 — Frontend dispute statistics tab — byCategory and byPriority data is silently unused + +**Priority:** P3 + +**Preconditions:** Admin logged into frontend with disputes in multiple categories and priorities. + +**Steps:** +1. Navigate to the dispute list view. +2. Observe the tab badges and any statistics displays. +3. Verify whether category or priority breakdown charts/tables are shown. + +**Expected Result:** Tab badges show counts for total, pending, inProgress, resolved only. byCategory and byPriority data from GET /api/disputes/statistics is fetched but not displayed anywhere in the UI. No statistics breakdown page exists at /dashboard/disputes/statistics. + +**Related Findings:** +- Dispute statistics page does not exist as a standalone route + +#### DISPUTE-042 — No frontend action exists for POST /api/disputes/:purchaseRequestId/raise + +**Priority:** P2 + +**Preconditions:** Admin or buyer in the frontend with a purchase request eligible for a hold dispute. + +**Steps:** +1. Navigate to the purchase request detail page. +2. Search for any 'raise hold dispute' button or UI element. +3. Inspect frontend/src/actions/dispute.ts for a raiseDispute function. + +**Expected Result:** No UI button and no frontend action function exists for raising a hold dispute. The endpoint exists in the backend but is unreachable from the frontend. Document that this flow must be triggered directly via API. + +**Related Findings:** +- Frontend has no action for POST /api/disputes/:purchaseRequestId/raise or GET /api/disputes/:purchaseRequestId/status + +#### DISPUTE-043 — Transition from resolved to closed — endpoint and actor are not documented + +**Priority:** P2 + +**Preconditions:** A dispute in status='resolved'. Admin JWT. + +**Steps:** +1. Attempt PATCH /api/disputes/:id/status with body: { status: 'closed' } using admin JWT. +2. Note response status and updated dispute. + +**Expected Result:** Determine whether resolved→closed transition is allowed. The state machine shows this transition but no dedicated endpoint or flow step documents who triggers it or when. Document actual behavior including whether the transition succeeds and what timeline entry is appended. + +#### DISPUTE-044 — PATCH /api/disputes/:id/status with invalid status value + +**Priority:** P2 + +**Preconditions:** A dispute. Admin JWT. + +**Steps:** +1. PATCH /api/disputes/:id/status with body: { status: 'under_review' } (value not in enum). +2. Note response. + +**Expected Result:** HTTP 400 validation error. 'under_review' is not a valid status. Valid values are: pending, in_progress, waiting_response, resolved, rejected, closed. + +**Related Findings:** +- API docs use 'under_review' status; code uses 'in_progress' + +#### DISPUTE-045 — Dispute creation with missing required fields returns validation error + +**Priority:** P1 + +**Preconditions:** Buyer JWT. + +**Steps:** +1. POST /api/disputes with body: {} (empty). +2. POST /api/disputes with body: { purchaseRequestId } only (missing reason, description, priority, category). +3. POST /api/disputes with body: { purchaseRequestId, reason, description, priority } (missing category). + +**Expected Result:** All three calls return HTTP 400 with descriptive validation errors identifying missing required fields. + +#### DISPUTE-046 — Dispute creation with invalid priority value is rejected + +**Priority:** P1 + +**Preconditions:** Buyer JWT with valid purchase request. + +**Steps:** +1. POST /api/disputes with body: { ..., priority: 'critical' } (not in enum). + +**Expected Result:** HTTP 400 validation error. Valid priorities are: low, medium, high, urgent. + +#### DISPUTE-047 — Race condition: two admins attempt to assign the same pending dispute simultaneously + +**Priority:** P2 + +**Preconditions:** A dispute in status='pending'. Two admin JWTs (admin A and admin B). + +**Steps:** +1. Send POST /api/disputes/:id/assign from admin A and admin B concurrently (within the same millisecond if possible, otherwise in rapid succession). +2. GET /api/disputes/:id. +3. Inspect dispute.adminId and timeline. + +**Expected Result:** Only one admin assignment should win. dispute.adminId should be set to exactly one admin. Timeline should have one 'admin_assigned' entry. If both succeed (due to no optimistic locking), document the race condition — last write wins, which is non-deterministic. + +#### DISPUTE-048 — Admin resolves a dispute that is still in pending status (no admin assigned) + +**Priority:** P2 + +**Preconditions:** Dispute in status='pending'. Admin JWT. + +**Steps:** +1. POST /api/disputes/:id/resolve (skipping assignment step) with admin JWT and valid body. + +**Expected Result:** Either HTTP 409/400 because the dispute must be in_progress before resolution, or the service allows it and transitions directly to resolved. Document actual behavior — ideally a state machine guard should prevent resolution of pending disputes. + +#### DISPUTE-049 — Admin attempts to re-resolve an already resolved dispute + +**Priority:** P2 + +**Preconditions:** Dispute in status='resolved'. Admin JWT. + +**Steps:** +1. POST /api/disputes/:id/resolve with a different action (e.g., action='no_action'). +2. GET /api/disputes/:id. + +**Expected Result:** Either HTTP 409 (dispute already resolved) or the service overwrites the resolution. Document actual behavior — idempotency or state guard is expected here. + +#### DISPUTE-050 — Evidence file types accepted: image, screenshot, video, document + +**Priority:** P1 + +**Preconditions:** Buyer JWT. Files of each type available. + +**Steps:** +1. POST /api/files/upload with an image file (jpg/png). +2. POST /api/files/upload with a video file (mp4). +3. POST /api/files/upload with a document (pdf). +4. For each, call POST /api/disputes/:id/evidence with the returned URL and appropriate type value. +5. GET /api/disputes/:id and verify evidence entries. + +**Expected Result:** All file types are accepted by the upload endpoint and can be attached as evidence. dispute.evidence contains entries with correct type fields. + +#### DISPUTE-051 — Seller receives dispute creation system message in the chat + +**Priority:** P1 + +**Preconditions:** Buyer and seller both connected. Valid purchase request. + +**Steps:** +1. POST /api/disputes to create a dispute. +2. Fetch the Chat document by dispute.chatId. +3. Inspect Chat.messages. +4. Check if seller's socket received a 'new-message' event. + +**Expected Result:** Chat.messages[0] has content 'اختلاف جدید ایجاد شد: {reason}' and messageType='system'. The seller does NOT receive a real-time 'new-message' socket event (because DisputeService inserts the message directly into the Chat document without calling ChatService.sendMessage, bypassing socket emission). + +**Related Findings:** +- All Socket.IO events for disputes are unimplemented — every socket claim in docs is a TODO stub + +#### DISPUTE-052 — Dispute created by a user who is neither buyer nor seller of the purchase request + +**Priority:** P1 + +**Preconditions:** A third-party user JWT (not the buyer or seller of the target purchase request). + +**Steps:** +1. POST /api/disputes with purchaseRequestId belonging to a different buyer/seller pair. +2. Note response. + +**Expected Result:** EXPECTED (hardened): HTTP 403 — initiator must be a party to the purchase request. ACTUAL (current): likely 201, because no initiator validation exists at the service level. Document this authorization gap. + +--- + +### Chat + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| CHAT-001 | Create direct chat between buyer and seller | P0 | — | +| CHAT-002 | Create direct chat — validation: wrong participantIds count | P1 | — | +| CHAT-003 | Create support chat (POST /api/chat/support) — idempotency | P1 | — | +| CHAT-004 | Create group/dispute chat with three participants | P1 | — | +| CHAT-005 | Post-payment auto-chat creation via POST /api/chat/purchase-request | P1 | — | +| CHAT-006 | Create chat — relatedTo field is silently ignored on generic endpoint | P2 | — | +| CHAT-007 | Send a text message — happy path | P0 | — | +| CHAT-008 | Send message — sender is not a participant (403 expected) | P0 | — | +| CHAT-009 | Send message — chat not found (404 expected) | P1 | — | +| CHAT-010 | Send message — content exceeds 5000 characters | P1 | — | +| CHAT-011 | Send message — rate limit exceeded (20 messages/minute) | P1 | — | +| CHAT-012 | Message deduplication via Redis (5-minute window) | P1 | — | +| CHAT-013 | File upload — verify correct endpoint POST /api/chat/:id/messages/file | P0 | — | +| CHAT-014 | File upload — image type is rendered inline | P1 | — | +| CHAT-015 | File upload — anonymous access to uploaded file URL (security check) | P1 | — | +| CHAT-016 | Mark messages as read — empty messageIds marks all unread messages | P1 | — | +| CHAT-017 | Mark messages as read — specific messageIds marks only those messages | P1 | — | +| CHAT-018 | Mark messages as read — uses PATCH not POST (verify frontend HTTP verb) | P1 | — | +| CHAT-019 | Edit message — happy path within 15-minute window | P0 | — | +| CHAT-020 | Edit message — request body must use 'content' field, not 'text' | P0 | — | +| CHAT-021 | Edit message — attempt edit after 15-minute window | P1 | — | +| CHAT-022 | Edit message — non-sender cannot edit another user's message | P1 | — | +| CHAT-023 | Delete message — soft delete behavior | P1 | — | +| CHAT-024 | Archive and unarchive chat — toggle behavior | P0 | — | +| CHAT-025 | Leave group chat — correct endpoint must be DELETE /api/chat/:id/participants/:participantId | P0 | — | +| CHAT-026 | Add participant to group chat — body must use userId (single string, not array) | P1 | — | +| CHAT-027 | Get participants — GET /api/chat/:id/participants returns 404 (no backend implementation) | P1 | — | +| CHAT-028 | Update participant role — PUT /api/chat/:id/participants/:participantId returns 404 | P1 | — | +| CHAT-029 | Get chat messages with pagination | P1 | — | +| CHAT-030 | getChatInfo truncates to 50 messages — frontend must paginate for full history | P2 | — | +| CHAT-031 | Get chat messages — chat not found (404 expected) | P1 | — | +| CHAT-032 | Get all chats for authenticated user (GET /api/chat) | P0 | — | +| CHAT-033 | Socket: join-chat-room and receive new-message event | P0 | — | +| CHAT-034 | Socket: chat-notification sent to non-sender's user room | P1 | — | +| CHAT-035 | Socket: chat-notification senderName is hardcoded as 'کاربر' instead of actual sender name | P1 | — | +| CHAT-036 | Socket: messages-read broadcast triggers double-tick on sender's UI | P1 | — | +| CHAT-037 | Socket: user-online and join-user-room are distinct events | P1 | — | +| CHAT-038 | Socket: disconnect does NOT broadcast offline status (known gap) | P2 | — | +| CHAT-039 | Typing indicator — start and stop events | P1 | — | +| CHAT-040 | Typing indicator — rate limit (5 events per 10 seconds) | P2 | — | +| CHAT-041 | Send message with reply-to reference | P1 | — | +| CHAT-042 | System messages are broadcast via socket on chat creation | P2 | — | +| CHAT-043 | GET /api/chat/stats returns correct aggregated counts | P2 | — | +| CHAT-044 | Unauthenticated requests are rejected on all chat endpoints | P0 | — | +| CHAT-045 | Concurrent markAsRead race condition is harmless | P2 | — | +| CHAT-046 | Authenticated user can only read chats they participate in | P0 | — | +| CHAT-047 | Archived chat does not appear in active chat list | P1 | — | +| CHAT-048 | Message content field empty string is currently allowed (known gap) | P2 | — | +| CHAT-049 | Large conversation pagination efficiency (>10k messages) | P2 | — | +| CHAT-050 | Purchase-request-linked chat uses dedicated endpoint, not generic POST /api/chat | P1 | — | + +#### CHAT-001 — Create direct chat between buyer and seller + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A (buyer) +2. POST /api/chat with body { type: 'direct', participantIds: [''] } +3. Assert HTTP 201 and response contains chatId, type='direct', participants array with both users, unreadCounts zeroed +4. Repeat the same POST with identical participantIds +5. Assert HTTP 200 (or 201) and the same chatId is returned (idempotent find-or-create) + +**Expected Result:** First call creates a new direct chat. Second call returns the existing chat without creating a duplicate. MongoDB chats collection has exactly one document for the pair. + +#### CHAT-002 — Create direct chat — validation: wrong participantIds count + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A +2. POST /api/chat with body { type: 'direct', participantIds: [] } (zero external participants) +3. Assert HTTP 400 with validation error +4. POST /api/chat with body { type: 'direct', participantIds: ['', ''] } (two external participants) +5. Assert HTTP 400 with validation error + +**Expected Result:** Both requests are rejected with 400. Exactly one external participantId is required for direct chats; the caller is auto-appended by the backend. + +**Related Findings:** +- Direct chat participant count validation: exactly 1 external participantId required — not documented + +#### CHAT-003 — Create support chat (POST /api/chat/support) — idempotency + +**Priority:** P1 + +**Steps:** +1. Authenticate as a regular user +2. POST /api/chat/support +3. Assert HTTP 201, response contains chat with type='support' and participants including support@amn.gg user +4. POST /api/chat/support again +5. Assert HTTP 200 (or 201) and the same chatId is returned + +**Expected Result:** Support chat creation is idempotent. Calling it twice never creates two support chats for the same user. + +#### CHAT-004 — Create group/dispute chat with three participants + +**Priority:** P1 + +**Steps:** +1. Authenticate as an admin user +2. POST /api/chat with body { type: 'group', participantIds: ['', '', ''], title: 'Dispute #123' } +3. Assert HTTP 201, type='group', all three participants listed, system welcome message present + +**Expected Result:** Group chat is created with all three participants. System message is appended. unreadCounts are zeroed for all participants. + +#### CHAT-005 — Post-payment auto-chat creation via POST /api/chat/purchase-request + +**Priority:** P1 + +**Steps:** +1. Confirm a payment server-side (trigger the payment-state cascade) +2. Assert that a direct chat between buyer and winning seller is automatically created in the chats collection +3. Alternatively, call POST /api/chat/purchase-request directly with { purchaseRequestId: '', sellerId: '' } +4. Assert HTTP 201 and a chat linked to the purchase request is returned + +**Expected Result:** A direct chat exists between buyer and seller after payment confirmation. No duplicate chat is created on repeated calls. Note: there is no frontend UI for the manual trigger path — verify via direct API call only. + +**Related Findings:** +- POST /api/chat/purchase-request has no frontend UI or action wiring + +#### CHAT-006 — Create chat — relatedTo field is silently ignored on generic endpoint + +**Priority:** P2 + +**Steps:** +1. Authenticate as User A +2. POST /api/chat with body { type: 'direct', participantIds: [''], relatedTo: { type: 'PurchaseRequest', id: '' } } +3. Assert HTTP 201 +4. Inspect the created chat document — confirm relatedTo is NOT persisted + +**Expected Result:** Chat is created successfully but relatedTo is silently dropped. Purchase-request-linked chats must use POST /api/chat/purchase-request instead. + +**Related Findings:** +- Flow doc CREATE CHAT body includes relatedTo field; backend API does not accept it at POST /api/chat + +#### CHAT-007 — Send a text message — happy path + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A, obtain a chatId where User A is a participant +2. POST /api/chat/:chatId/messages with body { content: 'Hello from buyer' } +3. Assert HTTP 201, response includes message object with content, senderId, timestamp, isRead=false +4. Assert User B's unreadCounts is incremented by 1 +5. Assert lastMessage cache on the chat document is updated + +**Expected Result:** Message is persisted. Non-sender's unreadCount is incremented. Socket event new-message is broadcast to the chat room. chat-notification is sent to User B's user-{userId} room. + +#### CHAT-008 — Send message — sender is not a participant (403 expected) + +**Priority:** P0 + +**Steps:** +1. Authenticate as User C (not a participant in the target chat) +2. POST /api/chat/:chatId/messages with body { content: 'Unauthorized message' } +3. Assert HTTP 403 with error 'User is not a participant in this chat' + +**Expected Result:** Backend returns 403. Message is not persisted. + +#### CHAT-009 — Send message — chat not found (404 expected) + +**Priority:** P1 + +**Steps:** +1. Authenticate as any user +2. POST /api/chat/000000000000000000000000/messages with body { content: 'Test' } +3. Assert HTTP 404 + +**Expected Result:** Backend returns 404. No message is persisted. + +#### CHAT-010 — Send message — content exceeds 5000 characters + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A in an existing chat +2. POST /api/chat/:chatId/messages with body { content: '' } +3. Assert HTTP 400 with a validation error referencing content length +4. Verify the frontend displays the error rather than silently failing + +**Expected Result:** Backend rejects with 400 validation error. Frontend shows user-facing error message. + +**Related Findings:** +- Backend enforces 5000-character message content limit — not documented in flow doc + +#### CHAT-011 — Send message — rate limit exceeded (20 messages/minute) + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A in an existing chat +2. Rapidly POST 21 messages to /api/chat/:chatId/messages within 60 seconds +3. Assert that the 21st request returns HTTP 429 (or equivalent rate-limit error) +4. Assert the frontend displays a meaningful error (not silent failure) + +**Expected Result:** After 20 messages in 60 seconds, subsequent sends are blocked with a rate-limit error. Frontend surfaces the error to the user. + +**Related Findings:** +- Backend enforces rate limiting (20 msgs/min) and message deduplication — not documented in flow + +#### CHAT-012 — Message deduplication via Redis (5-minute window) + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A +2. POST /api/chat/:chatId/messages with body { content: 'Duplicate test', clientMessageId: '' } +3. Immediately POST the identical message with the same idempotency key +4. Assert only one message is persisted in the chat + +**Expected Result:** Duplicate message within the 5-minute deduplication window is discarded. Only one message record exists in the database. + +**Related Findings:** +- Backend enforces rate limiting (20 msgs/min) and message deduplication — not documented in flow + +#### CHAT-013 — File upload — verify correct endpoint POST /api/chat/:id/messages/file + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A in an existing chat +2. Attempt to upload a file using the frontend 'attach file' UI control +3. Capture the outgoing network request in browser devtools +4. Assert the multipart/form-data POST is sent to /api/chat/:chatId/messages/file (NOT to /api/chat/:chatId/messages) +5. Assert HTTP 201 response contains a message object with attachments array including fileUrl, fileName, fileSize +6. Assert the message appears in the chat with the attachment rendered + +**Expected Result:** File is uploaded to the correct /messages/file endpoint. Backend returns a message with attachment metadata. Chat displays the file. NOTE: the current frontend sendFileMessage action posts to the wrong endpoint (/messages instead of /messages/file) — this test is expected to FAIL with the current code. + +**Related Findings:** +- sendFileMessage posts to wrong endpoint — missing /file suffix + +#### CHAT-014 — File upload — image type is rendered inline + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A +2. Upload a PNG or JPEG file via POST /api/chat/:chatId/messages/file +3. Assert response messageType is 'image' +4. Assert the chat UI renders the image inline (img tag or preview) + +**Expected Result:** Image files are rendered as inline previews in the chat thread, not as generic file download links. + +#### CHAT-015 — File upload — anonymous access to uploaded file URL (security check) + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A and upload a file in a chat +2. Copy the fileUrl from the response (e.g., /uploads/chat/) +3. Open the URL in an incognito browser session without any authentication cookies or tokens +4. Assert whether the file is accessible + +**Expected Result:** CURRENT BEHAVIOR (known security gap): file is accessible without authentication. This should be flagged as a security defect. Expected secure behavior would be a 401/403 for unauthenticated requests. + +**Related Findings:** +- File uploads stored under uploads/chat/ with anonymous access — security concern not surfaced in flow doc + +#### CHAT-016 — Mark messages as read — empty messageIds marks all unread messages + +**Priority:** P1 + +**Steps:** +1. As User B, ensure there are 5 unread messages in a chat sent by User A +2. PATCH /api/chat/:chatId/messages/read with body {} (no messageIds field) +3. Assert HTTP 200 +4. Assert User B's unreadCounts for this chat is now 0 +5. Assert all 5 messages have isRead=true +6. Assert messages-read socket event is broadcast to the chat room + +**Expected Result:** Omitting messageIds marks all unread messages as read and zeros the unreadCount. Socket event fires so User A sees double-tick on all messages. + +**Related Findings:** +- markAsRead with empty messageIds marks all unread — behavior undocumented + +#### CHAT-017 — Mark messages as read — specific messageIds marks only those messages + +**Priority:** P1 + +**Steps:** +1. As User B, ensure there are 5 unread messages in a chat +2. Capture the _id of messages 1 and 2 +3. PATCH /api/chat/:chatId/messages/read with body { messageIds: ['', ''] } +4. Assert HTTP 200 +5. Assert only messages 1 and 2 have isRead=true; messages 3-5 remain isRead=false +6. Assert unreadCounts is decremented by 2 (not zeroed) + +**Expected Result:** Only the specified messages are marked read. The unread count reflects remaining unread messages. + +**Related Findings:** +- markAsRead with empty messageIds marks all unread — behavior undocumented + +#### CHAT-018 — Mark messages as read — uses PATCH not POST (verify frontend HTTP verb) + +**Priority:** P1 + +**Steps:** +1. Authenticate as User B with unread messages +2. Open the chat in the frontend — the clickConversation handler should auto-call markAsRead +3. Capture the outgoing request in browser devtools +4. Assert the HTTP method is PATCH and path is /api/chat/:chatId/messages/read +5. Assert HTTP 200 from backend + +**Expected Result:** Frontend correctly uses PATCH. Backend accepts and processes the request. Any integration tests or scripts that use POST to this endpoint will get a 404/405. + +**Related Findings:** +- Flow doc states markAsRead is POST but backend and API docs define it as PATCH + +#### CHAT-019 — Edit message — happy path within 15-minute window + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A and send a message +2. Within 15 minutes, PUT /api/chat/:chatId/messages/:messageId with body { content: 'Edited content' } +3. Assert HTTP 200 and response shows updated content +4. Assert message in DB has isEdited=true (or equivalent flag) and new content + +**Expected Result:** Message content is updated. Edit is reflected in the chat UI. NOTE: the current frontend sends { text: '...' } but the backend expects { content: '...' } — this test is expected to FAIL with current code. + +**Related Findings:** +- editMessage sends field 'text' but backend expects field 'content' + +#### CHAT-020 — Edit message — request body must use 'content' field, not 'text' + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A and send a message +2. Within 15 minutes, PUT /api/chat/:chatId/messages/:messageId with body { text: 'Wrong field name' } +3. Assert HTTP 400 or that the response does NOT update the message content +4. Repeat with body { content: 'Correct field name' } +5. Assert HTTP 200 and message content is updated + +**Expected Result:** Backend rejects or ignores the 'text' field. Only 'content' is accepted. The frontend currently sends 'text' — edit functionality is broken until the frontend is fixed. + +**Related Findings:** +- editMessage sends field 'text' but backend expects field 'content' + +#### CHAT-021 — Edit message — attempt edit after 15-minute window + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A and identify a message sent more than 15 minutes ago +2. PUT /api/chat/:chatId/messages/:messageId with body { content: 'Late edit attempt' } +3. Assert HTTP 400 with an error indicating the edit window has expired +4. Verify the frontend displays this error to the user + +**Expected Result:** Backend returns 400. Message content is unchanged. Frontend surfaces the error rather than silently failing. + +**Related Findings:** +- Edit message has a 15-minute time window constraint not documented in flow doc + +#### CHAT-022 — Edit message — non-sender cannot edit another user's message + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A and send a message +2. Authenticate as User B (also a participant) +3. PUT /api/chat/:chatId/messages/:messageId (User A's message) as User B with { content: 'Unauthorized edit' } +4. Assert HTTP 403 + +**Expected Result:** Only the original sender can edit their own messages. Backend returns 403 for unauthorized edit attempts. + +#### CHAT-023 — Delete message — soft delete behavior + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A and send a message +2. DELETE /api/chat/:chatId/messages/:messageId as User A +3. Assert HTTP 200 +4. Assert the message no longer appears in the chat UI +5. Assert the database record has deletedAt set and content is cleared (soft delete) +6. Assert message-deleted socket event was broadcast to the chat room +7. Assert lastMessage cache on the chat is repaired if the deleted message was the last one + +**Expected Result:** Message is soft-deleted: content is cleared and deletedAt is set, but the record is retained. UI reflects deletion via the message-deleted socket event. + +**Related Findings:** +- Soft-delete on message DELETE and participant removal not documented in flow + +#### CHAT-024 — Archive and unarchive chat — toggle behavior + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A in an active chat +2. Attempt to archive via the frontend UI +3. Capture the outgoing request — assert it is PATCH /api/chat/:chatId/archive (NOT PUT) +4. Assert HTTP 200 and chat settings.isArchived=true +5. Call PATCH /api/chat/:chatId/archive again (directly, since frontend may not expose unarchive UI) +6. Assert HTTP 200 and chat settings.isArchived=false (chat is unarchived) + +**Expected Result:** Archive is a toggle. First call archives, second call unarchives. NOTE: the frontend currently uses PUT instead of PATCH — the archive action is expected to FAIL (404/405) with current code. + +**Related Findings:** +- archiveConversation uses PUT but backend exposes PATCH /api/chat/:id/archive +- PATCH /api/chat/:id/archive toggles archived state — unarchive path is undocumented + +#### CHAT-025 — Leave group chat — correct endpoint must be DELETE /api/chat/:id/participants/:participantId + +**Priority:** P0 + +**Steps:** +1. Authenticate as User B in a group chat +2. Trigger the 'leave chat' action in the frontend +3. Capture the outgoing request in browser devtools +4. Assert the request is DELETE /api/chat/:chatId/participants/:userId (NOT PUT /api/chat/:chatId/leave) +5. Assert HTTP 200 +6. Assert User B's participant record has isActive=false and leftAt timestamp set + +**Expected Result:** Leave action calls the correct DELETE endpoint. Backend soft-removes participant. NOTE: the current frontend action calls PUT /chat/:id/leave which does not exist — this will return 404 with current code. + +**Related Findings:** +- leaveConversation frontend action calls non-existent backend endpoint PUT /api/chat/:id/leave +- Soft-delete on message DELETE and participant removal not documented in flow + +#### CHAT-026 — Add participant to group chat — body must use userId (single string, not array) + +**Priority:** P1 + +**Steps:** +1. Authenticate as an admin in a group chat +2. POST /api/chat/:chatId/participants with body { userId: '' } +3. Assert HTTP 201 and the participant is added +4. POST /api/chat/:chatId/participants with body { participants: [''] } (frontend's current format) +5. Assert HTTP 400 or that the participant is NOT added + +**Expected Result:** Backend accepts { userId: string } (single ID). The frontend currently sends { participants: string[] } (array with wrong key) — adding participants from the UI is expected to fail with current code. + +**Related Findings:** +- addParticipants frontend sends { participants } but backend expects { userId } (single user) + +#### CHAT-027 — Get participants — GET /api/chat/:id/participants returns 404 (no backend implementation) + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A +2. GET /api/chat/:chatId/participants +3. Assert HTTP 404 +4. Verify participant data is available via GET /api/chat/:chatId/info instead + +**Expected Result:** GET /api/chat/:id/participants has no backend route and returns 404. Any frontend UI calling getParticipants will fail. Participant list must be loaded from the chat info endpoint. + +**Related Findings:** +- GET /api/chat/:id/participants has no backend implementation + +#### CHAT-028 — Update participant role — PUT /api/chat/:id/participants/:participantId returns 404 + +**Priority:** P1 + +**Steps:** +1. Authenticate as admin +2. PUT /api/chat/:chatId/participants/:userId with body { role: 'admin' } +3. Assert HTTP 404 or 405 — no such route exists on the backend + +**Expected Result:** No role-update endpoint exists. Any admin UI for changing participant roles will silently fail with 404/405. + +**Related Findings:** +- PUT /api/chat/:id/participants/:participantId (role update) has no backend implementation + +#### CHAT-029 — Get chat messages with pagination + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A in a chat with more than 50 messages +2. GET /api/chat/:chatId/messages?page=1&limit=20 +3. Assert HTTP 200 and exactly 20 messages are returned +4. GET /api/chat/:chatId/messages?page=2&limit=20 +5. Assert HTTP 200 and the next 20 messages are returned with no overlap + +**Expected Result:** Pagination works correctly. Messages are returned in correct order. Page 1 and page 2 contain distinct, consecutive messages. + +#### CHAT-030 — getChatInfo truncates to 50 messages — frontend must paginate for full history + +**Priority:** P2 + +**Steps:** +1. Create a chat and send more than 50 messages +2. GET /api/chat/:chatId/info +3. Assert HTTP 200 and count the messages in the response +4. Assert the messages array contains at most 50 entries +5. Verify the frontend uses GET /api/chat/:chatId/messages with pagination to load messages beyond 50 + +**Expected Result:** getChatInfo returns only the first 50 messages with no pagination metadata indicating more exist. Full history requires the paginated messages endpoint. + +**Related Findings:** +- getChatInfo returns only first 50 messages — not all messages — undocumented truncation + +#### CHAT-031 — Get chat messages — chat not found (404 expected) + +**Priority:** P1 + +**Steps:** +1. Authenticate as any user +2. GET /api/chat/000000000000000000000000/messages +3. Assert HTTP 404 + +**Expected Result:** Backend returns 404 when the chatId does not exist. + +#### CHAT-032 — Get all chats for authenticated user (GET /api/chat) + +**Priority:** P0 + +**Steps:** +1. Authenticate as User A who is a participant in 3 chats +2. GET /api/chat +3. Assert HTTP 200 and response contains exactly 3 chats +4. Assert each chat includes unreadCounts, lastMessage, and participant list +5. Assert chats where User A is NOT a participant are excluded + +**Expected Result:** Only chats where the authenticated user is an active participant are returned. Each chat object contains the expected metadata. + +#### CHAT-033 — Socket: join-chat-room and receive new-message event + +**Priority:** P0 + +**Steps:** +1. Connect User A and User B as authenticated socket clients +2. User A emits join-chat-room with { chatId } +3. User B emits join-chat-room with { chatId } +4. User A sends a message via POST /api/chat/:chatId/messages +5. Assert User B's socket client receives a new-message event with the correct message payload +6. Assert User A also receives new-message (broadcast to all room members) + +**Expected Result:** Both users in the chat room receive new-message in real time. The message payload includes content, senderId, and timestamp. + +#### CHAT-034 — Socket: chat-notification sent to non-sender's user room + +**Priority:** P1 + +**Steps:** +1. Connect User B's socket and emit join-user-room with { userId: '' } +2. User A sends a message to a chat where User B is a participant +3. Assert User B's socket client receives a chat-notification event on the user-{userId} room +4. Assert the notification contains chatId and sender information + +**Expected Result:** chat-notification is delivered to non-sender's personal room, driving unread badge and notification bell updates. + +**Related Findings:** +- chat-notification socket event uses a hardcoded senderName value of the Persian word 'کاربر' ('user') instead of resolving the actual sender's firstName + +#### CHAT-035 — Socket: chat-notification senderName is hardcoded as 'کاربر' instead of actual sender name + +**Priority:** P1 + +**Steps:** +1. Connect User B's socket and join user room +2. User A (with first name 'Alice') sends a message +3. Capture the chat-notification event received by User B +4. Assert the senderName field in the notification payload +5. Assert whether it shows 'Alice' or the hardcoded Persian string 'کاربر' + +**Expected Result:** CURRENT BEHAVIOR (known bug): senderName is hardcoded as 'کاربر' regardless of actual sender. Expected behavior: senderName should resolve to sender's actual firstName. This is a known UX defect. + +**Related Findings:** +- chat-notification socket event uses a hardcoded senderName value of the Persian word 'کاربر' ('user') instead of resolving the actual sender's firstName + +#### CHAT-036 — Socket: messages-read broadcast triggers double-tick on sender's UI + +**Priority:** P1 + +**Steps:** +1. User A and User B both connect sockets and join the chat room +2. User A sends a message +3. User B calls PATCH /api/chat/:chatId/messages/read +4. Assert User A's socket client receives a messages-read event +5. Assert User A's UI updates the message status to show read (double-tick) + +**Expected Result:** Sender receives messages-read socket event after recipient marks messages as read. UI updates to show read receipt. + +#### CHAT-037 — Socket: user-online and join-user-room are distinct events + +**Priority:** P1 + +**Steps:** +1. Connect User A's socket +2. Emit join-user-room with { userId: '' } — assert socket joins room user-{userId} +3. Emit user-online with { userId: '' } — assert user-status-change event is broadcast to other connected clients +4. Verify both events are emitted on login/app load +5. Confirm other users see User A's green online indicator + +**Expected Result:** join-user-room and user-online are separate events with separate roles. Both must be emitted for full functionality. The flow doc incorrectly describes user-online as the room-joining mechanism. + +**Related Findings:** +- Flow doc lists 'user-online' as a client-to-server socket event; backend joins user room via 'join-user-room' not 'user-online' + +#### CHAT-038 — Socket: disconnect does NOT broadcast offline status (known gap) + +**Priority:** P2 + +**Steps:** +1. Connect User A and User B as socket clients; both emit user-online +2. User B observes User A's status indicator (should be green/online) +3. Disconnect User A's socket (close tab or disconnect network) +4. Wait 5 seconds +5. Assert whether User B's UI updates User A's status to offline + +**Expected Result:** CURRENT BEHAVIOR (known gap): User A's status does NOT change to offline on User B's screen after disconnection. Backend only logs disconnect without broadcasting user-status-change. Stale 'online' indicators may mislead users. + +**Related Findings:** +- disconnect does not emit offline status — doc implies it does + +#### CHAT-039 — Typing indicator — start and stop events + +**Priority:** P1 + +**Steps:** +1. User A and User B both join the chat room via sockets +2. User A emits typing-start with { chatId, userId, userName } +3. Assert User B's socket client receives user-typing event with isTyping=true (or equivalent) and does NOT receive it on their own socket +4. User A emits typing-stop with { chatId, userId } +5. Assert User B's socket client receives user-typing event indicating User A stopped typing +6. Assert no DB record is created for typing events + +**Expected Result:** Typing indicator is relayed to other room members only. Sender does not receive the event. No persistence occurs. + +#### CHAT-040 — Typing indicator — rate limit (5 events per 10 seconds) + +**Priority:** P2 + +**Steps:** +1. User A connects and joins a chat room +2. Emit typing-start 6 times within 10 seconds +3. Assert the 6th event is dropped or ignored by the backend +4. Assert User B receives at most 5 user-typing events in that window + +**Expected Result:** Backend rate-limits typing events to 5 per user per 10 seconds. Excess events are silently dropped server-side. + +**Related Findings:** +- Backend enforces rate limiting (20 msgs/min) and message deduplication — not documented in flow + +#### CHAT-041 — Send message with reply-to reference + +**Priority:** P1 + +**Steps:** +1. User A sends an initial message and captures its messageId +2. User B sends a reply: POST /api/chat/:chatId/messages with body { content: 'Reply text', replyTo: '' } +3. Assert HTTP 201 and the response message includes replyTo populated with the original message +4. Assert the chat UI renders the quoted/replied message correctly + +**Expected Result:** Reply message is created with a reference to the original message. UI renders the reply thread correctly. + +#### CHAT-042 — System messages are broadcast via socket on chat creation + +**Priority:** P2 + +**Steps:** +1. Listen on a socket for new-message events in the chat room +2. Trigger chat creation (POST /api/chat) +3. Assert that a new-message event is received for the system welcome message +4. If the chat is linked to a PurchaseRequest, assert a second system message in Persian is also received + +**Expected Result:** System messages generated at chat creation (welcome and Persian-language message for purchase request context) are broadcast via new-message socket event. + +#### CHAT-043 — GET /api/chat/stats returns correct aggregated counts + +**Priority:** P2 + +**Steps:** +1. Authenticate as User A who has 3 chats, 2 of which have unread messages (total 5 unread) +2. GET /api/chat/stats +3. Assert HTTP 200 and response contains { totalChats: 3, unreadChats: 2, totalUnreadMessages: 5 } + +**Expected Result:** Stats endpoint returns correct counts. Note: there is no frontend UI for this endpoint — verify via direct API call only. + +**Related Findings:** +- GET /api/chat/stats endpoint exists but has no dedicated dashboard UI + +#### CHAT-044 — Unauthenticated requests are rejected on all chat endpoints + +**Priority:** P0 + +**Steps:** +1. Without any authentication token, attempt: GET /api/chat, POST /api/chat, POST /api/chat/:chatId/messages, GET /api/chat/:chatId/messages, PATCH /api/chat/:chatId/messages/read +2. Assert HTTP 401 for all requests + +**Expected Result:** All chat API endpoints require authentication. Unauthenticated requests return 401. + +#### CHAT-045 — Concurrent markAsRead race condition is harmless + +**Priority:** P2 + +**Steps:** +1. User B has 5 unread messages +2. Simultaneously fire two PATCH /api/chat/:chatId/messages/read requests from User B (simulate with parallel API calls) +3. Assert both return HTTP 200 (no 500 errors) +4. Assert unreadCounts for User B is 0 after both complete (double-zeroing is harmless, not negative) + +**Expected Result:** Concurrent read-mark requests do not cause errors or negative unreadCounts. Final state is correct (zero unread). + +**Related Findings:** +- Race condition on markAsRead: two parallel read requests may double-zero the unreadCounts counter, which is harmless + +#### CHAT-046 — Authenticated user can only read chats they participate in + +**Priority:** P0 + +**Steps:** +1. Authenticate as User C (not a participant in User A and User B's chat) +2. GET /api/chat/:chatId/messages (where chatId belongs to A+B chat) +3. Assert HTTP 403 or 404 — User C must not see messages from a chat they are not part of + +**Expected Result:** Backend enforces participant-level authorization. Non-participants cannot read message history. + +#### CHAT-047 — Archived chat does not appear in active chat list + +**Priority:** P1 + +**Steps:** +1. Authenticate as User A with 2 active chats +2. Archive one chat via PATCH /api/chat/:chatId/archive +3. GET /api/chat +4. Assert the archived chat is excluded from the default list (or has isArchived=true if returned separately) +5. Unarchive the chat and confirm it reappears in the active list + +**Expected Result:** Archived chats are hidden from the main chat list. Unarchiving restores visibility. + +**Related Findings:** +- PATCH /api/chat/:id/archive toggles archived state — unarchive path is undocumented + +#### CHAT-048 — Message content field empty string is currently allowed (known gap) + +**Priority:** P2 + +**Steps:** +1. Authenticate as User A in an existing chat +2. POST /api/chat/:chatId/messages with body { content: '' } +3. Assert whether the backend accepts or rejects this (no min-length validator currently exists) +4. Assert the frontend does not allow sending empty messages (UI-level check) + +**Expected Result:** CURRENT BEHAVIOR (known gap): empty message content is accepted by the backend (no min-length validator). Frontend should prevent this at the UI level. A min-length validator is recommended. + +**Related Findings:** +- Empty message content is currently allowed (no min-length validator) + +#### CHAT-049 — Large conversation pagination efficiency (>10k messages) + +**Priority:** P2 + +**Steps:** +1. Identify or seed a chat with more than 1000 messages +2. GET /api/chat/:chatId/messages?page=1&limit=50 +3. Measure response time +4. Assert response time is under an acceptable threshold (e.g., 2 seconds) +5. Assert only 50 messages are returned (not full in-memory slice of all messages) + +**Expected Result:** Paginated message retrieval performs acceptably even on large conversations. Backend should not load all messages into memory for slicing. Flag if response time degrades significantly with message count. + +**Related Findings:** +- Long conversations (>10k messages): getChatMessages slices an in-memory copy of messages[], which is inefficient + +#### CHAT-050 — Purchase-request-linked chat uses dedicated endpoint, not generic POST /api/chat + +**Priority:** P1 + +**Steps:** +1. Authenticate as a buyer with a confirmed purchase request +2. POST /api/chat/purchase-request with body { purchaseRequestId: '', sellerId: '' } +3. Assert HTTP 201 and chat is linked to the purchase request +4. Attempt POST /api/chat with body { type: 'direct', participantIds: [''], relatedTo: { type: 'PurchaseRequest', id: '' } } +5. Assert the relatedTo field is not persisted on the resulting chat document + +**Expected Result:** Purchase-request context is only carried through the dedicated /purchase-request endpoint. The generic create endpoint ignores relatedTo. + +**Related Findings:** +- Flow doc CREATE CHAT body includes relatedTo field; backend API does not accept it at POST /api/chat +- POST /api/chat/purchase-request has no frontend UI or action wiring + +--- + +### Points, Rating & Referral + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| POINTS-RATING-REFERRAL-001 | Happy path: buyer submits a 5-star review for a seller after a completed purchase | P0 | Buyer has at least one PurchaseRequest with status 'completed' or 'finalized'... | +| POINTS-RATING-REFERRAL-002 | Happy path: aggregate rating stats (count, average, histogram) are computed correctly after multiple reviews | P0 | Seller has zero existing reviews. ShopSettings.allowSellerReviews = true. | +| POINTS-RATING-REFERRAL-003 | Duplicate review attempt returns 409 | P0 | Buyer already has a published review for the seller. | +| POINTS-RATING-REFERRAL-004 | Reviews disabled: POST and GET both return 403 when ShopSettings.allowSellerReviews = false | P0 | ShopSettings.allowSellerReviews = false for the target seller. | +| POINTS-RATING-REFERRAL-005 | Rating value outside 1–5 is rejected at schema level | P1 | Buyer is authenticated and has a completed purchase from the seller. | +| POINTS-RATING-REFERRAL-006 | Comment exceeding 1000 characters is rejected | P1 | Buyer is authenticated. | +| POINTS-RATING-REFERRAL-007 | Non-verified buyer review is stored with isVerifiedBuyer = false | P1 | Reviewer has no completed PurchaseRequest from the target seller. ShopSetting... | +| POINTS-RATING-REFERRAL-008 | Existing reviews become unreadable after seller disables reviews | P1 | Seller has at least 3 published reviews. ShopSettings.allowSellerReviews is c... | +| POINTS-RATING-REFERRAL-009 | Template review: POST and GET respect ShopSettings.allowTemplateReviews via owning seller lookup | P1 | Template owned by seller X exists. ShopSettings.allowTemplateReviews = true. | +| POINTS-RATING-REFERRAL-010 | PATCH /api/marketplace/reviews/:id — edit own review within the edit window | P2 | Buyer has a published review. Edit window (duration undefined in docs) has no... | +| POINTS-RATING-REFERRAL-011 | DELETE /api/marketplace/reviews/:id — behavior and authorization | P2 | A published review exists. | +| POINTS-RATING-REFERRAL-012 | Happy path: generate referral code and display share URL | P0 | User is authenticated and has no existing referral code. | +| POINTS-RATING-REFERRAL-013 | CRITICAL — generate-referral-code: 'force' param is silently ignored and code always regenerates | P0 | User is authenticated with an existing referral code. | +| POINTS-RATING-REFERRAL-014 | No 'Generate/Regenerate Code' button exists in the UI | P1 | User is authenticated and on the points dashboard. | +| POINTS-RATING-REFERRAL-015 | Happy path: new user signs up via referral link and referrer receives referral-signup socket notification | P0 | Referrer has a valid referral code. Referrer's browser is connected to Socket... | +| POINTS-RATING-REFERRAL-016 | CRITICAL — referrer receives 'referral-reward' (not 'referral-signup') when referred user completes a purchase | P0 | A referred user exists (referredBy is set). Referred user has an active Purch... | +| POINTS-RATING-REFERRAL-017 | CRITICAL — PointTransaction type 'refund' does not exist; only 'earn', 'spend', 'expire' are valid | P0 | Admin or tester has API access. | +| POINTS-RATING-REFERRAL-018 | CRITICAL — GET /api/points/levels requires authentication (not public) | P0 | No JWT token available. | +| POINTS-RATING-REFERRAL-019 | CRITICAL — POST /api/points/redeem requires 'pointsToUse' and 'purchaseRequestId', not 'amount' | P0 | User is authenticated with at least 100 available points. A PurchaseRequest i... | +| POINTS-RATING-REFERRAL-020 | Points redemption has no UI — redeemPoints action is never invoked from any component | P0 | User is authenticated and on any checkout or purchase flow. | +| POINTS-RATING-REFERRAL-021 | CRITICAL — GET /api/points/leaderboard period filter is silently ignored | P0 | Multiple users have referral transactions spanning more than one week. | +| POINTS-RATING-REFERRAL-022 | CRITICAL — GET /api/points/transactions type filter only accepts 'earn', 'spend', 'expire' | P0 | User has transactions of various sources (referral, purchase, admin). | +| POINTS-RATING-REFERRAL-023 | CRITICAL — POST /api/points/admin/add: 'reason' field is not stored; 'description' is read but silently dropped | P0 | Admin JWT is available. | +| POINTS-RATING-REFERRAL-024 | Referrals list page /dashboard/points/referrals returns 404 | P1 | User is authenticated. | +| POINTS-RATING-REFERRAL-025 | Transactions full history page /dashboard/points/transactions returns 404 | P1 | User is authenticated and on the points dashboard. | +| POINTS-RATING-REFERRAL-026 | Levels/tiers page /dashboard/points/levels returns 404 | P1 | User is authenticated. | +| POINTS-RATING-REFERRAL-027 | Admin points management page does not exist; adminAddPoints only accessible via direct API | P1 | Admin is authenticated. | +| POINTS-RATING-REFERRAL-028 | Self-referral is not blocked — a user can attribute themselves as their own referee | P1 | Tester has an existing account with a referral code. | +| POINTS-RATING-REFERRAL-029 | activeReferrals counts all referred users, not only those with completed purchases | P2 | Referrer has 3 referred users: 1 has completed a purchase, 2 have only signed... | +| POINTS-RATING-REFERRAL-030 | Point expiry: 'expire' transaction type is never created and expiresAt is never enforced | P2 | User has earned points over time. No expiry mechanism is running. | +| POINTS-RATING-REFERRAL-031 | Referral code uniqueness guarantee under concurrent generation | P2 | Test environment capable of parallel requests. | +| POINTS-RATING-REFERRAL-032 | Level-up event is emitted when a user crosses a tier threshold via points award | P1 | User is near a LevelConfig tier threshold. User's browser is connected to Soc... | +| POINTS-RATING-REFERRAL-033 | Referral reward is awarded on 'completed' status only — NOT on 'delivered' | P0 | Referred user has an active PurchaseRequest with an accepted offer. Referrer'... | +| POINTS-RATING-REFERRAL-034 | Referral code with leading/trailing spaces is trimmed before lookup | P2 | A valid referral code exists. | +| POINTS-RATING-REFERRAL-035 | Points balance read: GET /api/points/my-points returns correct total and available amounts | P0 | User has a known points history (e.g., earned 200, spent 50). | +| POINTS-RATING-REFERRAL-036 | Points spend creates a PointTransaction of type 'spend' with negative amount | P1 | User has at least 100 available points and a valid PurchaseRequest. | +| POINTS-RATING-REFERRAL-037 | Spend rejected when available points are insufficient | P1 | User has 10 available points. | +| POINTS-RATING-REFERRAL-038 | GET /api/points/transactions — pagination and default limit | P2 | User has more than 10 transaction records. | +| POINTS-RATING-REFERRAL-039 | Atomic addPoints: concurrent point awards do not corrupt the balance | P2 | User is eligible for points from two simultaneous events (e.g., two referral ... | +| POINTS-RATING-REFERRAL-040 | Unauthenticated access to points endpoints returns 401 | P1 | No JWT token. | +| POINTS-RATING-REFERRAL-041 | GET /r/:code redirect works end-to-end | P0 | A valid referral code exists. | +| POINTS-RATING-REFERRAL-042 | Invalid or non-existent referral code during sign-up is handled gracefully | P1 | None. | +| POINTS-RATING-REFERRAL-043 | Referrer deleted — attributed referee is still registered but effectively un-attributed | P3 | A referrer user exists with at least one referee. | +| POINTS-RATING-REFERRAL-044 | Referral code is trimmed but full character set is valid (ABCDEFGHJKLMNPQRSTUVWXYZ23456789) | P3 | None. | +| POINTS-RATING-REFERRAL-045 | Review status 'pending' and 'rejected' exist in schema but have no UI — admin must use direct DB access | P2 | A published review exists. | +| POINTS-RATING-REFERRAL-046 | computeStats performance: aggregate query on high review volume | P3 | A seller or template with a large number of reviews (1000+) exists in a stagi... | +| POINTS-RATING-REFERRAL-047 | GET /api/points/leaderboard returns correct top-N referrers by all-time points | P1 | Multiple users with different points totals exist. | +| POINTS-RATING-REFERRAL-048 | Referral share link exposes API server URL — confirm functional despite non-clean domain | P1 | NEXT_PUBLIC_API_URL is set to the production API base URL. | +| POINTS-RATING-REFERRAL-049 | Reciprocal rating flow (seller rates buyer) is entirely undocumented — verify behavior | P2 | A completed purchase exists. | +| POINTS-RATING-REFERRAL-050 | metadata.rating stamped on PurchaseRequest — verify trigger condition | P2 | A buyer submits a review linked to a purchaseRequestId. | + +#### POINTS-RATING-REFERRAL-001 — Happy path: buyer submits a 5-star review for a seller after a completed purchase + +**Priority:** P0 + +**Preconditions:** Buyer has at least one PurchaseRequest with status 'completed' or 'finalized' from the target seller. ShopSettings.allowSellerReviews = true for that seller. Buyer is authenticated. + +**Steps:** +1. Authenticate as the buyer. +2. Navigate to the seller's profile or the completed request detail page. +3. Click 'Leave review'. +4. Select 5 stars. +5. Enter a comment of up to 1000 characters. +6. Submit the form. +7. Verify the POST /api/marketplace/reviews response is HTTP 201. +8. Fetch GET /api/marketplace/reviews/seller/{sellerId} and inspect the returned stats. + +**Expected Result:** Review is stored in the reviews collection with status 'published', isVerifiedBuyer = true, rating = 5. Aggregated stats reflect count += 1 and updated average. No duplicate review is possible for the same reviewer/seller pair. + +#### POINTS-RATING-REFERRAL-002 — Happy path: aggregate rating stats (count, average, histogram) are computed correctly after multiple reviews + +**Priority:** P0 + +**Preconditions:** Seller has zero existing reviews. ShopSettings.allowSellerReviews = true. + +**Steps:** +1. Submit a 5-star review as buyer A. +2. Submit a 3-star review as buyer B. +3. Submit a 1-star review as buyer C. +4. Call GET /api/marketplace/reviews/seller/{sellerId}. +5. Inspect count, average, and per-star histogram fields. + +**Expected Result:** count = 3, average = 3.0, histogram shows star-5: 1, star-3: 1, star-1: 1. Values are recomputed live via computeStats (no denormalized counter). + +#### POINTS-RATING-REFERRAL-003 — Duplicate review attempt returns 409 + +**Priority:** P0 + +**Preconditions:** Buyer already has a published review for the seller. + +**Steps:** +1. Authenticate as the same buyer. +2. POST /api/marketplace/reviews with the same subjectType and subjectId. +3. Observe the HTTP response code and error message. + +**Expected Result:** HTTP 409 is returned. Error message indicates 'Already reviewed'. No second review document is created in MongoDB (unique index on {subjectType, subjectId, reviewerId} enforces this). + +#### POINTS-RATING-REFERRAL-004 — Reviews disabled: POST and GET both return 403 when ShopSettings.allowSellerReviews = false + +**Priority:** P0 + +**Preconditions:** ShopSettings.allowSellerReviews = false for the target seller. + +**Steps:** +1. Attempt POST /api/marketplace/reviews with a valid payload targeting that seller. +2. Attempt GET /api/marketplace/reviews/seller/{sellerId}. +3. Observe HTTP response codes for both calls. + +**Expected Result:** Both calls return HTTP 403 with 'Reviews disabled'. No review is stored. + +#### POINTS-RATING-REFERRAL-005 — Rating value outside 1–5 is rejected at schema level + +**Priority:** P1 + +**Preconditions:** Buyer is authenticated and has a completed purchase from the seller. + +**Steps:** +1. POST /api/marketplace/reviews with rating = 0. +2. POST /api/marketplace/reviews with rating = 6. +3. POST /api/marketplace/reviews with rating = -1. + +**Expected Result:** All three requests are rejected (HTTP 400 or 422). Mongoose schema validator blocks values outside the 1–5 range. + +#### POINTS-RATING-REFERRAL-006 — Comment exceeding 1000 characters is rejected + +**Priority:** P1 + +**Preconditions:** Buyer is authenticated. + +**Steps:** +1. POST /api/marketplace/reviews with a comment string of 1001 characters. +2. Observe response. + +**Expected Result:** HTTP 400 or 422 returned. Review is not stored. + +#### POINTS-RATING-REFERRAL-007 — Non-verified buyer review is stored with isVerifiedBuyer = false + +**Priority:** P1 + +**Preconditions:** Reviewer has no completed PurchaseRequest from the target seller. ShopSettings.allowSellerReviews = true. + +**Steps:** +1. Authenticate as a user who has never purchased from the seller. +2. POST /api/marketplace/reviews with a valid 3-star payload. +3. Inspect the stored review document. + +**Expected Result:** Review is created with status 'published' and isVerifiedBuyer = false. GET stats endpoint reflects the review. UI should surface an indicator that this reviewer is not verified. + +#### POINTS-RATING-REFERRAL-008 — Existing reviews become unreadable after seller disables reviews + +**Priority:** P1 + +**Preconditions:** Seller has at least 3 published reviews. ShopSettings.allowSellerReviews is currently true. + +**Steps:** +1. Confirm GET /api/marketplace/reviews/seller/{sellerId} returns reviews. +2. Set ShopSettings.allowSellerReviews = false for the seller. +3. Call GET /api/marketplace/reviews/seller/{sellerId} again. + +**Expected Result:** After the flag is toggled, the GET endpoint returns 403 'Reviews disabled'. Existing review documents remain in MongoDB but are not surfaced via the API. Toggling the flag back to true should restore visibility. + +#### POINTS-RATING-REFERRAL-009 — Template review: POST and GET respect ShopSettings.allowTemplateReviews via owning seller lookup + +**Priority:** P1 + +**Preconditions:** Template owned by seller X exists. ShopSettings.allowTemplateReviews = true. + +**Steps:** +1. POST /api/marketplace/reviews with subjectType='template' and a valid templateId. +2. Verify HTTP 201 and review stored. +3. Set ShopSettings.allowTemplateReviews = false. +4. Attempt POST and GET for the same templateId. +5. Observe responses. + +**Expected Result:** With flag true: review created. With flag false: both POST and GET return 403. isReviewsAllowed resolves the owning seller via the template's relationship. + +#### POINTS-RATING-REFERRAL-010 — PATCH /api/marketplace/reviews/:id — edit own review within the edit window + +**Priority:** P2 + +**Preconditions:** Buyer has a published review. Edit window (duration undefined in docs) has not yet elapsed. + +**Steps:** +1. Authenticate as the original reviewer. +2. PATCH /api/marketplace/reviews/{reviewId} with a new rating and comment. +3. Fetch GET for the review and verify updated values. +4. Re-fetch aggregate stats to confirm they reflect the updated rating. + +**Expected Result:** Review is updated. Aggregate stats are recomputed to reflect the changed rating. Note: the edit window duration is not defined in documentation — flag this as a gap if no server-side time check is enforced. + +#### POINTS-RATING-REFERRAL-011 — DELETE /api/marketplace/reviews/:id — behavior and authorization + +**Priority:** P2 + +**Preconditions:** A published review exists. + +**Steps:** +1. Authenticate as the original reviewer and DELETE /api/marketplace/reviews/{reviewId}. +2. Verify response and attempt to GET the review. +3. Authenticate as a different non-admin user and attempt to DELETE the same review. +4. Verify response. + +**Expected Result:** Reviewer can delete their own review; GET confirms it is gone. Another user cannot delete it (403 or 404). Document whether this is a hard delete or soft delete — behavior is unspecified in the doc. + +#### POINTS-RATING-REFERRAL-012 — Happy path: generate referral code and display share URL + +**Priority:** P0 + +**Preconditions:** User is authenticated and has no existing referral code. + +**Steps:** +1. Navigate to /dashboard/account/points (or referrals section). +2. Observe the invite-friends widget. +3. Check whether a referral code is already displayed (lazy bootstrap via getMyPoints). +4. If no code exists, attempt to trigger code generation. +5. Confirm the displayed share URL format. + +**Expected Result:** A referral code is shown. Share URL is displayed. Per finding POINTS-RATING-REFERRAL-018, the URL will show NEXT_PUBLIC_API_URL as the base (e.g. https://api.amn.gg/r/{code}) rather than the clean marketing URL https://amn.gg/r/{code}. Confirm functional redirect works even if URL is not the clean domain. + +**Related Findings:** +- minor: referral link uses NEXT_PUBLIC_API_URL + +#### POINTS-RATING-REFERRAL-013 — CRITICAL — generate-referral-code: 'force' param is silently ignored and code always regenerates + +**Priority:** P0 + +**Preconditions:** User is authenticated with an existing referral code. + +**Steps:** +1. Record the current referral code. +2. POST /api/points/generate-referral-code with body {}. +3. Record the new code. +4. POST /api/points/generate-referral-code again with body { force: false }. +5. Record the code again. +6. Verify the response does not include a 'link' field. + +**Expected Result:** Each call always regenerates and overwrites the code regardless of the force parameter. Response contains { referralCode } only — no 'link' field. The frontend invite-friends component must construct the URL client-side from NEXT_PUBLIC_API_URL. + +**Related Findings:** +- major: POST /points/generate-referral-code 'force' param silently ignored + +#### POINTS-RATING-REFERRAL-014 — No 'Generate/Regenerate Code' button exists in the UI + +**Priority:** P1 + +**Preconditions:** User is authenticated and on the points dashboard. + +**Steps:** +1. Navigate to /dashboard/account/points. +2. Inspect the invite-friends / referral widget for any button wired to generateReferralCode. +3. Attempt to find any UI control that rotates the code. + +**Expected Result:** No 'Generate Code' or 'Regenerate Code' button is present. The code displayed is the one bootstrapped by getMyPoints. Users cannot rotate their referral code via the UI. This is a confirmed missing feature. + +**Related Findings:** +- major: generateReferralCode action is never called from any component + +#### POINTS-RATING-REFERRAL-015 — Happy path: new user signs up via referral link and referrer receives referral-signup socket notification + +**Priority:** P0 + +**Preconditions:** Referrer has a valid referral code. Referrer's browser is connected to Socket.IO room user-{referrerId}. + +**Steps:** +1. Open referral share URL: {NEXT_PUBLIC_API_URL}/r/{code}. +2. Confirm HTTP 302 redirect to {FRONTEND_URL}/auth/jwt/sign-up?ref={code}. +3. Complete new user registration with the ref param pre-filled. +4. Observe the referrer's dashboard for a toast/notification. +5. Check that the referrer's referralStats.totalReferrals has incremented by 1. +6. Check that the new user document has referredBy = referrer._id. + +**Expected Result:** Referrer receives a 'referral-signup' socket event (emitted from authController.ts, NOT PointsService). Toast shows referee name, email, and updated total. No points are awarded at this stage — only sign-up attribution occurs. + +**Related Findings:** +- critical: referral-signup is an auth-domain event, not PointsService + +#### POINTS-RATING-REFERRAL-016 — CRITICAL — referrer receives 'referral-reward' (not 'referral-signup') when referred user completes a purchase + +**Priority:** P0 + +**Preconditions:** A referred user exists (referredBy is set). Referred user has an active PurchaseRequest with an accepted offer. + +**Steps:** +1. Monitor the referrer's socket room user-{referrerId} for events. +2. Advance the referred user's PurchaseRequest to status 'delivered'. +3. Observe whether any points or socket event is emitted. +4. Advance the PurchaseRequest to status 'completed'. +5. Observe socket events and referrer's points balance. + +**Expected Result:** No points are awarded on 'delivered'. On 'completed', PointsService.processReferralReward fires and emits 'referral-reward' (not 'referral-signup') to user-{referrerId}. Referrer's points.total and points.available increase by the commission amount (2% of offer price). A PointTransaction of type 'earn' with source 'referral' is created. + +**Related Findings:** +- critical: PointsService emits 'referral-reward', not 'referral-signup' +- major: referral reward triggered on 'completed' only, not 'delivered' + +#### POINTS-RATING-REFERRAL-017 — CRITICAL — PointTransaction type 'refund' does not exist; only 'earn', 'spend', 'expire' are valid + +**Priority:** P0 + +**Preconditions:** Admin or tester has API access. + +**Steps:** +1. Inspect the PointTransaction schema enum values via a GET /api/points/transactions response. +2. Attempt to create a transaction (if any admin endpoint accepts a type override) with type='refund'. +3. Cancel a purchase after points were redeemed and observe what transaction is created. +4. Check whether points are restored and what transaction type is used. + +**Expected Result:** No 'refund' type exists in the schema. Any attempt to create one fails validation. If a purchase cancellation restores points, the mechanism should create an 'earn' type transaction — confirm this is the case. No 'refund' record appears in the DB. + +**Related Findings:** +- critical: PointTransaction type 'refund' does not exist + +#### POINTS-RATING-REFERRAL-018 — CRITICAL — GET /api/points/levels requires authentication (not public) + +**Priority:** P0 + +**Preconditions:** No JWT token available. + +**Steps:** +1. Send GET /api/points/levels without an Authorization header. +2. Observe the HTTP response. +3. Send GET /api/points/levels with a valid JWT. +4. Observe the HTTP response. + +**Expected Result:** Without auth: HTTP 401 returned. With auth: HTTP 200 with level configurations. If any marketing or public-facing page intends to display tier info without login, a separate unauthenticated mechanism must exist — verify it does or does not. + +**Related Findings:** +- major: GET /points/levels is not public, requires authenticateToken + +#### POINTS-RATING-REFERRAL-019 — CRITICAL — POST /api/points/redeem requires 'pointsToUse' and 'purchaseRequestId', not 'amount' + +**Priority:** P0 + +**Preconditions:** User is authenticated with at least 100 available points. A PurchaseRequest in an appropriate state exists. + +**Steps:** +1. POST /api/points/redeem with body { amount: 100, purchaseRequestId: '{id}' }. +2. Observe HTTP response (expect failure). +3. POST /api/points/redeem with body { pointsToUse: 100, purchaseRequestId: '{id}' }. +4. Observe HTTP response and returned discount value. +5. Verify discount = pointsToUse * 1000 (IRR). +6. Verify 'purpose' field is not accepted and wallet_credit/discount_code options do not exist. + +**Expected Result:** Call with 'amount' fails (missing required field). Call with 'pointsToUse' succeeds. Response contains { transaction, discount (pointsToUse * 1000), remainingPoints } — no 'newBalance' or 'redemption' object. No currency flexibility exists. + +**Related Findings:** +- major: POST /points/redeem request/response shape mismatch + +#### POINTS-RATING-REFERRAL-020 — Points redemption has no UI — redeemPoints action is never invoked from any component + +**Priority:** P0 + +**Preconditions:** User is authenticated and on any checkout or purchase flow. + +**Steps:** +1. Navigate through the full purchase/checkout flow. +2. Look for any 'use points' option, discount code entry, or redemption prompt. +3. Navigate to /dashboard/account/points and look for a redeem button or form. +4. Search the UI for any element that triggers point redemption. + +**Expected Result:** No redemption UI exists anywhere. The redeemPoints action is defined in actions/points.ts but is never called from any component. Points-spend use-case is completely blocked for end users. Flag as a P0 missing feature blocking launch. + +**Related Findings:** +- major: points redemption has no UI + +#### POINTS-RATING-REFERRAL-021 — CRITICAL — GET /api/points/leaderboard period filter is silently ignored + +**Priority:** P0 + +**Preconditions:** Multiple users have referral transactions spanning more than one week. + +**Steps:** +1. GET /api/points/leaderboard?period=week. +2. GET /api/points/leaderboard?period=month. +3. GET /api/points/leaderboard?period=all. +4. GET /api/points/leaderboard (no period param). +5. Compare all four responses. + +**Expected Result:** All four responses return identical data — the period parameter is silently ignored. The backend only reads 'limit' from the query. All leaderboard results are all-time. Document this as a known limitation. + +**Related Findings:** +- major: leaderboard period filter silently ignored + +#### POINTS-RATING-REFERRAL-022 — CRITICAL — GET /api/points/transactions type filter only accepts 'earn', 'spend', 'expire' + +**Priority:** P0 + +**Preconditions:** User has transactions of various sources (referral, purchase, admin). + +**Steps:** +1. GET /api/points/transactions?type=referral. +2. GET /api/points/transactions?type=purchase. +3. GET /api/points/transactions?type=admin_grant. +4. GET /api/points/transactions?type=earn. +5. GET /api/points/transactions?type=spend. +6. Compare results. + +**Expected Result:** 'referral', 'purchase', and 'admin_grant' return 0 results or all results (no filtering effect). 'earn' and 'spend' correctly filter. There is no source-based filtering via the API. Confirm there is no way to isolate referral earnings from purchase earnings through the transactions endpoint. + +**Related Findings:** +- major: transactions type filter mismatch — doc lists semantic types, backend only has earn/spend/expire + +#### POINTS-RATING-REFERRAL-023 — CRITICAL — POST /api/points/admin/add: 'reason' field is not stored; 'description' is read but silently dropped + +**Priority:** P0 + +**Preconditions:** Admin JWT is available. + +**Steps:** +1. POST /api/points/admin/add with body { userId: '{id}', amount: 50, reason: 'compensation for outage' }. +2. Fetch the resulting PointTransaction document. +3. POST /api/points/admin/add with body { userId: '{id}', amount: 50, description: 'test description' }. +4. Fetch the resulting PointTransaction document. + +**Expected Result:** Neither 'reason' nor 'description' appears in the stored PointTransaction. The addPoints call is made with empty metadata {}. Admin-granted points have no human-readable audit trail stored. Flag as an audit trail gap. + +**Related Findings:** +- major: POST /points/admin/add request body mismatch — reason/description silently dropped + +#### POINTS-RATING-REFERRAL-024 — Referrals list page /dashboard/points/referrals returns 404 + +**Priority:** P1 + +**Preconditions:** User is authenticated. + +**Steps:** +1. Navigate directly to /dashboard/points/referrals. +2. Observe the page response. +3. Check whether any navigation link points to this route. + +**Expected Result:** The route returns a 404 or the parent layout with no content. No Next.js page file exists at /app/dashboard/points/referrals/. The getReferrals action is defined but never called from any component. This is a confirmed missing feature. + +**Related Findings:** +- major: referrals list page does not exist + +#### POINTS-RATING-REFERRAL-025 — Transactions full history page /dashboard/points/transactions returns 404 + +**Priority:** P1 + +**Preconditions:** User is authenticated and on the points dashboard. + +**Steps:** +1. On the points main view, click 'View All Transactions'. +2. Observe the navigation target (/dashboard/points/transactions). +3. Observe the page response. + +**Expected Result:** The route 404s. No Next.js page exists at /app/dashboard/points/transactions/. Only the first 5 transactions shown in the overview widget are accessible to users. Flag as missing feature. + +**Related Findings:** +- major: full paginated transactions page does not exist + +#### POINTS-RATING-REFERRAL-026 — Levels/tiers page /dashboard/points/levels returns 404 + +**Priority:** P1 + +**Preconditions:** User is authenticated. + +**Steps:** +1. Navigate to /dashboard/points/levels. +2. Observe the page response. +3. Confirm the PointsLevelProgress component in the main view only shows current vs next level (not the full tier ladder). + +**Expected Result:** Route 404s. No page file at /app/dashboard/points/levels/. getLevels action is never called. Users cannot see the full loyalty tier structure, thresholds, or benefits. + +**Related Findings:** +- major: levels/tiers page does not exist + +#### POINTS-RATING-REFERRAL-027 — Admin points management page does not exist; adminAddPoints only accessible via direct API + +**Priority:** P1 + +**Preconditions:** Admin is authenticated. + +**Steps:** +1. Navigate through all admin dashboard pages (/dashboard/admin/*). +2. Search for any point management, manual grant, or balance adjustment UI. +3. Test POST /api/points/admin/add via curl or Postman with a valid admin JWT. +4. Confirm the API call succeeds even though no UI exists. + +**Expected Result:** No admin UI for managing user points exists. The API endpoint is functional via direct HTTP calls. Admins must use API tooling or database access to grant/deduct points. + +**Related Findings:** +- major: admin points management page does not exist + +#### POINTS-RATING-REFERRAL-028 — Self-referral is not blocked — a user can attribute themselves as their own referee + +**Priority:** P1 + +**Preconditions:** Tester has an existing account with a referral code. + +**Steps:** +1. Obtain the referral code of the existing account. +2. Attempt to create a new account using that same referral code (or sign in to a secondary account with it). +3. Observe whether the attribution is blocked. +4. Check if referrer._id equals the new user._id. +5. Inspect referralStats.totalReferrals on the original account. + +**Expected Result:** No self-referral guard exists in authController.ts (lines ~700 and ~1130). The self-referral is attributed and totalReferrals increments. This is a known gaming vulnerability. Document as a confirmed gap needing a guard: if (referrer._id.equals(user._id)) return. + +**Related Findings:** +- minor: self-referral prevention is absent + +#### POINTS-RATING-REFERRAL-029 — activeReferrals counts all referred users, not only those with completed purchases + +**Priority:** P2 + +**Preconditions:** Referrer has 3 referred users: 1 has completed a purchase, 2 have only signed up. + +**Steps:** +1. Trigger a referral reward event (advance one referred user's purchase to 'completed'). +2. Call GET /api/points/my-points or GET /api/points/referrals. +3. Inspect referralStats.activeReferrals. +4. Compare activeReferrals vs totalReferrals. + +**Expected Result:** activeReferrals equals the total count of all users with referredBy = referrer._id (i.e., same as totalReferrals = 3), not just the 1 with a completed purchase. This conflates 'signed up' with 'active buyer'. Document as a metric accuracy gap. + +**Related Findings:** +- minor: activeReferrals meaning changed — counts all referred users not active buyers + +#### POINTS-RATING-REFERRAL-030 — Point expiry: 'expire' transaction type is never created and expiresAt is never enforced + +**Priority:** P2 + +**Preconditions:** User has earned points over time. No expiry mechanism is running. + +**Steps:** +1. Review a user's points balance after a long period (or with an old account). +2. Check PointTransaction records for any with type='expire'. +3. Confirm no TTL index or scheduled job runs to expire points. +4. Attempt to find any API call that creates a type='expire' transaction. + +**Expected Result:** No 'expire' type transactions exist. Points never expire regardless of age. The 'expire' enum value and expiresAt sparse index exist in the model but are unused. If point expiry is a business requirement, the scheduler is entirely missing. + +**Related Findings:** +- minor: point expiry — expiresAt field exists but no expiry enforcement + +#### POINTS-RATING-REFERRAL-031 — Referral code uniqueness guarantee under concurrent generation + +**Priority:** P2 + +**Preconditions:** Test environment capable of parallel requests. + +**Steps:** +1. Simultaneously fire 5 POST /api/points/generate-referral-code requests for 5 different users. +2. Check all returned codes for duplicates. +3. Verify each user's referralCode in the database is unique. + +**Expected Result:** All 5 codes are unique. The while-loop in generateReferralCode provides a uniqueness guarantee via User.findOne({ referralCode }). No two users share the same code. + +#### POINTS-RATING-REFERRAL-032 — Level-up event is emitted when a user crosses a tier threshold via points award + +**Priority:** P1 + +**Preconditions:** User is near a LevelConfig tier threshold. User's browser is connected to Socket.IO room user-{userId}. + +**Steps:** +1. Determine the threshold for the next tier from GET /api/points/levels. +2. Award enough points (via admin add or referral completion) to push the user past the threshold. +3. Observe socket events on the user's room. +4. Check GET /api/points/my-points for updated level. + +**Expected Result:** 'level-up' socket event is emitted to user-{userId}. Frontend toast is shown once. User's points.level is updated. Race condition note: two parallel addPoints calls might both trigger level-up emit — verify the frontend shows the toast only once (idempotent handling). + +#### POINTS-RATING-REFERRAL-033 — Referral reward is awarded on 'completed' status only — NOT on 'delivered' + +**Priority:** P0 + +**Preconditions:** Referred user has an active PurchaseRequest with an accepted offer. Referrer's points balance is known. + +**Steps:** +1. Advance the PurchaseRequest to status 'delivered'. +2. Check referrer's points balance immediately after. +3. Advance the PurchaseRequest to status 'completed'. +4. Check referrer's points balance again. +5. Verify the PointTransaction source = 'referral' appears only after 'completed'. + +**Expected Result:** No points are awarded at 'delivered'. Points are awarded only at 'completed'. If the escrow flow can end at 'delivered' without reaching 'completed', the referrer earns nothing. Confirm whether this is the intended business rule or a gap. + +**Related Findings:** +- major: referral reward triggered on 'completed' only, doc claims 'delivered or completed' + +#### POINTS-RATING-REFERRAL-034 — Referral code with leading/trailing spaces is trimmed before lookup + +**Priority:** P2 + +**Preconditions:** A valid referral code exists. + +**Steps:** +1. Submit registration with a referral code that has leading spaces (e.g. ' ABCD1234'). +2. Submit registration with a referral code that has trailing spaces (e.g. 'ABCD1234 '). +3. Observe whether referral attribution succeeds. + +**Expected Result:** Both submissions succeed. .trim() is applied in authController.ts (lines ~74 and ~127) before the lookup, so the spaces are stripped and the referrer is correctly attributed. + +#### POINTS-RATING-REFERRAL-035 — Points balance read: GET /api/points/my-points returns correct total and available amounts + +**Priority:** P0 + +**Preconditions:** User has a known points history (e.g., earned 200, spent 50). + +**Steps:** +1. Authenticate as the user. +2. GET /api/points/my-points. +3. Verify points.total, points.available, and points.level match expected values. +4. Verify the response includes the user's current level tier and name. + +**Expected Result:** points.total = lifetime earned (not reduced by spends, used for tier calculation). points.available = spendable balance (total minus spends). Level matches the LevelConfig threshold for the user's total. Response is HTTP 200. + +#### POINTS-RATING-REFERRAL-036 — Points spend creates a PointTransaction of type 'spend' with negative amount + +**Priority:** P1 + +**Preconditions:** User has at least 100 available points and a valid PurchaseRequest. + +**Steps:** +1. POST /api/points/redeem with { pointsToUse: 100, purchaseRequestId: '{id}' }. +2. Fetch GET /api/points/transactions. +3. Inspect the most recent transaction. + +**Expected Result:** A PointTransaction is created with type='spend', amount=-100 (or stored as negative), and a running balance. User's points.available decreases by 100. points.total is unchanged (total is lifetime, not spendable). + +#### POINTS-RATING-REFERRAL-037 — Spend rejected when available points are insufficient + +**Priority:** P1 + +**Preconditions:** User has 10 available points. + +**Steps:** +1. POST /api/points/redeem with { pointsToUse: 100, purchaseRequestId: '{id}' }. +2. Observe response. + +**Expected Result:** HTTP 400 or 422 returned. No transaction is created. Available balance remains 10. + +#### POINTS-RATING-REFERRAL-038 — GET /api/points/transactions — pagination and default limit + +**Priority:** P2 + +**Preconditions:** User has more than 10 transaction records. + +**Steps:** +1. GET /api/points/transactions with no params. +2. GET /api/points/transactions?page=2&limit=5. +3. Verify no overlap between page 1 and page 2 results. +4. GET /api/points/transactions?type=earn and verify only 'earn' type transactions are returned. + +**Expected Result:** Default pagination returns a manageable set. Pages are non-overlapping. type=earn filter works. type=spend filter works. type=referral returns empty or unfiltered (invalid type is silently ignored). + +**Related Findings:** +- major: transactions type filter mismatch + +#### POINTS-RATING-REFERRAL-039 — Atomic addPoints: concurrent point awards do not corrupt the balance + +**Priority:** P2 + +**Preconditions:** User is eligible for points from two simultaneous events (e.g., two referral purchases completing at the same time). + +**Steps:** +1. Trigger two simultaneous addPoints calls for the same user (e.g., two purchase completions in rapid succession). +2. After both resolve, check user.points.available and count PointTransaction records. +3. Verify the balance equals the sum of both awards. + +**Expected Result:** Both transactions are recorded and balance is correct (no lost update). MongoDB session in addPoints ensures atomicity. A potential double level-up emit is acceptable as the frontend handles it idempotently. + +#### POINTS-RATING-REFERRAL-040 — Unauthenticated access to points endpoints returns 401 + +**Priority:** P1 + +**Preconditions:** No JWT token. + +**Steps:** +1. GET /api/points/my-points without Authorization header. +2. GET /api/points/transactions without Authorization header. +3. POST /api/points/redeem without Authorization header. +4. POST /api/points/generate-referral-code without Authorization header. +5. GET /api/points/leaderboard without Authorization header. + +**Expected Result:** All endpoints return HTTP 401. No data is leaked to unauthenticated callers. + +**Related Findings:** +- major: GET /points/levels is not public + +#### POINTS-RATING-REFERRAL-041 — GET /r/:code redirect works end-to-end + +**Priority:** P0 + +**Preconditions:** A valid referral code exists. + +**Steps:** +1. Construct the URL: {API_BASE_URL}/r/{code}. +2. Open the URL (or issue GET /r/{code} without following redirects). +3. Observe the HTTP 302 redirect location. +4. Follow the redirect and confirm the sign-up page loads with ?ref={code} query param. + +**Expected Result:** HTTP 302 redirect to {FRONTEND_URL}/auth/jwt/sign-up?ref={code}. Sign-up form pre-fills or stores the referral code. The share URL exposes the API server URL (not amn.gg) unless NEXT_PUBLIC_API_URL is set to the clean domain. + +**Related Findings:** +- minor: referral link uses NEXT_PUBLIC_API_URL not clean marketing URL + +#### POINTS-RATING-REFERRAL-042 — Invalid or non-existent referral code during sign-up is handled gracefully + +**Priority:** P1 + +**Preconditions:** None. + +**Steps:** +1. Attempt registration at /auth/jwt/sign-up?ref=INVALIDCODE00. +2. Observe whether the sign-up proceeds or shows an error. +3. Check the new user document for referredBy field. + +**Expected Result:** Registration succeeds but no referral attribution is made. referredBy is not set. No error is thrown to the user (graceful degradation). totalReferrals on any user is not affected. + +#### POINTS-RATING-REFERRAL-043 — Referrer deleted — attributed referee is still registered but effectively un-attributed + +**Priority:** P3 + +**Preconditions:** A referrer user exists with at least one referee. + +**Steps:** +1. Delete the referrer account. +2. Check the referee's user document for referredBy field. +3. Trigger a purchase completion for the referee. +4. Observe whether processReferralReward errors or silently skips. + +**Expected Result:** The referee's referredBy still points to the deleted user's ID. processReferralReward should handle the missing referrer gracefully (no crash). The referee is effectively un-attributed for commission purposes. + +#### POINTS-RATING-REFERRAL-044 — Referral code is trimmed but full character set is valid (ABCDEFGHJKLMNPQRSTUVWXYZ23456789) + +**Priority:** P3 + +**Preconditions:** None. + +**Steps:** +1. Generate multiple referral codes and inspect their character composition. +2. Verify no O, 0, I, 1 characters appear (excluded from the charset). +3. Confirm all characters are from the documented safe charset. + +**Expected Result:** All generated codes are 8 characters using only ABCDEFGHJKLMNPQRSTUVWXYZ23456789. No visually ambiguous characters (O, 0, I, 1) are used. + +#### POINTS-RATING-REFERRAL-045 — Review status 'pending' and 'rejected' exist in schema but have no UI — admin must use direct DB access + +**Priority:** P2 + +**Preconditions:** A published review exists. + +**Steps:** +1. Search the admin dashboard for any review moderation UI. +2. Attempt to find any API endpoint that sets review status to 'pending' or 'rejected'. +3. Directly update a review's status to 'rejected' in MongoDB. +4. Attempt to GET the review via the public endpoint. +5. Verify whether rejected reviews are hidden or still visible. + +**Expected Result:** No moderation UI exists. No API endpoint allows setting review status. Only direct DB access can hide a review by setting status='rejected'. GET endpoint behavior for rejected reviews should be tested — confirm whether rejected reviews are excluded from the public response. + +#### POINTS-RATING-REFERRAL-046 — computeStats performance: aggregate query on high review volume + +**Priority:** P3 + +**Preconditions:** A seller or template with a large number of reviews (1000+) exists in a staging/performance environment. + +**Steps:** +1. Measure response time for GET /api/marketplace/reviews/seller/{sellerId} with 1000 reviews. +2. Measure with 5000 reviews. +3. Compare response times and check whether any caching is applied. + +**Expected Result:** Response times are acceptable under load. Note: no caching is implemented for computeStats aggregate (identified as a known performance concern in docs). If latency degrades significantly, flag for caching implementation. + +#### POINTS-RATING-REFERRAL-047 — GET /api/points/leaderboard returns correct top-N referrers by all-time points + +**Priority:** P1 + +**Preconditions:** Multiple users with different points totals exist. + +**Steps:** +1. GET /api/points/leaderboard?limit=5. +2. Verify the response contains at most 5 entries. +3. Verify the list is sorted by points in descending order. +4. GET /api/points/leaderboard?limit=100 and verify the cap is enforced. + +**Expected Result:** Returns up to 5 users sorted by all-time points (descending). The period parameter has no effect (all-time only). A reasonable hard cap on limit is enforced to prevent excessive data loading. + +**Related Findings:** +- major: leaderboard period filter silently ignored + +#### POINTS-RATING-REFERRAL-048 — Referral share link exposes API server URL — confirm functional despite non-clean domain + +**Priority:** P1 + +**Preconditions:** NEXT_PUBLIC_API_URL is set to the production API base URL. + +**Steps:** +1. Navigate to the points dashboard invite-friends widget. +2. Inspect the displayed referral URL. +3. Copy the URL and open it in a browser. +4. Confirm the redirect to the sign-up page works. +5. Confirm the URL does not show a clean marketing domain (e.g., amn.gg) unless NEXT_PUBLIC_API_URL is configured as such. + +**Expected Result:** The share link is {NEXT_PUBLIC_API_URL}/r/{code}. It functions correctly (redirects to sign-up). However, the URL exposes the API server base URL publicly. If NEXT_PUBLIC_API_URL = 'https://api.amn.gg', the link shows the API subdomain — not the clean amn.gg domain claimed in the docs. + +**Related Findings:** +- minor: referral link uses NEXT_PUBLIC_API_URL not https://amn.gg + +#### POINTS-RATING-REFERRAL-049 — Reciprocal rating flow (seller rates buyer) is entirely undocumented — verify behavior + +**Priority:** P2 + +**Preconditions:** A completed purchase exists. + +**Steps:** +1. Authenticate as a seller. +2. Attempt POST /api/marketplace/reviews with subjectType='buyer' (or equivalent). +3. Observe whether this is accepted or rejected. +4. Search the UI for any 'rate buyer' option on request detail pages. + +**Expected Result:** The seller-rates-buyer flow is mentioned in docs but has no steps, API detail, or UI. The behavior when a seller tries to rate a buyer is undefined. Document the actual behavior (likely rejected or unsupported) as a gap. + +#### POINTS-RATING-REFERRAL-050 — metadata.rating stamped on PurchaseRequest — verify trigger condition + +**Priority:** P2 + +**Preconditions:** A buyer submits a review linked to a purchaseRequestId. + +**Steps:** +1. POST /api/marketplace/reviews with a purchaseRequestId field included. +2. Fetch the PurchaseRequest document. +3. Check whether metadata.rating is set on the PurchaseRequest. +4. Submit a review without a purchaseRequestId and verify no stamp occurs. + +**Expected Result:** When purchaseRequestId is provided in the review payload, metadata.rating is stamped on the corresponding PurchaseRequest document (via routes.ts references). Without purchaseRequestId, no stamp occurs. Confirm the exact trigger and value written. + +--- + +### Trezor Safekeeping + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| TREZOR-001 | Happy path: Admin registers a Trezor xpub and receives a valid challenge | P0 | Admin account exists and is authenticated. Trezor hardware device connected. ... | +| TREZOR-002 | Happy path: Admin completes Trezor registration with valid xpub, address, and signature | P0 | Admin is authenticated. Valid xpub (not xprv) is available. registrationAddre... | +| TREZOR-003 | Happy path: Issue a deposit address for a payment | P0 | Admin Trezor account is registered. A valid paymentId exists. | +| TREZOR-004 | Happy path: Repeated address request for same paymentId returns same address without incrementing index | P0 | Admin Trezor account registered. A deposit address has already been issued fo... | +| TREZOR-005 | Happy path: Admin obtains operation message for a release | P0 | Admin is authenticated. Valid paymentId and transactionHash exist. Trezor acc... | +| TREZOR-006 | Happy path: Admin signs operation message and verify-operation succeeds | P0 | Admin Trezor account registered. Operation message obtained from TREZOR-005. ... | +| TREZOR-007 | Happy path: Release/refund succeeds when TREZOR_SAFEKEEPING_REQUIRED=false (no signature needed) | P0 | TREZOR_SAFEKEEPING_REQUIRED=false (or not set). Admin is authenticated. Payme... | +| TREZOR-008 | Critical gap: Admin release from frontend is blocked when TREZOR_SAFEKEEPING_REQUIRED=true | P0 | TREZOR_SAFEKEEPING_REQUIRED=true is set on the backend. Admin is authenticate... | +| TREZOR-009 | Critical gap: Admin refund from frontend is blocked when TREZOR_SAFEKEEPING_REQUIRED=true | P0 | TREZOR_SAFEKEEPING_REQUIRED=true. Admin is authenticated. Payment is in a ref... | +| TREZOR-010 | Critical gap: No Trezor registration UI exists — verify feature status | P0 | Running frontend instance. Admin account available. | +| TREZOR-011 | Edge case: Registration rejected when xpub is a private extended key (xprv/tprv) | P1 | Admin is authenticated. | +| TREZOR-012 | Edge case: Registration rejected when registrationAddress does not match xpub-derived index 0 | P1 | Admin is authenticated. Valid xpub available. | +| TREZOR-013 | Edge case: Registration rejected when signature does not recover the registrationAddress | P1 | Admin is authenticated. Valid xpub and correct registrationAddress (index 0) ... | +| TREZOR-014 | Edge case: TREZOR_SAFEKEEPING_REQUIRED set to non-'true' string is treated as disabled | P1 | Backend env has TREZOR_SAFEKEEPING_REQUIRED set to values like '1', 'yes', 'T... | +| TREZOR-015 | Edge case: Free-form signature rejected — must use exact operation-message output | P1 | TREZOR_SAFEKEEPING_REQUIRED=true. Admin Trezor registered. | +| TREZOR-016 | Edge case: Deposit address is proof of derivation only — not proof of payment | P1 | A deposit address has been issued for a paymentId. No actual on-chain transac... | +| TREZOR-017 | Security: verify-operation is admin-only — non-admin roles are rejected | P0 | Buyer and seller accounts exist and are authenticated. Valid operation payloa... | +| TREZOR-018 | Security: operation-message is admin-only — non-admin roles are rejected | P0 | Buyer and seller accounts exist and are authenticated. | +| TREZOR-019 | Role clarification: buyer/seller can call POST /api/trezor/register — verify what it enables | P1 | Buyer account exists, is authenticated, and has a valid Trezor xpub. | +| TREZOR-020 | Re-registration upsert: new xpub replaces old but existing address records are preserved | P1 | Admin Trezor account is registered with xpub-A. At least one deposit address ... | +| TREZOR-021 | Purpose field: all four valid values are accepted by POST /api/trezor/addresses/next | P1 | Admin Trezor account registered. Multiple unique paymentIds available. | +| TREZOR-022 | Purpose field: invalid purpose value is rejected | P2 | Admin Trezor account registered. Valid paymentId available. | +| TREZOR-023 | Replay attack prevention: reused operation signature is rejected | P0 | TREZOR_SAFEKEEPING_REQUIRED=true. Admin Trezor registered. Valid operation me... | +| TREZOR-024 | Canonical message: shuffled JSON key order in operation payload causes signature rejection | P1 | Admin Trezor registered. Valid operation parameters available. | +| TREZOR-025 | Canonical message: non-checksummed (lowercase) address in operation payload is normalized before verification | P1 | Admin Trezor registered. Valid operation parameters available. Address in pay... | +| TREZOR-026 | Unauthenticated requests to all Trezor endpoints are rejected | P0 | No authentication token. | +| TREZOR-027 | GET /api/trezor/account returns { registered: false } for a user with no Trezor registration | P1 | Admin account that has never registered a Trezor is authenticated. | +| TREZOR-028 | GET /api/trezor/account returns full account details after successful registration | P1 | Admin has completed Trezor registration (TREZOR-002). | +| TREZOR-029 | No socket event emitted after Trezor registration — response is synchronous only | P2 | Admin is authenticated and connected to the frontend WebSocket. | +| TREZOR-030 | No socket event emitted after address issuance — response is synchronous only | P2 | Admin Trezor registered. WebSocket connection active. | +| TREZOR-031 | Concurrency: simultaneous address requests for different paymentIds do not collide on index | P1 | Admin Trezor registered. At least 5 unique paymentIds available. | +| TREZOR-032 | Concurrency: simultaneous address requests for the SAME paymentId return the same address | P1 | Admin Trezor registered. One paymentId that has no address yet. | +| TREZOR-033 | Registration with missing required fields returns 4xx with descriptive error | P2 | Admin is authenticated. | +| TREZOR-034 | Operation message with missing required fields returns 4xx | P2 | Admin is authenticated. | +| TREZOR-035 | Ledger availability checks remain enabled during release/refund when TREZOR_SAFEKEEPING_REQUIRED=true | P0 | TREZOR_SAFEKEEPING_REQUIRED=true. Ledger availability check is configured. Ad... | +| TREZOR-036 | Registration message is invalidated after use — cannot reuse same challenge | P1 | Admin authenticated. Valid xpub and registrationAddress available. | +| TREZOR-037 | Verify no cross-tenant address leakage: admin A cannot retrieve addresses for admin B's account | P0 | Two separate admin accounts (admin-A and admin-B) each with registered Trezors. | +| TREZOR-038 | Verify no Trezor Connect SDK or /api/trezor/* calls appear in frontend network traffic during normal operation | P1 | Running frontend instance. Admin and buyer accounts available. | +| TREZOR-039 | POST /api/trezor/addresses/next returns 4xx for a paymentId that does not exist | P2 | Admin Trezor registered. | +| TREZOR-040 | POST /api/trezor/operation-message for an operation on a payment in wrong state is rejected | P2 | Admin authenticated. Payment in a state that cannot be released (e.g. already... | +| TREZOR-041 | Verify xpub is stored securely — not exposed in logs or error responses | P1 | Admin authenticated. Valid xpub available. | +| TREZOR-042 | Multisig upgrade path ambiguity — current single-signer path is not marked deprecated | P3 | Access to backend codebase and deployment documentation. | + +#### TREZOR-001 — Happy path: Admin registers a Trezor xpub and receives a valid challenge + +**Priority:** P0 + +**Preconditions:** Admin account exists and is authenticated. Trezor hardware device connected. Valid Ethereum xpub available at derivation path m/44'/60'/0'. + +**Steps:** +1. Authenticate as admin. +2. Call GET /api/trezor/registration-message?xpub=®istrationAddress=. +3. Verify the response is HTTP 200 and contains a challenge/message string. +4. Verify the message is deterministic for the same xpub and registrationAddress inputs. + +**Expected Result:** HTTP 200 with a non-empty challenge message that uniquely identifies the xpub and registrationAddress. No error body. + +**Related Findings:** +- Registration role ambiguity: doc says 'User' but operation endpoints are admin-only + +#### TREZOR-002 — Happy path: Admin completes Trezor registration with valid xpub, address, and signature + +**Priority:** P0 + +**Preconditions:** Admin is authenticated. Valid xpub (not xprv) is available. registrationAddress matches xpub-derived index 0 (m/44'/60'/0'/0/0). Challenge message obtained from TREZOR-001. + +**Steps:** +1. Obtain registration challenge via GET /api/trezor/registration-message. +2. Sign the challenge on the Trezor using the key at m/44'/60'/0'/0/0. +3. POST /api/trezor/register with body: { xpub, registrationAddress, proofMessage, proofSignature, basePath, deviceLabel }. +4. Verify HTTP 200/201 response. +5. Call GET /api/trezor/account and verify the account fields: xpubFingerprint, registrationAddress, basePath, nextAddressIndex, addressCount. + +**Expected Result:** Registration succeeds. GET /api/trezor/account returns { registered: true, registrationAddress: , nextAddressIndex: 0, addressCount: 0 }. + +**Related Findings:** +- GET /api/trezor/account endpoint not documented + +#### TREZOR-003 — Happy path: Issue a deposit address for a payment + +**Priority:** P0 + +**Preconditions:** Admin Trezor account is registered. A valid paymentId exists. + +**Steps:** +1. POST /api/trezor/addresses/next with body: { purpose: 'deposit', paymentId: '' }. +2. Verify HTTP 200 response contains a derived Ethereum address. +3. Verify the returned address matches the expected derivation at m/44'/60'/0'/0/{nextAddressIndex}. +4. Call GET /api/trezor/account and verify nextAddressIndex incremented by 1. + +**Expected Result:** A unique Ethereum address is returned. nextAddressIndex is incremented. The address is correctly derived from the registered xpub. + +#### TREZOR-004 — Happy path: Repeated address request for same paymentId returns same address without incrementing index + +**Priority:** P0 + +**Preconditions:** Admin Trezor account registered. A deposit address has already been issued for paymentId X. + +**Steps:** +1. Record the current nextAddressIndex via GET /api/trezor/account. +2. POST /api/trezor/addresses/next with { purpose: 'deposit', paymentId: '' }. +3. Verify the returned address is identical to the previously issued address. +4. Call GET /api/trezor/account and verify nextAddressIndex has NOT changed. + +**Expected Result:** Same address returned. nextAddressIndex unchanged. No duplicate address record created. + +#### TREZOR-005 — Happy path: Admin obtains operation message for a release + +**Priority:** P0 + +**Preconditions:** Admin is authenticated. Valid paymentId and transactionHash exist. Trezor account is registered for this admin. + +**Steps:** +1. POST /api/trezor/operation-message with body: { operation: 'release', paymentId: '', transactionHash: '', amount: '', currency: '', provider: 'request.network' }. +2. Verify HTTP 200 response contains a canonical message string. +3. Verify the message includes all submitted fields in a deterministic format. + +**Expected Result:** HTTP 200 with a canonical operation message ready to be signed on the Trezor. Message is deterministic for the same input. + +**Related Findings:** +- Canonical message construction details not documented + +#### TREZOR-006 — Happy path: Admin signs operation message and verify-operation succeeds + +**Priority:** P0 + +**Preconditions:** Admin Trezor account registered. Operation message obtained from TREZOR-005. Message signed on Trezor using registrationAddress key. + +**Steps:** +1. Obtain operation message via POST /api/trezor/operation-message. +2. Sign the message on the Trezor with the admin's registered key. +3. POST /api/trezor/verify-operation with the signed payload. +4. Verify HTTP 200 response indicating signature is valid. + +**Expected Result:** HTTP 200. Backend recovers the signer address from the ECDSA signature and confirms it matches the admin's registrationAddress. + +**Related Findings:** +- POST /api/trezor/verify-operation endpoint not documented + +#### TREZOR-007 — Happy path: Release/refund succeeds when TREZOR_SAFEKEEPING_REQUIRED=false (no signature needed) + +**Priority:** P0 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=false (or not set). Admin is authenticated. Payment is in a releasable state. + +**Steps:** +1. Confirm env var TREZOR_SAFEKEEPING_REQUIRED is not set to 'true'. +2. Initiate release from admin UI or call the release endpoint with { txHash } only (no trezor field). +3. Verify the release is processed successfully. + +**Expected Result:** Release completes without requiring a Trezor signature. HTTP 200. Payment status transitions to released. + +**Related Findings:** +- Release/refund confirmation does not include Trezor signature payload + +#### TREZOR-008 — Critical gap: Admin release from frontend is blocked when TREZOR_SAFEKEEPING_REQUIRED=true + +**Priority:** P0 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=true is set on the backend. Admin is authenticated. Payment is in a releasable state. + +**Steps:** +1. Confirm TREZOR_SAFEKEEPING_REQUIRED=true on the backend. +2. Trigger release from the admin frontend UI (using confirmReleaseTx in payment.ts). +3. Observe the HTTP response from the backend release endpoint. +4. Verify no Trezor signature is included in the frontend request body. + +**Expected Result:** Backend returns HTTP 4xx (likely 403 or 400) because no trezor object is present in the request. The release is blocked. The frontend displays an appropriate error — not a silent failure. + +**Related Findings:** +- Release/refund confirmation does not include Trezor signature payload +- No frontend implementation for any Trezor API endpoint + +#### TREZOR-009 — Critical gap: Admin refund from frontend is blocked when TREZOR_SAFEKEEPING_REQUIRED=true + +**Priority:** P0 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=true. Admin is authenticated. Payment is in a refundable state. + +**Steps:** +1. Confirm TREZOR_SAFEKEEPING_REQUIRED=true on the backend. +2. Trigger refund from the admin frontend UI (using confirmRefundTx in payment.ts). +3. Observe the HTTP response. +4. Inspect the outgoing request body to confirm absence of trezor field. + +**Expected Result:** Backend returns HTTP 4xx. Refund is blocked. Frontend shows an error. No partial state change occurs on the payment record. + +**Related Findings:** +- Release/refund confirmation does not include Trezor signature payload + +#### TREZOR-010 — Critical gap: No Trezor registration UI exists — verify feature status + +**Priority:** P0 + +**Preconditions:** Running frontend instance. Admin account available. + +**Steps:** +1. Log in as admin. +2. Search the navigation, settings, and admin panel for any Trezor registration or safekeeping section. +3. Attempt to navigate to any known route that might host a Trezor UI (e.g. /admin/trezor, /settings/trezor). +4. Search browser network tab for any requests to /api/trezor/* during normal admin navigation. +5. Check whether an external tool (non-Next.js) is documented or deployed to handle Trezor registration. + +**Expected Result:** Either: (a) a Trezor registration UI is found and functional, OR (b) no UI exists — confirm this is intentional (feature not yet deployed) and document it as a known gap. In case (b), verify the backend endpoints work correctly via direct API calls so the backend is not also broken. + +**Related Findings:** +- No frontend implementation for any Trezor API endpoint + +#### TREZOR-011 — Edge case: Registration rejected when xpub is a private extended key (xprv/tprv) + +**Priority:** P1 + +**Preconditions:** Admin is authenticated. + +**Steps:** +1. Obtain a valid xprv (private extended key) string. +2. Call GET /api/trezor/registration-message?xpub=®istrationAddress=. +3. If a message is returned, attempt POST /api/trezor/register with the xprv as the xpub field. + +**Expected Result:** Backend returns HTTP 4xx at registration message or register step. Error message indicates that private extended keys are not accepted. The key is not stored. + +#### TREZOR-012 — Edge case: Registration rejected when registrationAddress does not match xpub-derived index 0 + +**Priority:** P1 + +**Preconditions:** Admin is authenticated. Valid xpub available. + +**Steps:** +1. Derive the address at index 1 (m/44'/60'/0'/0/1) from the xpub — this is NOT index 0. +2. Call GET /api/trezor/registration-message with this mismatched registrationAddress. +3. If a message is returned, sign it and POST /api/trezor/register with the index-1 address as registrationAddress. + +**Expected Result:** Backend returns HTTP 4xx. Error clearly states the registrationAddress must match the xpub-derived address at index 0. No account record is created. + +#### TREZOR-013 — Edge case: Registration rejected when signature does not recover the registrationAddress + +**Priority:** P1 + +**Preconditions:** Admin is authenticated. Valid xpub and correct registrationAddress (index 0) obtained. + +**Steps:** +1. Obtain registration challenge via GET /api/trezor/registration-message. +2. Sign the challenge with a DIFFERENT private key (not the one corresponding to registrationAddress). +3. POST /api/trezor/register with the mismatched signature. + +**Expected Result:** Backend returns HTTP 4xx. Error indicates signature verification failed — recovered address does not match registrationAddress. No account is stored. + +#### TREZOR-014 — Edge case: TREZOR_SAFEKEEPING_REQUIRED set to non-'true' string is treated as disabled + +**Priority:** P1 + +**Preconditions:** Backend env has TREZOR_SAFEKEEPING_REQUIRED set to values like '1', 'yes', 'TRUE', 'enabled'. + +**Steps:** +1. Set TREZOR_SAFEKEEPING_REQUIRED='1' and attempt release without Trezor signature. +2. Restart backend and set TREZOR_SAFEKEEPING_REQUIRED='TRUE' (uppercase), attempt release. +3. Set TREZOR_SAFEKEEPING_REQUIRED='yes', attempt release. +4. Verify each case behaves as if safekeeping is DISABLED. + +**Expected Result:** Only the literal string 'true' enables enforcement. All other values (including '1', 'TRUE', 'yes') leave safekeeping disabled and releases/refunds proceed without Trezor signature. + +#### TREZOR-015 — Edge case: Free-form signature rejected — must use exact operation-message output + +**Priority:** P1 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=true. Admin Trezor registered. + +**Steps:** +1. Construct a free-form message string (e.g. 'I approve this release') and sign it on the Trezor. +2. POST /api/trezor/verify-operation with this free-form signature. +3. Also attempt to submit this signature as part of a release confirmation. + +**Expected Result:** Backend rejects both requests with HTTP 4xx. Only messages generated by POST /api/trezor/operation-message are accepted. Free-form signed messages do not pass verification. + +**Related Findings:** +- Canonical message construction details not documented + +#### TREZOR-016 — Edge case: Deposit address is proof of derivation only — not proof of payment + +**Priority:** P1 + +**Preconditions:** A deposit address has been issued for a paymentId. No actual on-chain transaction has occurred to that address. + +**Steps:** +1. Issue a deposit address via POST /api/trezor/addresses/next. +2. Do NOT send any funds to the address. +3. Verify that the payment status does NOT change to paid/confirmed. +4. Verify Ledger availability checks are not bypassed. + +**Expected Result:** Deposit address issuance has no effect on payment status. Payment remains in its pre-payment state. Ledger accounting checks remain active. + +#### TREZOR-017 — Security: verify-operation is admin-only — non-admin roles are rejected + +**Priority:** P0 + +**Preconditions:** Buyer and seller accounts exist and are authenticated. Valid operation payload available. + +**Steps:** +1. Authenticate as a buyer (non-admin role). +2. POST /api/trezor/verify-operation with a valid operation payload and signature. +3. Authenticate as a seller (non-admin role). +4. Repeat the same POST request. + +**Expected Result:** Both buyer and seller receive HTTP 401 or 403. The endpoint is restricted to admin role only. + +**Related Findings:** +- POST /api/trezor/verify-operation endpoint not documented +- Registration role ambiguity: doc says 'User' but operation endpoints are admin-only + +#### TREZOR-018 — Security: operation-message is admin-only — non-admin roles are rejected + +**Priority:** P0 + +**Preconditions:** Buyer and seller accounts exist and are authenticated. + +**Steps:** +1. Authenticate as a buyer. +2. POST /api/trezor/operation-message with a valid payload. +3. Authenticate as a seller. +4. Repeat the request. + +**Expected Result:** HTTP 401 or 403 for both non-admin roles. Only admins can generate operation messages. + +**Related Findings:** +- Registration role ambiguity: doc says 'User' but operation endpoints are admin-only + +#### TREZOR-019 — Role clarification: buyer/seller can call POST /api/trezor/register — verify what it enables + +**Priority:** P1 + +**Preconditions:** Buyer account exists, is authenticated, and has a valid Trezor xpub. + +**Steps:** +1. Authenticate as a buyer. +2. Complete the full registration flow (get challenge, sign, POST /api/trezor/register). +3. Verify the registration succeeds (HTTP 200/201). +4. Call GET /api/trezor/account as the buyer and verify the account is stored. +5. Verify that the buyer's registered Trezor address is NOT used as the safekeeping guard address for admin release/refund operations. + +**Expected Result:** Buyer can register a Trezor (no role restriction on /register). However, the safekeeping enforcement on release/refund uses the admin's TrezorAccount registrationAddress — not the buyer's. Document what buyer registration enables (if anything). + +**Related Findings:** +- Registration role ambiguity: doc says 'User' but operation endpoints are admin-only + +#### TREZOR-020 — Re-registration upsert: new xpub replaces old but existing address records are preserved + +**Priority:** P1 + +**Preconditions:** Admin Trezor account is registered with xpub-A. At least one deposit address has been issued. + +**Steps:** +1. Record the current nextAddressIndex and issued address from GET /api/trezor/account. +2. Generate a new valid xpub (xpub-B) on a different Trezor or path. +3. Complete the re-registration flow with xpub-B via POST /api/trezor/register. +4. Call GET /api/trezor/account and verify: xpub is now xpub-B, registrationAddress is the new one, but nextAddressIndex and addressCount are PRESERVED from before. +5. Call POST /api/trezor/addresses/next for a new paymentId and verify the new address is derived from xpub-B at the preserved nextAddressIndex. +6. Verify that old address records in the account still reference the original derivation (xpub-A based). + +**Expected Result:** xpub and registrationAddress updated. nextAddressIndex and addresses array preserved. New addresses derived from xpub-B — creating a potential address/xpub mismatch for old records. This mismatch should be flagged as a data integrity concern. + +**Related Findings:** +- Upsert behavior on re-registration not documented + +#### TREZOR-021 — Purpose field: all four valid values are accepted by POST /api/trezor/addresses/next + +**Priority:** P1 + +**Preconditions:** Admin Trezor account registered. Multiple unique paymentIds available. + +**Steps:** +1. POST /api/trezor/addresses/next with { purpose: 'deposit', paymentId: '' } — verify success. +2. POST /api/trezor/addresses/next with { purpose: 'release', paymentId: '' } — verify success. +3. POST /api/trezor/addresses/next with { purpose: 'refund', paymentId: '' } — verify success. +4. POST /api/trezor/addresses/next with { purpose: 'other', paymentId: '' } — verify success. +5. Verify each call returns a unique derived address and increments nextAddressIndex. + +**Expected Result:** All four purpose values (deposit, release, refund, other) are accepted with HTTP 200. Each returns a valid derived address. + +**Related Findings:** +- Purpose field valid values not documented but are enumerated in the schema + +#### TREZOR-022 — Purpose field: invalid purpose value is rejected + +**Priority:** P2 + +**Preconditions:** Admin Trezor account registered. Valid paymentId available. + +**Steps:** +1. POST /api/trezor/addresses/next with { purpose: 'invalid_purpose', paymentId: '' }. +2. POST /api/trezor/addresses/next with { purpose: '', paymentId: '' }. +3. POST /api/trezor/addresses/next with purpose field omitted entirely. + +**Expected Result:** HTTP 4xx for invalid or missing purpose. Error response indicates valid enum values. nextAddressIndex is not incremented. + +**Related Findings:** +- Purpose field valid values not documented but are enumerated in the schema + +#### TREZOR-023 — Replay attack prevention: reused operation signature is rejected + +**Priority:** P0 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=true. Admin Trezor registered. Valid operation message obtained and signed. + +**Steps:** +1. Obtain operation message via POST /api/trezor/operation-message for paymentId X. +2. Sign the message on the Trezor. +3. Submit the signature via POST /api/trezor/verify-operation — verify HTTP 200. +4. Immediately resubmit the SAME signature to POST /api/trezor/verify-operation. +5. Attempt to use the same signature in a release/refund confirmation. + +**Expected Result:** Second submission of the same signature is rejected with HTTP 4xx. The per-operation nonce is consumed on first use. Replay attacks are prevented. + +**Related Findings:** +- Per-operation nonce for replay prevention not documented + +#### TREZOR-024 — Canonical message: shuffled JSON key order in operation payload causes signature rejection + +**Priority:** P1 + +**Preconditions:** Admin Trezor registered. Valid operation parameters available. + +**Steps:** +1. Obtain the canonical operation message via POST /api/trezor/operation-message. +2. Manually construct an alternative message with the same fields but a different JSON key order. +3. Sign the manually constructed (non-canonical) message on the Trezor. +4. POST /api/trezor/verify-operation with this signature. +5. Compare rejection with the acceptance of a correctly-obtained canonical message signature. + +**Expected Result:** Non-canonical message signature is rejected. Only signatures over the exact message returned by /api/trezor/operation-message are accepted. + +**Related Findings:** +- Canonical message construction details not documented + +#### TREZOR-025 — Canonical message: non-checksummed (lowercase) address in operation payload is normalized before verification + +**Priority:** P1 + +**Preconditions:** Admin Trezor registered. Valid operation parameters available. Address in payload is known. + +**Steps:** +1. POST /api/trezor/operation-message with a lowercase (non-EIP-55) version of an address field. +2. Obtain the returned canonical message. +3. Verify the canonical message contains the EIP-55 checksummed version of the address. +4. Sign the canonical message and verify it passes POST /api/trezor/verify-operation. + +**Expected Result:** Backend normalizes the address to EIP-55 checksum format (ethers.getAddress) before building the canonical message. Signature verification succeeds when signing the normalized message. + +**Related Findings:** +- Canonical message construction details not documented + +#### TREZOR-026 — Unauthenticated requests to all Trezor endpoints are rejected + +**Priority:** P0 + +**Preconditions:** No authentication token. + +**Steps:** +1. Without any Authorization header, call GET /api/trezor/registration-message. +2. Without auth, call POST /api/trezor/register. +3. Without auth, call POST /api/trezor/addresses/next. +4. Without auth, call POST /api/trezor/operation-message. +5. Without auth, call POST /api/trezor/verify-operation. +6. Without auth, call GET /api/trezor/account. + +**Expected Result:** All six endpoints return HTTP 401 for unauthenticated requests. + +#### TREZOR-027 — GET /api/trezor/account returns { registered: false } for a user with no Trezor registration + +**Priority:** P1 + +**Preconditions:** Admin account that has never registered a Trezor is authenticated. + +**Steps:** +1. Authenticate as an admin with no prior Trezor registration. +2. Call GET /api/trezor/account. +3. Verify response shape. + +**Expected Result:** HTTP 200 with body { registered: false }. No error or 404. + +**Related Findings:** +- GET /api/trezor/account endpoint not documented + +#### TREZOR-028 — GET /api/trezor/account returns full account details after successful registration + +**Priority:** P1 + +**Preconditions:** Admin has completed Trezor registration (TREZOR-002). + +**Steps:** +1. Call GET /api/trezor/account as the registered admin. +2. Verify all documented fields are present: xpubFingerprint, registrationAddress, basePath, nextAddressIndex, addressCount. +3. Verify sensitive fields (full xpub private key — should not exist, but verify xpub itself is returned or only fingerprint). + +**Expected Result:** HTTP 200 with { registered: true, xpubFingerprint, registrationAddress, basePath, nextAddressIndex, addressCount }. Full xprv is never exposed. + +**Related Findings:** +- GET /api/trezor/account endpoint not documented + +#### TREZOR-029 — No socket event emitted after Trezor registration — response is synchronous only + +**Priority:** P2 + +**Preconditions:** Admin is authenticated and connected to the frontend WebSocket. + +**Steps:** +1. Open browser developer tools and monitor WebSocket frames. +2. Complete Trezor registration via POST /api/trezor/register. +3. Monitor WebSocket for 30 seconds after registration completes. +4. Verify the HTTP response from /register is sufficient to confirm success. + +**Expected Result:** No Trezor-specific socket event (e.g. trezor-registered) is emitted. Registration result is communicated entirely via the HTTP response. No polling is required. + +**Related Findings:** +- No socket events emitted for Trezor registration or address issuance + +#### TREZOR-030 — No socket event emitted after address issuance — response is synchronous only + +**Priority:** P2 + +**Preconditions:** Admin Trezor registered. WebSocket connection active. + +**Steps:** +1. Monitor WebSocket frames. +2. POST /api/trezor/addresses/next for a new paymentId. +3. Verify the HTTP response contains the derived address. +4. Monitor WebSocket for 15 seconds for any address-issued event. + +**Expected Result:** No socket event for address issuance. Address is returned synchronously in the HTTP response body only. + +**Related Findings:** +- No socket events emitted for Trezor registration or address issuance + +#### TREZOR-031 — Concurrency: simultaneous address requests for different paymentIds do not collide on index + +**Priority:** P1 + +**Preconditions:** Admin Trezor registered. At least 5 unique paymentIds available. + +**Steps:** +1. Send 5 concurrent POST /api/trezor/addresses/next requests simultaneously, each with a distinct paymentId. +2. Collect all 5 returned addresses. +3. Verify all 5 addresses are distinct (no duplicates). +4. Verify GET /api/trezor/account shows nextAddressIndex incremented by exactly 5. + +**Expected Result:** All 5 addresses are unique. Index increments atomically — no two requests receive the same index. nextAddressIndex = initial + 5. + +#### TREZOR-032 — Concurrency: simultaneous address requests for the SAME paymentId return the same address + +**Priority:** P1 + +**Preconditions:** Admin Trezor registered. One paymentId that has no address yet. + +**Steps:** +1. Send 5 concurrent POST /api/trezor/addresses/next requests simultaneously, all with the SAME paymentId. +2. Collect all 5 returned addresses. +3. Verify all 5 addresses are identical. +4. Verify nextAddressIndex incremented by exactly 1 (not 5). + +**Expected Result:** All 5 responses return the same address. Index increments by 1, not 5. Idempotency is maintained under concurrent load. + +#### TREZOR-033 — Registration with missing required fields returns 4xx with descriptive error + +**Priority:** P2 + +**Preconditions:** Admin is authenticated. + +**Steps:** +1. POST /api/trezor/register with xpub missing. +2. POST /api/trezor/register with registrationAddress missing. +3. POST /api/trezor/register with proofMessage missing. +4. POST /api/trezor/register with proofSignature missing. +5. POST /api/trezor/register with an empty body. + +**Expected Result:** HTTP 400 for each missing required field. Error body identifies which field is missing. No partial registration occurs. + +#### TREZOR-034 — Operation message with missing required fields returns 4xx + +**Priority:** P2 + +**Preconditions:** Admin is authenticated. + +**Steps:** +1. POST /api/trezor/operation-message omitting operation field. +2. POST /api/trezor/operation-message omitting paymentId. +3. POST /api/trezor/operation-message omitting transactionHash. +4. POST /api/trezor/operation-message omitting amount or currency. + +**Expected Result:** HTTP 400 for each missing required field. Descriptive error messages indicate what is missing. + +#### TREZOR-035 — Ledger availability checks remain enabled during release/refund when TREZOR_SAFEKEEPING_REQUIRED=true + +**Priority:** P0 + +**Preconditions:** TREZOR_SAFEKEEPING_REQUIRED=true. Ledger availability check is configured. Admin has valid Trezor registered. + +**Steps:** +1. Configure a scenario where Ledger availability check would normally block a release (e.g. insufficient ledger balance). +2. Provide a valid Trezor signature for the operation. +3. Attempt the release/refund with the valid Trezor signature. +4. Verify the Ledger check is still enforced despite the valid Trezor signature. + +**Expected Result:** Trezor signature validates the admin's intent, but does NOT bypass Ledger availability checks. The release is still blocked if Ledger conditions are not met. + +#### TREZOR-036 — Registration message is invalidated after use — cannot reuse same challenge + +**Priority:** P1 + +**Preconditions:** Admin authenticated. Valid xpub and registrationAddress available. + +**Steps:** +1. Obtain registration challenge via GET /api/trezor/registration-message. +2. Sign and successfully register via POST /api/trezor/register. +3. Attempt to use the same challenge and signature in a second POST /api/trezor/register call. + +**Expected Result:** The second registration attempt with the same challenge either: (a) upserts the account (idempotent), OR (b) is rejected as a replayed challenge. In either case, no new record is created and no security bypass occurs. + +#### TREZOR-037 — Verify no cross-tenant address leakage: admin A cannot retrieve addresses for admin B's account + +**Priority:** P0 + +**Preconditions:** Two separate admin accounts (admin-A and admin-B) each with registered Trezors. + +**Steps:** +1. Authenticate as admin-A. +2. Issue a deposit address for paymentId-1 (recorded as belonging to admin-A's account). +3. Authenticate as admin-B. +4. Call GET /api/trezor/account and verify it returns admin-B's data only. +5. Attempt to call POST /api/trezor/addresses/next for paymentId-1 as admin-B. +6. Verify admin-B cannot read or issue addresses against admin-A's account. + +**Expected Result:** GET /api/trezor/account is scoped to the authenticated user's userId. Admin-B cannot access admin-A's address records or account data. + +**Related Findings:** +- No description of how the backend associates a trezorRegistration record with a specific payment or tenant + +#### TREZOR-038 — Verify no Trezor Connect SDK or /api/trezor/* calls appear in frontend network traffic during normal operation + +**Priority:** P1 + +**Preconditions:** Running frontend instance. Admin and buyer accounts available. + +**Steps:** +1. Open browser network tab and filter for 'trezor'. +2. Log in as buyer, browse payment pages, initiate a purchase. +3. Log in as admin, browse admin panel, view payments, attempt a release. +4. Review all network requests made during these flows. + +**Expected Result:** Zero requests to any /api/trezor/* endpoint are made automatically during normal operation. Trezor Connect SDK is not loaded. Confirms the feature is gated and not accidentally triggered. + +**Related Findings:** +- No frontend implementation for any Trezor API endpoint + +#### TREZOR-039 — POST /api/trezor/addresses/next returns 4xx for a paymentId that does not exist + +**Priority:** P2 + +**Preconditions:** Admin Trezor registered. + +**Steps:** +1. POST /api/trezor/addresses/next with a paymentId that does not correspond to any known payment in the system. +2. Observe the response. + +**Expected Result:** HTTP 4xx (likely 404 or 400). Error body indicates the paymentId is invalid. nextAddressIndex is not incremented. No address is persisted. + +#### TREZOR-040 — POST /api/trezor/operation-message for an operation on a payment in wrong state is rejected + +**Priority:** P2 + +**Preconditions:** Admin authenticated. Payment in a state that cannot be released (e.g. already released or still pending). + +**Steps:** +1. POST /api/trezor/operation-message with operation: 'release' for an already-released paymentId. +2. Verify the response. +3. Repeat for a pending payment that is not yet eligible for release. + +**Expected Result:** Backend returns HTTP 4xx indicating the operation is not valid for the current payment state. A canonical message is not generated for invalid state transitions. + +#### TREZOR-041 — Verify xpub is stored securely — not exposed in logs or error responses + +**Priority:** P1 + +**Preconditions:** Admin authenticated. Valid xpub available. + +**Steps:** +1. Complete registration with a known xpub value. +2. Monitor backend logs during registration for the full xpub string. +3. Intentionally trigger a registration failure (e.g. bad signature) and inspect the error response body for xpub leakage. +4. Call GET /api/trezor/account and verify only xpubFingerprint is returned, not the full xpub. + +**Expected Result:** Full xpub is not present in error response bodies or debug logs. GET /api/trezor/account exposes only xpubFingerprint. The full xpub is stored server-side only. + +#### TREZOR-042 — Multisig upgrade path ambiguity — current single-signer path is not marked deprecated + +**Priority:** P3 + +**Preconditions:** Access to backend codebase and deployment documentation. + +**Steps:** +1. Review backend API responses and documentation for any deprecation warnings on the current single-signer endpoints. +2. Verify no breaking API contract changes have been silently introduced. +3. Check whether the multisig upgrade path (referenced in PRD) requires changes to POST /api/trezor/register or addresses/next contracts. +4. Confirm with stakeholders whether the current single-signer path is production-ready or considered temporary. + +**Expected Result:** Either: (a) current single-signer path is explicitly documented as production-ready, OR (b) it is marked as temporary with a clear migration path to multisig. No ambiguity about production readiness should remain before launch. + +**Related Findings:** +- The upgrade path to multisig is described as 'recommended production path' but the current single-signer path is not marked as temporary or deprecated + +--- + +### Admin Operations + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| ADMIN-001 | POST /api/payment/payments/:id/fetch-tx is accessible without authentication | P0 | At least one payment record exists in the system | +| ADMIN-002 | POST /api/payment/payments/auto-fetch-missing is accessible without authentication | P0 | Backend service is running | +| ADMIN-003 | GET /api/payment/payments/:id/debug is accessible without authentication | P0 | At least one payment record exists | +| ADMIN-004 | GET /api/admin/scanner/status is accessible without authentication | P0 | Backend is running and AMN_SCANNER_URL is configured | +| ADMIN-005 | GET /api/admin/scanner/status returns correct scanner data for authenticated admin | P1 | Admin account exists; AMN_SCANNER_URL is reachable | +| ADMIN-006 | Shkeeper release endpoint: documented path returns 404, correct path returns expected response | P0 | A payment in fundable/releasable state exists; admin JWT available | +| ADMIN-007 | Shkeeper refund endpoint: documented /shkeeper/ path returns 404, correct path succeeds | P0 | A payment eligible for refund exists | +| ADMIN-008 | Shkeeper release/confirm and refund/confirm documented paths return 404 | P1 | Payments in appropriate states exist; admin JWT available | +| ADMIN-009 | User admin endpoints: singular /api/user/admin/* paths return 404 | P0 | Admin JWT available; a non-admin user ID is known | +| ADMIN-010 | User admin endpoints: plural /api/users/admin/* paths succeed for authorized admin | P1 | Admin JWT; a test user account available for manipulation | +| ADMIN-011 | updateUserStatus frontend action uses PUT — verify backend accepts PUT for /api/users/admin/:id/status | P0 | Admin account logged in; non-admin user exists in system | +| ADMIN-012 | updateUserRole frontend action uses PUT — verify backend accepts PUT for /api/users/admin/:id/role | P0 | Admin account; non-admin user exists | +| ADMIN-013 | User status values: verify backend accepts 'inactive' and 'pending' sent by frontend | P1 | Admin JWT; test user exists | +| ADMIN-014 | POST /api/admin/cleanup/clean with dryRun=false and no confirm field is rejected | P0 | Admin JWT available; do NOT run this against production data | +| ADMIN-015 | POST /api/admin/cleanup/clean with dryRun=true performs dry run without deleting data | P1 | Admin JWT; staging/test environment only | +| ADMIN-016 | POST /api/admin/cleanup/clean with dryRun=false and confirm='DELETE_ALL_DATA' performs actual cleanup | P1 | Admin JWT; ONLY on staging/test environment with disposable data | +| ADMIN-017 | GET /api/admin/settings/aml returns current AML configuration | P1 | Admin JWT; AML settings configured in env | +| ADMIN-018 | PATCH /api/admin/settings/aml updates AML provider at runtime | P1 | Admin JWT; access to restart service in staging | +| ADMIN-019 | PATCH /api/admin/settings/aml is rejected for non-admin authenticated users | P1 | Non-admin user JWT available | +| ADMIN-020 | GET /api/admin/settings/confirmation-thresholds returns per-chain confirmation counts | P1 | Admin JWT; at least one blockchain network configured | +| ADMIN-021 | PATCH /api/admin/settings/confirmation-thresholds/:chainId updates the threshold for a specific chain | P1 | Admin JWT; known chainId in the system | +| ADMIN-022 | GET /api/admin/settings/confirmation-thresholds/history returns 404 (unimplemented endpoint) | P1 | Admin JWT | +| ADMIN-023 | Confirmation-thresholds admin page loads without crashing when history endpoint returns 404 | P1 | Admin account logged in; browser with DevTools | +| ADMIN-024 | GET /api/admin/payments/awaiting-confirmation returns payments pending confirmation | P1 | Admin JWT; at least one payment in awaiting-confirmation state | +| ADMIN-025 | GET /api/admin/rn/networks returns network registry list | P1 | Admin JWT; backend has at least one configured RN network | +| ADMIN-026 | Network registry Reload and Probe buttons return 404 (unimplemented backend routes) | P1 | Admin account logged in; browser DevTools open | +| ADMIN-027 | Derived-destinations list page loads and displays current destinations | P1 | Admin account; at least one derived destination exists | +| ADMIN-028 | Derived-destinations cron status endpoint returns 404 (unimplemented) | P1 | Admin account; DevTools open | +| ADMIN-029 | Start/Stop sweep cron and single-destination sweep UI actions return 404 | P1 | Admin account; derived-destinations page accessible | +| ADMIN-030 | POST /api/payment/derived-destinations/sweep (bulk) succeeds with admin auth | P2 | Admin JWT; derived destinations with sweepable balance exist | +| ADMIN-031 | GET /api/disputes/statistics returns 200 for non-admin authenticated user (authorization gap) | P0 | Non-admin user JWT; at least some dispute data exists | +| ADMIN-032 | GET /api/disputes/statistics returns data for admin user | P1 | Admin JWT; dispute records exist | +| ADMIN-033 | POST /api/payment/payments/cleanup-pending rejects non-admin authenticated user | P0 | Non-admin user JWT | +| ADMIN-034 | POST /api/payment/payments/cleanup-pending only deletes pending payments older than 2 hours | P1 | Admin JWT; payments in appropriate age/state combinations exist | +| ADMIN-035 | POST /api/points/admin/add rejects non-admin authenticated user | P0 | Non-admin user JWT; a target user exists | +| ADMIN-036 | POST /api/points/admin/add succeeds for admin and credits correct points | P1 | Admin JWT; a non-admin target user exists | +| ADMIN-037 | POST /api/disputes/:id/assign rejects non-admin authenticated user | P1 | Non-admin user JWT; a dispute in assignable state exists | +| ADMIN-038 | POST /api/disputes/:id/resolve rejects non-admin authenticated user | P1 | Non-admin user JWT; an open dispute exists | +| ADMIN-039 | Admin can assign a dispute to themselves and dispute status updates | P1 | Admin JWT; an open unassigned dispute exists | +| ADMIN-040 | Admin can resolve a dispute and resolution is persisted | P1 | Admin JWT; an open (and assigned) dispute exists | +| ADMIN-041 | GET /api/users/admin/stats returns aggregate user analytics for admin | P2 | Admin JWT; user records exist | +| ADMIN-042 | GET /api/users/admin/stats returns 403 for non-admin user | P1 | Non-admin user JWT | +| ADMIN-043 | PATCH /api/users/admin/:userId/password resets user password and clears refresh tokens | P1 | Admin JWT; target user with a known password exists | +| ADMIN-044 | POST /api/users/admin/:userId/resend-verification queues a verification email | P2 | Admin JWT; an unverified user exists; email service is configured | +| ADMIN-045 | PUT /api/users/admin/update/:email updates user by email address | P2 | Admin JWT; a user with the target email exists | +| ADMIN-046 | DELETE /api/admin/cleanup/user/:userId permanently deletes all user data (GDPR) | P1 | Admin JWT; a disposable test user exists in a staging environment | +| ADMIN-047 | GET /api/admin/cleanup/stats returns collection document counts | P2 | Admin JWT | +| ADMIN-048 | GET /api/blog/admin/posts returns all posts including unpublished for admin | P1 | Admin JWT; at least one published and one draft blog post exist | +| ADMIN-049 | POST /api/blog/posts creates a new blog post as admin | P1 | Admin JWT | +| ADMIN-050 | PUT /api/blog/posts/:id updates a blog post and DELETE removes it | P1 | Admin JWT | +| ADMIN-051 | Blog admin endpoints reject non-admin authenticated users | P1 | Non-admin user JWT | +| ADMIN-052 | Admin payment fetch-tx requires valid admin JWT after auth is fixed | P1 | Admin JWT; a payment with a known on-chain transaction exists | +| ADMIN-053 | Admin can release a payment escrow via /api/payment/:id/release | P1 | Admin JWT; a funded payment exists | +| ADMIN-054 | Admin can process a refund via /api/payment/:id/refund | P1 | Admin JWT; a payment in refundable state exists | +| ADMIN-055 | Release and refund endpoints reject unauthenticated requests | P0 | A valid payment ID is known | +| ADMIN-056 | Release and refund endpoints reject non-admin authenticated users | P0 | Non-admin JWT; payment IDs that are in releasable/refundable states | +| ADMIN-057 | GET /api/users/admin/list returns paginated user list for admin | P1 | Admin JWT; multiple user records exist | +| ADMIN-058 | Dispute statistics page: action exists but no UI page renders the data | P2 | Admin account logged in | +| ADMIN-059 | Unauthenticated request to all three debug/utility endpoints is blocked after auth fix | P0 | Auth middleware has been applied to the relevant routes | +| ADMIN-060 | AML configuration change is lost after server restart (persistence limitation) | P1 | Admin JWT; access to restart backend in staging | +| ADMIN-061 | POST /api/admin/cleanup/seed-templates seeds required data in staging | P2 | Admin JWT; staging environment with empty templates collection | +| ADMIN-062 | All admin endpoints return 401 when called without any Authorization header | P0 | Backend running; list of admin endpoint paths available | +| ADMIN-063 | All admin endpoints return 403 when called with a valid non-admin JWT | P0 | Non-admin user JWT | +| ADMIN-064 | Derived-destinations /:id/sweep-native (registered backend route) succeeds for admin | P2 | Admin JWT; a derived destination with native balance exists | +| ADMIN-065 | GET /api/admin/cleanup/collections lists available collections for cleanup targeting | P2 | Admin JWT | + +#### ADMIN-001 — POST /api/payment/payments/:id/fetch-tx is accessible without authentication + +**Priority:** P0 + +**Preconditions:** At least one payment record exists in the system + +**Steps:** +1. Obtain a valid payment ID from the database +2. Send POST /api/payment/payments/{id}/fetch-tx with NO Authorization header +3. Observe the HTTP response status and body + +**Expected Result:** Response should be 401 Unauthorized. Currently returns 200 — this is a critical security finding. Log the actual status code received. + +**Related Findings:** +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-002 — POST /api/payment/payments/auto-fetch-missing is accessible without authentication + +**Priority:** P0 + +**Preconditions:** Backend service is running + +**Steps:** +1. Send POST /api/payment/payments/auto-fetch-missing with NO Authorization header +2. Observe the HTTP response status and body + +**Expected Result:** Response should be 401 Unauthorized. Currently returns 200 — log actual status and whether on-chain fetch logic was triggered. + +**Related Findings:** +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-003 — GET /api/payment/payments/:id/debug is accessible without authentication + +**Priority:** P0 + +**Preconditions:** At least one payment record exists + +**Steps:** +1. Obtain a valid payment ID +2. Send GET /api/payment/payments/{id}/debug with NO Authorization header +3. Observe the response — check if full payment internals are returned + +**Expected Result:** Response should be 401 Unauthorized. Currently exposes full payment state to unauthenticated callers. + +**Related Findings:** +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-004 — GET /api/admin/scanner/status is accessible without authentication + +**Priority:** P0 + +**Preconditions:** Backend is running and AMN_SCANNER_URL is configured + +**Steps:** +1. Send GET /api/admin/scanner/status with NO Authorization header +2. Observe HTTP status and whether scanner data is returned + +**Expected Result:** Response should be 401 Unauthorized. Currently proxies to AMN_SCANNER_URL and returns scanner data without any auth check. + +**Related Findings:** +- GET /api/admin/scanner/status has no authentication middleware despite being under /api/admin/ + +#### ADMIN-005 — GET /api/admin/scanner/status returns correct scanner data for authenticated admin + +**Priority:** P1 + +**Preconditions:** Admin account exists; AMN_SCANNER_URL is reachable + +**Steps:** +1. Authenticate as admin user and obtain JWT +2. Send GET /api/admin/scanner/status with Authorization: Bearer {admin-jwt} +3. Verify response body contains valid scanner status fields + +**Expected Result:** 200 OK with scanner status payload. Fields should include scanner health, last scan timestamp, and relevant chain coverage. + +**Related Findings:** +- GET /api/admin/scanner/status has no authentication middleware despite being under /api/admin/ + +#### ADMIN-006 — Shkeeper release endpoint: documented path returns 404, correct path returns expected response + +**Priority:** P0 + +**Preconditions:** A payment in fundable/releasable state exists; admin JWT available + +**Steps:** +1. Authenticate as admin and obtain JWT +2. Send POST /api/payment/shkeeper/{id}/release with valid admin JWT — note HTTP status +3. Send POST /api/payment/{id}/release with the same admin JWT — note HTTP status and response body + +**Expected Result:** The /shkeeper/ path returns 404. The /payment/:id/release path returns 200 with escrow-release transaction data. + +**Related Findings:** +- Shkeeper release/refund doc paths do not match backend paths + +#### ADMIN-007 — Shkeeper refund endpoint: documented /shkeeper/ path returns 404, correct path succeeds + +**Priority:** P0 + +**Preconditions:** A payment eligible for refund exists + +**Steps:** +1. Authenticate as admin +2. Send POST /api/payment/shkeeper/{id}/refund — expect 404 +3. Send POST /api/payment/{id}/refund with admin JWT — expect success + +**Expected Result:** 404 for the documented /shkeeper/ segment path. 200 for the actual /payment/:id/refund path. + +**Related Findings:** +- Shkeeper release/refund doc paths do not match backend paths + +#### ADMIN-008 — Shkeeper release/confirm and refund/confirm documented paths return 404 + +**Priority:** P1 + +**Preconditions:** Payments in appropriate states exist; admin JWT available + +**Steps:** +1. Authenticate as admin +2. Send POST /api/payment/shkeeper/{id}/release/confirm — note status +3. Send POST /api/payment/shkeeper/{id}/refund/confirm — note status +4. Send POST /api/payment/{id}/release/confirm and POST /api/payment/{id}/refund/confirm — note status + +**Expected Result:** The /shkeeper/ variants return 404. The /payment/:id/ variants return 200 or appropriate business-logic responses. + +**Related Findings:** +- Shkeeper release/refund doc paths do not match backend paths + +#### ADMIN-009 — User admin endpoints: singular /api/user/admin/* paths return 404 + +**Priority:** P0 + +**Preconditions:** Admin JWT available; a non-admin user ID is known + +**Steps:** +1. Authenticate as admin +2. Send PATCH /api/user/admin/{userId}/status with admin JWT +3. Send DELETE /api/user/admin/{userId} with admin JWT +4. Send PATCH /api/user/admin/{userId}/role with admin JWT +5. Send GET /api/user/admin/list with admin JWT +6. Record HTTP status for each + +**Expected Result:** All singular /api/user/admin/* paths return 404. The plural /api/users/admin/* paths should be used instead. + +**Related Findings:** +- User admin endpoint prefix inconsistency: doc mixes /api/user/ and /api/users/ + +#### ADMIN-010 — User admin endpoints: plural /api/users/admin/* paths succeed for authorized admin + +**Priority:** P1 + +**Preconditions:** Admin JWT; a test user account available for manipulation + +**Steps:** +1. Authenticate as admin +2. Send GET /api/users/admin/list — verify user list is returned +3. Send PATCH /api/users/admin/{userId}/status with body {status: 'active'} — verify success +4. Send PATCH /api/users/admin/{userId}/role with a valid role — verify success +5. Send DELETE /api/users/admin/{userId} — verify deletion + +**Expected Result:** All /api/users/admin/* (plural) requests succeed with 200/204 and appropriate response bodies. + +**Related Findings:** +- User admin endpoint prefix inconsistency: doc mixes /api/user/ and /api/users/ + +#### ADMIN-011 — updateUserStatus frontend action uses PUT — verify backend accepts PUT for /api/users/admin/:id/status + +**Priority:** P0 + +**Preconditions:** Admin account logged in; non-admin user exists in system + +**Steps:** +1. Open browser DevTools network tab +2. Log in as admin and navigate to user management +3. Trigger a user status toggle for any non-admin user +4. Inspect the outgoing network request — confirm HTTP method is PUT +5. Confirm the backend returns 200 (not 404 or 405) + +**Expected Result:** Network request is PUT /api/users/admin/{id}/status. Backend responds 200 with updated user. If backend only accepts PATCH, this will fail with 404 or 405. + +**Related Findings:** +- updateUserStatus and updateUserRole use PUT in frontend but PATCH in API doc + +#### ADMIN-012 — updateUserRole frontend action uses PUT — verify backend accepts PUT for /api/users/admin/:id/role + +**Priority:** P0 + +**Preconditions:** Admin account; non-admin user exists + +**Steps:** +1. Open browser DevTools network tab +2. Log in as admin and navigate to user management +3. Trigger a role change for a non-admin user +4. Inspect outgoing request — confirm HTTP method is PUT +5. Confirm backend returns 200 with updated user role + +**Expected Result:** Network request is PUT /api/users/admin/{id}/role. Backend responds 200. If the backend only accepts PATCH, the role change will silently fail. + +**Related Findings:** +- updateUserStatus and updateUserRole use PUT in frontend but PATCH in API doc + +#### ADMIN-013 — User status values: verify backend accepts 'inactive' and 'pending' sent by frontend + +**Priority:** P1 + +**Preconditions:** Admin JWT; test user exists + +**Steps:** +1. Authenticate as admin +2. Send PUT /api/users/admin/{userId}/status with body {status: 'inactive'} — observe response +3. Send PUT /api/users/admin/{userId}/status with body {status: 'pending'} — observe response +4. Send PUT /api/users/admin/{userId}/status with body {status: 'suspended'} — observe response + +**Expected Result:** 'active' and 'inactive' return 200 and update the user. 'pending' behavior should be documented. 'suspended' (doc value) should either succeed or return a validation error — confirm which values the backend model actually accepts. + +**Related Findings:** +- updateUserStatus frontend accepts 'inactive'/'pending' but API doc says 'active'/'suspended' + +#### ADMIN-014 — POST /api/admin/cleanup/clean with dryRun=false and no confirm field is rejected + +**Priority:** P0 + +**Preconditions:** Admin JWT available; do NOT run this against production data + +**Steps:** +1. Authenticate as admin +2. Send POST /api/admin/cleanup/clean with body {dryRun: false} — omit the confirm field entirely +3. Observe HTTP status and response body + +**Expected Result:** Request is rejected with 400 or 422 and a clear error message indicating confirm='DELETE_ALL_DATA' is required. No data should be deleted. + +**Related Findings:** +- POST /api/admin/cleanup/clean: confirm body param documented as optional but backend requires it for real deletions + +#### ADMIN-015 — POST /api/admin/cleanup/clean with dryRun=true performs dry run without deleting data + +**Priority:** P1 + +**Preconditions:** Admin JWT; staging/test environment only + +**Steps:** +1. Authenticate as admin +2. Record current document counts for relevant collections +3. Send POST /api/admin/cleanup/clean with body {dryRun: true} +4. Re-check document counts — confirm nothing was deleted +5. Verify response lists what would be deleted + +**Expected Result:** 200 OK. Response contains a preview of what would be cleaned. No documents are actually removed. + +**Related Findings:** +- POST /api/admin/cleanup/clean: confirm body param documented as optional but backend requires it for real deletions + +#### ADMIN-016 — POST /api/admin/cleanup/clean with dryRun=false and confirm='DELETE_ALL_DATA' performs actual cleanup + +**Priority:** P1 + +**Preconditions:** Admin JWT; ONLY on staging/test environment with disposable data + +**Steps:** +1. Authenticate as admin +2. Send POST /api/admin/cleanup/clean with body {dryRun: false, confirm: 'DELETE_ALL_DATA'} +3. Verify response reports deletion counts +4. Query affected collections to confirm records were removed + +**Expected Result:** 200 OK. Cleanup executes. Response reports number of records deleted per collection. Affected collection counts decrease accordingly. + +**Related Findings:** +- POST /api/admin/cleanup/clean: confirm body param documented as optional but backend requires it for real deletions + +#### ADMIN-017 — GET /api/admin/settings/aml returns current AML configuration + +**Priority:** P1 + +**Preconditions:** Admin JWT; AML settings configured in env + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/settings/aml with admin JWT +3. Verify response contains 'provider' (none or chainalysis) and 'costUsd' fields +4. Confirm API key is NOT included in the response + +**Expected Result:** 200 OK. Response includes {provider: 'none'|'chainalysis', costUsd: }. No API key field is present. + +**Related Findings:** +- AML settings endpoints entirely absent from API documentation + +#### ADMIN-018 — PATCH /api/admin/settings/aml updates AML provider at runtime + +**Priority:** P1 + +**Preconditions:** Admin JWT; access to restart service in staging + +**Steps:** +1. Authenticate as admin +2. Send PATCH /api/admin/settings/aml with body {provider: 'chainalysis', costUsd: 0.10} +3. Send GET /api/admin/settings/aml — confirm new values are returned +4. Simulate a server restart (or use a staging environment where this is safe) +5. Send GET /api/admin/settings/aml again — confirm values reverted to original env file values + +**Expected Result:** PATCH returns 200 with updated values. GET confirms update. After server restart, GET returns original env values — confirming the known persistence limitation. + +**Related Findings:** +- AML settings endpoints entirely absent from API documentation +- AML runtime configuration is not persisted — server restart silently reverts admin changes + +#### ADMIN-019 — PATCH /api/admin/settings/aml is rejected for non-admin authenticated users + +**Priority:** P1 + +**Preconditions:** Non-admin user JWT available + +**Steps:** +1. Authenticate as a regular (non-admin) user and obtain JWT +2. Send PATCH /api/admin/settings/aml with non-admin JWT +3. Observe HTTP status + +**Expected Result:** 403 Forbidden. Non-admin users cannot modify AML configuration. + +**Related Findings:** +- AML settings endpoints entirely absent from API documentation + +#### ADMIN-020 — GET /api/admin/settings/confirmation-thresholds returns per-chain confirmation counts + +**Priority:** P1 + +**Preconditions:** Admin JWT; at least one blockchain network configured + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/settings/confirmation-thresholds with admin JWT +3. Verify response lists at least one chain with a confirmation threshold value + +**Expected Result:** 200 OK. Response is a map or array of chainId → confirmationCount. Values match configured blockchain network requirements. + +**Related Findings:** +- Confirmation thresholds, awaiting-confirmation, and RN network registry endpoints are undocumented + +#### ADMIN-021 — PATCH /api/admin/settings/confirmation-thresholds/:chainId updates the threshold for a specific chain + +**Priority:** P1 + +**Preconditions:** Admin JWT; known chainId in the system + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/settings/confirmation-thresholds — note current value for a known chainId +3. Send PATCH /api/admin/settings/confirmation-thresholds/{chainId} with body {confirmations: } +4. Send GET /api/admin/settings/confirmation-thresholds again — confirm updated value persists + +**Expected Result:** PATCH returns 200. Subsequent GET returns the new threshold value. Change is persisted to the database (verify it survives a page reload). + +**Related Findings:** +- Confirmation thresholds, awaiting-confirmation, and RN network registry endpoints are undocumented + +#### ADMIN-022 — GET /api/admin/settings/confirmation-thresholds/history returns 404 (unimplemented endpoint) + +**Priority:** P1 + +**Preconditions:** Admin JWT + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/settings/confirmation-thresholds/history with admin JWT +3. Observe HTTP status code + +**Expected Result:** 404 Not Found — the history endpoint is not registered on the backend. If the frontend confirmation-thresholds page calls this on mount, the page should handle the 404 gracefully without crashing. + +**Related Findings:** +- Frontend calls GET /api/admin/settings/confirmation-thresholds/history which is not in backend data + +#### ADMIN-023 — Confirmation-thresholds admin page loads without crashing when history endpoint returns 404 + +**Priority:** P1 + +**Preconditions:** Admin account logged in; browser with DevTools + +**Steps:** +1. Log in as admin +2. Navigate to /dashboard/admin/confirmation-thresholds +3. Open browser DevTools network tab +4. Observe any requests to /confirmation-thresholds/history +5. Verify the page renders usable content despite the 404 + +**Expected Result:** Page loads and displays current thresholds. The history request (if made) returns 404 but does not cause a white screen or unhandled error. + +**Related Findings:** +- Frontend calls GET /api/admin/settings/confirmation-thresholds/history which is not in backend data + +#### ADMIN-024 — GET /api/admin/payments/awaiting-confirmation returns payments pending confirmation + +**Priority:** P1 + +**Preconditions:** Admin JWT; at least one payment in awaiting-confirmation state + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/payments/awaiting-confirmation with admin JWT +3. Verify response lists payments that have a tx hash but are not yet in funded or released state + +**Expected Result:** 200 OK with an array of payment objects. Each entry has a txHash and a status indicating it is awaiting blockchain confirmation. + +**Related Findings:** +- Confirmation thresholds, awaiting-confirmation, and RN network registry endpoints are undocumented + +#### ADMIN-025 — GET /api/admin/rn/networks returns network registry list + +**Priority:** P1 + +**Preconditions:** Admin JWT; backend has at least one configured RN network + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/rn/networks with admin JWT +3. Verify response contains at least one network entry with chainId and network metadata + +**Expected Result:** 200 OK. Response is an array of registered blockchain networks with their configurations. + +**Related Findings:** +- Confirmation thresholds, awaiting-confirmation, and RN network registry endpoints are undocumented + +#### ADMIN-026 — Network registry Reload and Probe buttons return 404 (unimplemented backend routes) + +**Priority:** P1 + +**Preconditions:** Admin account logged in; browser DevTools open + +**Steps:** +1. Log in as admin and navigate to /dashboard/admin/networks +2. Open DevTools network tab +3. Click the Reload Registry button — observe network request and status +4. Click the Probe Chain button for any listed chain — observe network request and status + +**Expected Result:** POST /api/admin/rn/networks/reload returns 404. POST /api/admin/rn/networks/probe/{chainId} returns 404. The UI should display an appropriate error rather than silently failing. + +**Related Findings:** +- Frontend calls network registry reload and chain probe endpoints not in backend data + +#### ADMIN-027 — Derived-destinations list page loads and displays current destinations + +**Priority:** P1 + +**Preconditions:** Admin account; at least one derived destination exists + +**Steps:** +1. Log in as admin +2. Navigate to /dashboard/admin/derived-destinations +3. Verify the page loads and lists derived destination addresses with balances + +**Expected Result:** Page renders with a list of derived destination addresses. GET /api/payment/derived-destinations returns 200 with the list. + +**Related Findings:** +- Derived destinations and sweep endpoints are undocumented + +#### ADMIN-028 — Derived-destinations cron status endpoint returns 404 (unimplemented) + +**Priority:** P1 + +**Preconditions:** Admin account; DevTools open + +**Steps:** +1. Log in as admin and navigate to /dashboard/admin/derived-destinations +2. Open DevTools network tab +3. Observe whether GET /api/payment/derived-destinations/cron/status is called on page load +4. Check the HTTP status of that request + +**Expected Result:** GET /api/payment/derived-destinations/cron/status returns 404 — the cron management endpoints are not registered on the backend. The page should not crash. + +**Related Findings:** +- Frontend calls derived-destinations cron and single-sweep endpoints not present in backend data + +#### ADMIN-029 — Start/Stop sweep cron and single-destination sweep UI actions return 404 + +**Priority:** P1 + +**Preconditions:** Admin account; derived-destinations page accessible + +**Steps:** +1. Log in as admin and navigate to /dashboard/admin/derived-destinations +2. Open DevTools network tab +3. Click Start Cron — observe network request status +4. Click Stop Cron — observe network request status +5. Click Sweep for a single destination — observe network request status + +**Expected Result:** POST /api/payment/derived-destinations/cron/start, cron/stop, and /:id/sweep all return 404. UI should surface an error message rather than showing success. + +**Related Findings:** +- Frontend calls derived-destinations cron and single-sweep endpoints not present in backend data + +#### ADMIN-030 — POST /api/payment/derived-destinations/sweep (bulk) succeeds with admin auth + +**Priority:** P2 + +**Preconditions:** Admin JWT; derived destinations with sweepable balance exist + +**Steps:** +1. Authenticate as admin +2. Send POST /api/payment/derived-destinations/sweep with admin JWT and appropriate body +3. Verify response indicates sweep was triggered or queued + +**Expected Result:** 200 OK. Bulk sweep endpoint (which IS registered on the backend) responds successfully. + +**Related Findings:** +- Derived destinations and sweep endpoints are undocumented + +#### ADMIN-031 — GET /api/disputes/statistics returns 200 for non-admin authenticated user (authorization gap) + +**Priority:** P0 + +**Preconditions:** Non-admin user JWT; at least some dispute data exists + +**Steps:** +1. Authenticate as a regular non-admin user and obtain JWT +2. Send GET /api/disputes/statistics with the non-admin JWT +3. Observe HTTP status and whether data is returned + +**Expected Result:** Should return 403 Forbidden. Currently returns 200 with statistics data for any authenticated user — this is an authorization gap. Record actual vs expected behavior. + +**Related Findings:** +- GET /api/disputes/statistics auth: doc claims admin-only, backend applies only authenticateToken + +#### ADMIN-032 — GET /api/disputes/statistics returns data for admin user + +**Priority:** P1 + +**Preconditions:** Admin JWT; dispute records exist + +**Steps:** +1. Authenticate as admin +2. Send GET /api/disputes/statistics with admin JWT +3. Verify response contains KPI fields such as total disputes, open, resolved, by-status breakdown + +**Expected Result:** 200 OK with aggregate dispute statistics. All KPI fields are populated. + +**Related Findings:** +- GET /api/disputes/statistics auth: doc claims admin-only, backend applies only authenticateToken + +#### ADMIN-033 — POST /api/payment/payments/cleanup-pending rejects non-admin authenticated user + +**Priority:** P0 + +**Preconditions:** Non-admin user JWT + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send POST /api/payment/payments/cleanup-pending with non-admin JWT +3. Observe HTTP status + +**Expected Result:** 403 Forbidden before any payment deletion logic executes. Confirm via response timing that the check fires early. + +**Related Findings:** +- POST /api/payment/payments/cleanup-pending: doc claims admin middleware, backend enforces admin in handler only + +#### ADMIN-034 — POST /api/payment/payments/cleanup-pending only deletes pending payments older than 2 hours + +**Priority:** P1 + +**Preconditions:** Admin JWT; payments in appropriate age/state combinations exist + +**Steps:** +1. Create or identify a pending payment created less than 2 hours ago +2. Create or identify a pending payment older than 2 hours +3. Authenticate as admin +4. Send POST /api/payment/payments/cleanup-pending with admin JWT +5. Verify only the older pending payment was deleted; the recent pending payment remains + +**Expected Result:** 200 OK. Only pending payments older than 2 hours are deleted. Recent pending payments and payments in non-pending states are untouched. + +**Related Findings:** +- POST /api/payment/payments/cleanup-pending: doc claims admin middleware, backend enforces admin in handler only + +#### ADMIN-035 — POST /api/points/admin/add rejects non-admin authenticated user + +**Priority:** P0 + +**Preconditions:** Non-admin user JWT; a target user exists + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send POST /api/points/admin/add with non-admin JWT and a valid body +3. Observe HTTP status — verify no points were added + +**Expected Result:** 403 Forbidden. Points balance of the target user is unchanged. + +**Related Findings:** +- POST /api/points/admin/add: doc claims middleware-level admin auth, backend uses handler-level check + +#### ADMIN-036 — POST /api/points/admin/add succeeds for admin and credits correct points + +**Priority:** P1 + +**Preconditions:** Admin JWT; a non-admin target user exists + +**Steps:** +1. Authenticate as admin +2. Record target user's current points balance +3. Send POST /api/points/admin/add with admin JWT and body {userId, amount, reason} +4. Verify target user's points balance increased by the specified amount + +**Expected Result:** 200 OK. Target user's points balance reflects the added amount. + +**Related Findings:** +- POST /api/points/admin/add: doc claims middleware-level admin auth, backend uses handler-level check + +#### ADMIN-037 — POST /api/disputes/:id/assign rejects non-admin authenticated user + +**Priority:** P1 + +**Preconditions:** Non-admin user JWT; a dispute in assignable state exists + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send POST /api/disputes/{id}/assign with non-admin JWT +3. Observe HTTP status + +**Expected Result:** 403 Forbidden. Dispute assignment should not proceed. Confirm the controller-level check fires before any state mutation. + +**Related Findings:** +- Dispute assign and resolve doc claims admin middleware; backend enforces in controller + +#### ADMIN-038 — POST /api/disputes/:id/resolve rejects non-admin authenticated user + +**Priority:** P1 + +**Preconditions:** Non-admin user JWT; an open dispute exists + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send POST /api/disputes/{id}/resolve with non-admin JWT +3. Observe HTTP status and verify dispute status is unchanged + +**Expected Result:** 403 Forbidden. Dispute status remains unchanged. + +**Related Findings:** +- Dispute assign and resolve doc claims admin middleware; backend enforces in controller + +#### ADMIN-039 — Admin can assign a dispute to themselves and dispute status updates + +**Priority:** P1 + +**Preconditions:** Admin JWT; an open unassigned dispute exists + +**Steps:** +1. Authenticate as admin +2. Send POST /api/disputes/{id}/assign with admin JWT and body {assigneeId: } +3. Retrieve the dispute via GET /api/disputes/{id} +4. Verify the assignee field is set and dispute status reflects assigned state + +**Expected Result:** 200 OK on assign. Dispute shows updated assignee and appropriate status. + +**Related Findings:** +- Dispute assign and resolve doc claims admin middleware; backend enforces in controller + +#### ADMIN-040 — Admin can resolve a dispute and resolution is persisted + +**Priority:** P1 + +**Preconditions:** Admin JWT; an open (and assigned) dispute exists + +**Steps:** +1. Authenticate as admin +2. Send POST /api/disputes/{id}/resolve with admin JWT and resolution body +3. Retrieve the dispute and confirm status is 'resolved' +4. Verify resolution metadata (resolution note, timestamp, resolver) is stored + +**Expected Result:** 200 OK. Dispute transitions to resolved state with full resolution audit trail. + +**Related Findings:** +- Dispute assign and resolve doc claims admin middleware; backend enforces in controller + +#### ADMIN-041 — GET /api/users/admin/stats returns aggregate user analytics for admin + +**Priority:** P2 + +**Preconditions:** Admin JWT; user records exist + +**Steps:** +1. Authenticate as admin +2. Send GET /api/users/admin/stats with admin JWT +3. Verify response contains aggregate fields such as total users, active users, new registrations, etc. + +**Expected Result:** 200 OK. Response includes meaningful aggregate statistics. Values are non-zero if users exist in the system. + +**Related Findings:** +- No admin UI for user statistics endpoint + +#### ADMIN-042 — GET /api/users/admin/stats returns 403 for non-admin user + +**Priority:** P1 + +**Preconditions:** Non-admin user JWT + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send GET /api/users/admin/stats with non-admin JWT +3. Observe HTTP status + +**Expected Result:** 403 Forbidden. Non-admin users cannot access aggregate user statistics. + +**Related Findings:** +- No admin UI for user statistics endpoint + +#### ADMIN-043 — PATCH /api/users/admin/:userId/password resets user password and clears refresh tokens + +**Priority:** P1 + +**Preconditions:** Admin JWT; target user with a known password exists + +**Steps:** +1. Authenticate as admin +2. Note the target user's active session (if any) +3. Send PATCH /api/users/admin/{userId}/password with admin JWT and a new password body +4. Attempt to use the old password or a previously valid refresh token — both should be rejected +5. Verify new password works for login + +**Expected Result:** 200 OK. Target user's password is updated. All existing refresh tokens for that user are invalidated. User must log in again with the new password. + +**Related Findings:** +- No admin UI for user password reset, resend-verification, update-by-email, and user stats + +#### ADMIN-044 — POST /api/users/admin/:userId/resend-verification queues a verification email + +**Priority:** P2 + +**Preconditions:** Admin JWT; an unverified user exists; email service is configured + +**Steps:** +1. Authenticate as admin +2. Identify an unverified user account +3. Send POST /api/users/admin/{userId}/resend-verification with admin JWT +4. Check the email delivery system (test inbox or email log) for a new verification email + +**Expected Result:** 200 OK. A verification email is queued/sent to the target user's email address. + +**Related Findings:** +- No admin UI for user password reset, resend-verification, update-by-email, and user stats + +#### ADMIN-045 — PUT /api/users/admin/update/:email updates user by email address + +**Priority:** P2 + +**Preconditions:** Admin JWT; a user with the target email exists + +**Steps:** +1. Authenticate as admin +2. Send PUT /api/users/admin/update/{email} with admin JWT and update body (e.g., display name change) +3. Retrieve the user and verify the change was applied + +**Expected Result:** 200 OK. User record is updated. Changes are visible on subsequent GET. + +**Related Findings:** +- No admin UI for user password reset, resend-verification, update-by-email, and user stats + +#### ADMIN-046 — DELETE /api/admin/cleanup/user/:userId permanently deletes all user data (GDPR) + +**Priority:** P1 + +**Preconditions:** Admin JWT; a disposable test user exists in a staging environment + +**Steps:** +1. Authenticate as admin +2. Create a disposable test user and note their userId +3. Send DELETE /api/admin/cleanup/user/{userId} with admin JWT +4. Attempt GET /api/users/admin/{userId} — user should not exist +5. Verify associated data (payments, disputes, points) is also removed per GDPR scope + +**Expected Result:** 200 OK or 204 No Content. User record and associated personal data are permanently deleted. Subsequent lookups return 404. + +**Related Findings:** +- No admin UI for data cleanup, seeder, and GDPR user-deletion operations + +#### ADMIN-047 — GET /api/admin/cleanup/stats returns collection document counts + +**Priority:** P2 + +**Preconditions:** Admin JWT + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/cleanup/stats with admin JWT +3. Verify response lists counts for key collections (users, payments, disputes, etc.) + +**Expected Result:** 200 OK with a breakdown of document counts per collection. Counts match direct database queries. + +**Related Findings:** +- No admin UI for data cleanup, seeder, and GDPR user-deletion operations + +#### ADMIN-048 — GET /api/blog/admin/posts returns all posts including unpublished for admin + +**Priority:** P1 + +**Preconditions:** Admin JWT; at least one published and one draft blog post exist + +**Steps:** +1. Create at least one unpublished/draft blog post +2. Authenticate as admin +3. Send GET /api/blog/admin/posts with admin JWT +4. Verify response includes the draft post +5. Compare with the public blog list — confirm drafts are absent from public view + +**Expected Result:** 200 OK with all posts including drafts. The public blog endpoint should not return drafts. + +**Related Findings:** +- Blog admin CRUD endpoints are undocumented + +#### ADMIN-049 — POST /api/blog/posts creates a new blog post as admin + +**Priority:** P1 + +**Preconditions:** Admin JWT + +**Steps:** +1. Authenticate as admin +2. Send POST /api/blog/posts with admin JWT and a complete post body (title, content, status) +3. Verify response contains the newly created post with a generated ID +4. Retrieve the post via GET /api/blog/admin/posts/{id} and confirm all fields match + +**Expected Result:** 201 Created. Post is persisted and retrievable. Title and content match the submitted values. + +**Related Findings:** +- Blog admin CRUD endpoints are undocumented + +#### ADMIN-050 — PUT /api/blog/posts/:id updates a blog post and DELETE removes it + +**Priority:** P1 + +**Preconditions:** Admin JWT + +**Steps:** +1. Authenticate as admin +2. Create a test blog post via POST /api/blog/posts +3. Send PUT /api/blog/posts/{id} with updated title and content +4. Verify GET returns updated values +5. Send DELETE /api/blog/posts/{id} +6. Verify GET returns 404 for the deleted post + +**Expected Result:** PUT returns 200 with updated post. DELETE returns 200 or 204. Subsequent GET returns 404. + +**Related Findings:** +- Blog admin CRUD endpoints are undocumented + +#### ADMIN-051 — Blog admin endpoints reject non-admin authenticated users + +**Priority:** P1 + +**Preconditions:** Non-admin user JWT + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send GET /api/blog/admin/posts with non-admin JWT +3. Send POST /api/blog/posts with non-admin JWT +4. Observe HTTP status for each request + +**Expected Result:** Both requests return 403 Forbidden. Non-admin users cannot access the admin blog management endpoints. + +**Related Findings:** +- Blog admin CRUD endpoints are undocumented + +#### ADMIN-052 — Admin payment fetch-tx requires valid admin JWT after auth is fixed + +**Priority:** P1 + +**Preconditions:** Admin JWT; a payment with a known on-chain transaction exists + +**Steps:** +1. Authenticate as admin and obtain JWT +2. Send POST /api/payment/payments/{id}/fetch-tx with admin JWT +3. Verify the on-chain fetch is triggered and response contains transaction data + +**Expected Result:** 200 OK with tx data when called with valid admin JWT. This verifies the happy path once the auth gap (ADMIN-001) is resolved. + +**Related Findings:** +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-053 — Admin can release a payment escrow via /api/payment/:id/release + +**Priority:** P1 + +**Preconditions:** Admin JWT; a funded payment exists + +**Steps:** +1. Authenticate as admin +2. Identify a payment in a funded/releasable state +3. Send POST /api/payment/{id}/release with admin JWT +4. Verify response contains the escrow release transaction details +5. Verify payment status updates to released + +**Expected Result:** 200 OK with release transaction. Payment status transitions to released. + +**Related Findings:** +- No admin UI for shkeeper release, refund, payout, and webhook-stats operations + +#### ADMIN-054 — Admin can process a refund via /api/payment/:id/refund + +**Priority:** P1 + +**Preconditions:** Admin JWT; a payment in refundable state exists + +**Steps:** +1. Authenticate as admin +2. Identify a payment eligible for refund +3. Send POST /api/payment/{id}/refund with admin JWT and required refund body +4. Verify response and check that payment status updates to refunded + +**Expected Result:** 200 OK. Payment status is updated. Refund transaction reference is present in the response. + +**Related Findings:** +- No admin UI for shkeeper release, refund, payout, and webhook-stats operations + +#### ADMIN-055 — Release and refund endpoints reject unauthenticated requests + +**Priority:** P0 + +**Preconditions:** A valid payment ID is known + +**Steps:** +1. Send POST /api/payment/{id}/release with NO Authorization header +2. Send POST /api/payment/{id}/refund with NO Authorization header +3. Observe HTTP status for each + +**Expected Result:** Both return 401 Unauthorized. No escrow state changes occur. + +**Related Findings:** +- No admin UI for shkeeper release, refund, payout, and webhook-stats operations + +#### ADMIN-056 — Release and refund endpoints reject non-admin authenticated users + +**Priority:** P0 + +**Preconditions:** Non-admin JWT; payment IDs that are in releasable/refundable states + +**Steps:** +1. Authenticate as a regular non-admin user +2. Send POST /api/payment/{id}/release with non-admin JWT +3. Send POST /api/payment/{id}/refund with non-admin JWT +4. Observe HTTP status for each + +**Expected Result:** Both return 403 Forbidden. Payment states are unchanged. + +**Related Findings:** +- No admin UI for shkeeper release, refund, payout, and webhook-stats operations + +#### ADMIN-057 — GET /api/users/admin/list returns paginated user list for admin + +**Priority:** P1 + +**Preconditions:** Admin JWT; multiple user records exist + +**Steps:** +1. Authenticate as admin +2. Send GET /api/users/admin/list with admin JWT +3. Verify response is paginated and includes user records with id, email, role, and status fields + +**Expected Result:** 200 OK with paginated user list. Sensitive fields such as password hashes are absent from the response. + +**Related Findings:** +- User admin endpoint prefix inconsistency: doc mixes /api/user/ and /api/users/ + +#### ADMIN-058 — Dispute statistics page: action exists but no UI page renders the data + +**Priority:** P2 + +**Preconditions:** Admin account logged in + +**Steps:** +1. Log in as admin +2. Attempt to navigate to any dispute statistics page under /dashboard/admin/ or /dashboard/disputes/ +3. Confirm no such page exists or that the data from GET /api/disputes/statistics is not displayed anywhere in the UI + +**Expected Result:** No admin page renders dispute statistics. GET /api/disputes/statistics endpoint returns valid data but is unused by any current UI page. Document as a missing feature. + +**Related Findings:** +- No admin UI for dispute statistics despite frontend action existing + +#### ADMIN-059 — Unauthenticated request to all three debug/utility endpoints is blocked after auth fix + +**Priority:** P0 + +**Preconditions:** Auth middleware has been applied to the relevant routes + +**Steps:** +1. After authentication middleware is added to fetch-tx, auto-fetch-missing, and debug endpoints: +2. Send POST /api/payment/payments/{id}/fetch-tx with no auth header — expect 401 +3. Send POST /api/payment/payments/auto-fetch-missing with no auth header — expect 401 +4. Send GET /api/payment/payments/{id}/debug with no auth header — expect 401 + +**Expected Result:** All three return 401 Unauthorized. This is a regression test to verify the auth fix was applied to all three endpoints simultaneously. + +**Related Findings:** +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-060 — AML configuration change is lost after server restart (persistence limitation) + +**Priority:** P1 + +**Preconditions:** Admin JWT; access to restart backend in staging + +**Steps:** +1. Authenticate as admin +2. Record current AML provider via GET /api/admin/settings/aml +3. Change provider via PATCH /api/admin/settings/aml with a different provider value +4. Confirm change via GET /api/admin/settings/aml +5. Restart the backend service in a staging environment +6. Send GET /api/admin/settings/aml again and compare provider value with original + +**Expected Result:** After restart, GET returns the original env-file value, not the patched value. This confirms and documents the known persistence limitation. Any admin UI for this feature must display a warning about this behavior. + +**Related Findings:** +- AML runtime configuration is not persisted — server restart silently reverts admin changes + +#### ADMIN-061 — POST /api/admin/cleanup/seed-templates seeds required data in staging + +**Priority:** P2 + +**Preconditions:** Admin JWT; staging environment with empty templates collection + +**Steps:** +1. Authenticate as admin +2. Send POST /api/admin/cleanup/seed-templates with admin JWT +3. Verify response indicates templates were seeded +4. Query the templates collection to confirm records exist + +**Expected Result:** 200 OK. Template documents are created. Re-running seed-templates is idempotent (does not create duplicates). + +**Related Findings:** +- No admin UI for data cleanup, seeder, and GDPR user-deletion operations + +#### ADMIN-062 — All admin endpoints return 401 when called without any Authorization header + +**Priority:** P0 + +**Preconditions:** Backend running; list of admin endpoint paths available + +**Steps:** +1. Compile a list of all /api/admin/* endpoints +2. Send a request to each with no Authorization header +3. Record which return 401 and which return 200 or other non-401 status + +**Expected Result:** Every /api/admin/* endpoint returns 401 for unauthenticated requests. Any endpoint returning 200 without auth is a security finding. + +**Related Findings:** +- GET /api/admin/scanner/status has no authentication middleware despite being under /api/admin/ +- fetch-tx and auto-fetch-missing have no authentication despite doc claiming admin-only + +#### ADMIN-063 — All admin endpoints return 403 when called with a valid non-admin JWT + +**Priority:** P0 + +**Preconditions:** Non-admin user JWT + +**Steps:** +1. Authenticate as a regular non-admin user and obtain JWT +2. Send requests to key /api/admin/* and /api/users/admin/* endpoints using the non-admin JWT +3. Record HTTP status for each + +**Expected Result:** All admin-only endpoints return 403 Forbidden for non-admin JWTs. Any endpoint returning 200 for a non-admin token is an authorization gap. + +**Related Findings:** +- GET /api/disputes/statistics auth: doc claims admin-only, backend applies only authenticateToken +- POST /api/payment/payments/cleanup-pending: doc claims admin middleware, backend enforces admin in handler only + +#### ADMIN-064 — Derived-destinations /:id/sweep-native (registered backend route) succeeds for admin + +**Priority:** P2 + +**Preconditions:** Admin JWT; a derived destination with native balance exists + +**Steps:** +1. Authenticate as admin +2. Identify a derived destination with a native token balance +3. Send POST /api/payment/derived-destinations/{id}/sweep-native with admin JWT +4. Verify response indicates the native sweep was initiated + +**Expected Result:** 200 OK. Native token sweep is triggered for the specified destination. This distinguishes the confirmed backend route from the unimplemented /:id/sweep route. + +**Related Findings:** +- Frontend calls derived-destinations cron and single-sweep endpoints not present in backend data + +#### ADMIN-065 — GET /api/admin/cleanup/collections lists available collections for cleanup targeting + +**Priority:** P2 + +**Preconditions:** Admin JWT + +**Steps:** +1. Authenticate as admin +2. Send GET /api/admin/cleanup/collections with admin JWT +3. Verify response lists collection names that can be targeted in the /cleanup/clean endpoint + +**Expected Result:** 200 OK with an array of collection names. List includes expected collections such as users, payments, disputes, etc. + +**Related Findings:** +- No admin UI for data cleanup, seeder, and GDPR user-deletion operations + +--- + +### Notifications & Socket Events + +| ID | Title | Priority | Preconditions | +|-----|-------|----------|---------------| +| NOTIFICATION-001 | Mark all notifications read uses PATCH /notifications/mark-all-read, not POST /notifications/read-all | P0 | Authenticated user has at least 3 unread notifications. Network proxy (e.g. b... | +| NOTIFICATION-002 | POST /notifications/read-all returns 404 | P0 | Valid JWT token for an authenticated user. | +| NOTIFICATION-003 | GET /notifications/:id returns 404 for any notification that is not the user's most-recent | P0 | Authenticated user has at least 2 notifications. Note the _id of the second-m... | +| NOTIFICATION-004 | Single notification mark-read uses PATCH /notifications/:id/read | P0 | Authenticated user has at least one unread notification. Network proxy captur... | +| NOTIFICATION-005 | POST /notifications/mark-read returns 404 | P0 | Valid JWT token. Any notification _id. | +| NOTIFICATION-006 | Badge count syncs across two open tabs via unread-count-update socket event | P0 | User is signed in on two separate browser tabs (Tab A and Tab B). Both tabs s... | +| NOTIFICATION-007 | Mark-all-read syncs unread count to 0 across open tabs via unread-count-update | P0 | User is signed in on two tabs. Both show badge count >= 2. | +| NOTIFICATION-008 | New notification arrival emits unread-count-update and increments badge in all open tabs | P0 | User is signed in on two tabs. Initial badge count is known. A second actor (... | +| NOTIFICATION-009 | Happy path: notification creation persists to MongoDB and triggers socket push | P0 | User A is signed in on the frontend with socket connected. User B (or admin) ... | +| NOTIFICATION-010 | Happy path: paginated notification list returns correct unreadCount | P0 | Authenticated user has 5 unread and 3 read notifications. | +| NOTIFICATION-011 | GET /notifications/settings returns 404 | P0 | Valid JWT token. | +| NOTIFICATION-012 | Components using useNotifications hook from use-notifications.ts display real notification data | P0 | User has existing notifications. Identify all components that import useNotif... | +| NOTIFICATION-013 | Socket notifications arriving via new-notification event have real MongoDB _id values, not timestamp strings | P0 | User is signed in. Socket connected. Another actor can trigger a notification. | +| NOTIFICATION-014 | Creating a notification without specifying category does not cause schema validation error | P0 | Admin or service account with ability to POST /api/notifications without a ca... | +| NOTIFICATION-015 | Dual router registration: all documented notification endpoints are handled by the correct controller | P0 | Access to app.ts and both notificationControllerRoutes.ts and routes.ts. Test... | +| NOTIFICATION-016 | Happy path: offline user receives notification on next sign-in | P1 | User A is signed out. Another actor can trigger a notification for User A. | +| NOTIFICATION-017 | Happy path: mark single notification read updates isRead and readAt, decrements badge | P1 | User has at least one unread notification with a known _id. | +| NOTIFICATION-018 | Happy path: delete notification removes it from list permanently | P1 | User has at least one notification with a known _id. | +| NOTIFICATION-019 | Unauthenticated requests to all notification endpoints return 401 | P1 | No Authorization header. | +| NOTIFICATION-020 | User A cannot mark or delete User B's notifications | P1 | User A and User B both have notifications. User A has a valid JWT. Obtain a n... | +| NOTIFICATION-021 | GET /notifications returns only the authenticated user's notifications regardless of userId query param | P1 | User A and User B both have notifications. User A has a valid JWT. User B's u... | +| NOTIFICATION-022 | Bulk mark-read endpoint accepts notification ID array and marks each read | P1 | User has at least 3 unread notifications. Note their _ids. | +| NOTIFICATION-023 | Bulk delete endpoint accepts notification ID array and removes each document | P1 | User has at least 3 notifications. Note their _ids. | +| NOTIFICATION-024 | Bulk mark-read and bulk delete are not reachable from the frontend UI | P1 | Access to the frontend source: actions/notification.ts and src/lib/axios.ts. | +| NOTIFICATION-025 | Notification bell badge reconciles against GET /notifications/unread-count when drawer is opened | P1 | User has unread notifications. The React state unread count may be stale. | +| NOTIFICATION-026 | Notification with actionUrl navigates to the correct URL on click | P1 | User has a notification with a non-null actionUrl. | +| NOTIFICATION-027 | Notification without actionUrl does not navigate on click | P1 | User has a notification with actionUrl: null or actionUrl: ''. | +| NOTIFICATION-028 | Frontend joins user-{userId} socket room on app mount | P1 | WebSocket debug logging enabled or proxy capturing socket frames. | +| NOTIFICATION-029 | level-up socket event triggers visible feedback and persists a notification document | P1 | User is signed in with socket connected. A scenario exists to trigger PointsS... | +| NOTIFICATION-030 | referral-signup and referral-reward socket events fire on referral completion | P1 | A referral flow can be completed: User A refers User B. B signs up via the re... | +| NOTIFICATION-031 | Notifications older than 90 days are auto-deleted and not returned by GET /notifications | P1 | Access to MongoDB to insert a notification with createdAt set to 91 days ago,... | +| NOTIFICATION-032 | Purchase request transition to pending_payment produces no notification | P1 | A purchase request exists that can be transitioned to pending_payment status. | +| NOTIFICATION-033 | Purchase request transition to seller_paid produces no notification | P1 | A purchase request exists that can be transitioned to seller_paid status. | +| NOTIFICATION-034 | All valid notification types are accepted: info, success, warning, error | P1 | Service or admin account can create notifications. | +| NOTIFICATION-035 | All valid notification categories are accepted: purchase_request, offer, payment, delivery, system | P1 | Service or admin account can create notifications. | +| NOTIFICATION-036 | Paginated notification list respects page and limit parameters | P2 | User has at least 25 notifications. | +| NOTIFICATION-037 | Notifications drawer displays all notification fields correctly | P2 | User has notifications of multiple types and categories. | +| NOTIFICATION-038 | Toast notification appears for a new real-time notification in the active tab | P2 | User is signed in. The active browser tab is in the foreground. A new notific... | +| NOTIFICATION-039 | User preferences notification opt-outs are not enforced — notifications fire regardless | P2 | User has emailNotifications or pushNotifications set to false in User.prefere... | +| NOTIFICATION-040 | Notifications from all documented originating services are created correctly | P2 | Test flows exist for: offer submission, payment confirmation, delivery update... | +| NOTIFICATION-041 | Notification actionUrl is enforced by factory methods and not null for standard notification types | P2 | Access to create notifications via standard service factory methods. | +| NOTIFICATION-042 | Bulk mark-read with a mix of valid and invalid IDs reports per-ID errors without aborting | P2 | User has 2 unread notifications. One valid _id and one non-existent _id are p... | +| NOTIFICATION-043 | Race condition: two simultaneous mark-all-read requests do not cause duplicate updates | P2 | User has at least 5 unread notifications. | +| NOTIFICATION-044 | Marking a notification read that is already read is idempotent | P2 | User has a notification with isRead: true. | +| NOTIFICATION-045 | Deleting a notification that does not exist returns 404 | P2 | Valid JWT. A non-existent notification _id (e.g. a valid ObjectId format that... | +| NOTIFICATION-046 | PATCH /notifications/:id/read with an invalid (non-ObjectId) ID returns 400 | P2 | Valid JWT. | +| NOTIFICATION-047 | Notification list pagination with out-of-range page returns empty array, not an error | P2 | User has fewer than 20 notifications. | +| NOTIFICATION-048 | Socket new-notification event payload contains all required fields | P2 | Socket listener is attached to user room. A new notification is triggered. | +| NOTIFICATION-049 | unread-count-update event payload contains unreadCount and timestamp | P2 | Socket listener attached. An action that triggers unread-count-update is perf... | +| NOTIFICATION-050 | chat-notification socket event does NOT create a document in the notifications collection | P2 | User A and User B are in a chat. Socket listener active. | +| NOTIFICATION-051 | User preferences fields emailNotifications and pushNotifications exist in schema | P3 | Access to user creation or profile update endpoint. | +| NOTIFICATION-052 | Email digest: emailDigested field defaults to false on new notifications | P3 | A notification can be created. | +| NOTIFICATION-053 | High-volume fan-out: creating notifications for 50 users simultaneously completes without errors | P3 | Test environment with 50 user accounts. Ability to trigger a batch notificati... | +| NOTIFICATION-054 | Dispute flow: no notification is created for dispute status changes | P3 | A dispute can be opened on a transaction. | +| NOTIFICATION-055 | GET /api/notifications/unread-count returns correct count as notifications are read | P3 | User has 5 unread notifications. | + +#### NOTIFICATION-001 — Mark all notifications read uses PATCH /notifications/mark-all-read, not POST /notifications/read-all + +**Priority:** P0 + +**Preconditions:** Authenticated user has at least 3 unread notifications. Network proxy (e.g. browser DevTools or mitmproxy) is capturing HTTP requests. + +**Steps:** +1. Sign in as the test user. +2. Open the notifications drawer (bell icon). +3. Click 'Mark all read'. +4. Inspect outgoing HTTP requests in the network proxy. + +**Expected Result:** A single PATCH request is sent to /notifications/mark-all-read. The response is HTTP 200 with a body containing modifiedCount >= 3. No POST request to /notifications/read-all is issued. All notifications in the drawer change to read state and the badge count drops to 0. + +**Related Findings:** +- POST /api/notifications/read-all does not exist — correct method is PATCH + +#### NOTIFICATION-002 — POST /notifications/read-all returns 404 + +**Priority:** P0 + +**Preconditions:** Valid JWT token for an authenticated user. + +**Steps:** +1. Send POST /api/notifications/read-all with the user's Authorization header. +2. Record the HTTP status code and response body. + +**Expected Result:** HTTP 404 is returned. No notifications are modified. This confirms the undocumented POST route does not exist and callers must use PATCH /notifications/mark-all-read. + +**Related Findings:** +- POST /api/notifications/read-all does not exist — correct method is PATCH + +#### NOTIFICATION-003 — GET /notifications/:id returns 404 for any notification that is not the user's most-recent + +**Priority:** P0 + +**Preconditions:** Authenticated user has at least 2 notifications. Note the _id of the second-most-recent notification (not the latest). + +**Steps:** +1. Send GET /api/notifications/{second-most-recent-id} with valid Authorization. +2. Send GET /api/notifications/{most-recent-id} with valid Authorization. +3. Compare responses. + +**Expected Result:** The second-most-recent ID returns HTTP 404. The most-recent ID returns HTTP 200 with the notification document. This confirms the pagination bug in getNotificationById: only the latest notification is retrievable by ID. + +**Related Findings:** +- GET /notifications/:id is a broken workaround — only returns the user's most-recent notification + +#### NOTIFICATION-004 — Single notification mark-read uses PATCH /notifications/:id/read + +**Priority:** P0 + +**Preconditions:** Authenticated user has at least one unread notification. Network proxy capturing requests. + +**Steps:** +1. Open the notifications drawer. +2. Click on a single unread notification to mark it read. +3. Inspect outgoing HTTP request. + +**Expected Result:** A PATCH request is sent to /notifications/{id}/read. Response is HTTP 200 with the updated notification document containing isRead: true and a non-null readAt timestamp. No POST to /notifications/mark-read is issued. + +**Related Findings:** +- POST /api/notifications/mark-read (narrative step 8) matches no real endpoint + +#### NOTIFICATION-005 — POST /notifications/mark-read returns 404 + +**Priority:** P0 + +**Preconditions:** Valid JWT token. Any notification _id. + +**Steps:** +1. Send POST /api/notifications/mark-read with body { notificationId: '' } and valid Authorization. + +**Expected Result:** HTTP 404. No notification is modified. Confirms the undocumented route does not exist. + +**Related Findings:** +- POST /api/notifications/mark-read (narrative step 8) matches no real endpoint + +#### NOTIFICATION-006 — Badge count syncs across two open tabs via unread-count-update socket event + +**Priority:** P0 + +**Preconditions:** User is signed in on two separate browser tabs (Tab A and Tab B). Both tabs show unread badge count N > 0. + +**Steps:** +1. In Tab A, open the notifications drawer and mark one notification as read. +2. Without refreshing Tab B, observe the bell badge in Tab B within 2 seconds. + +**Expected Result:** Tab B's badge decrements by 1 automatically. No page refresh is required. The update arrives via the unread-count-update socket event, not notification-read (which does not exist). + +**Related Findings:** +- unread-count-update socket event is undocumented but actively used +- notification-read socket event does not exist in the backend or frontend + +#### NOTIFICATION-007 — Mark-all-read syncs unread count to 0 across open tabs via unread-count-update + +**Priority:** P0 + +**Preconditions:** User is signed in on two tabs. Both show badge count >= 2. + +**Steps:** +1. In Tab A, click 'Mark all read'. +2. Observe the badge in Tab B within 2 seconds. + +**Expected Result:** Tab B's badge drops to 0 via unread-count-update socket event. The event payload contains unreadCount: 0. + +**Related Findings:** +- unread-count-update socket event is undocumented but actively used + +#### NOTIFICATION-008 — New notification arrival emits unread-count-update and increments badge in all open tabs + +**Priority:** P0 + +**Preconditions:** User is signed in on two tabs. Initial badge count is known. A second actor (admin or another user) can trigger a notification for this user. + +**Steps:** +1. Record current badge count in both Tab A and Tab B. +2. Trigger a server-side action that creates a notification for the test user (e.g. admin sends a system notification). +3. Observe both tabs within 2 seconds. + +**Expected Result:** Both tabs increment their badge by 1. Tab A and Tab B each receive the new-notification socket event and the unread-count-update event. A toast appears in the active tab. + +**Related Findings:** +- unread-count-update socket event is undocumented but actively used + +#### NOTIFICATION-009 — Happy path: notification creation persists to MongoDB and triggers socket push + +**Priority:** P0 + +**Preconditions:** User A is signed in on the frontend with socket connected. User B (or admin) has ability to trigger a notification for User A. + +**Steps:** +1. Establish a WebSocket connection listener on user-{userA-id} room. +2. Trigger an action that causes notificationService.createNotification for User A (e.g. B submits an offer on A's purchase request). +3. Within 2 seconds, check the socket for a new-notification event. +4. Send GET /api/notifications?page=1&limit=20 as User A. +5. Verify the notification document in MongoDB directly. + +**Expected Result:** Socket emits new-notification with the full notification payload (userId, title, message, type, category, isRead: false, createdAt). GET /api/notifications returns the notification in the list. MongoDB document has isRead: false and correct fields. + +#### NOTIFICATION-010 — Happy path: paginated notification list returns correct unreadCount + +**Priority:** P0 + +**Preconditions:** Authenticated user has 5 unread and 3 read notifications. + +**Steps:** +1. Send GET /api/notifications?page=1&limit=20. +2. Send GET /api/notifications/unread-count. + +**Expected Result:** GET /api/notifications returns up to 20 notifications and includes unreadCount: 5 in the response. GET /api/notifications/unread-count returns { unreadCount: 5 }. + +#### NOTIFICATION-011 — GET /notifications/settings returns 404 + +**Priority:** P0 + +**Preconditions:** Valid JWT token. + +**Steps:** +1. Send GET /api/notifications/settings with valid Authorization. + +**Expected Result:** HTTP 404. No response body with settings data. Confirms the endpoint is dead and should not be exposed in UI until implemented. + +**Related Findings:** +- GET /notifications/settings is wired in axios endpoints but has no backend route + +#### NOTIFICATION-012 — Components using useNotifications hook from use-notifications.ts display real notification data + +**Priority:** P0 + +**Preconditions:** User has existing notifications. Identify all components that import useNotifications from src/socket/hooks/use-notifications.ts. + +**Steps:** +1. Sign in as a user with known notifications. +2. Navigate to each component that uses the use-notifications.ts hook. +3. Observe whether notifications are displayed. + +**Expected Result:** Each component displays real notification data, not an empty list. If any component shows an empty state despite the user having notifications, it is consuming the stubbed hook. The fetchNotifications TODO must be resolved before these components are usable. + +**Related Findings:** +- useNotifications hook in use-notifications.ts has fetchNotifications stubbed out (TODO comment) + +#### NOTIFICATION-013 — Socket notifications arriving via new-notification event have real MongoDB _id values, not timestamp strings + +**Priority:** P0 + +**Preconditions:** User is signed in. Socket connected. Another actor can trigger a notification. + +**Steps:** +1. Intercept or log the new-notification socket event payload as it arrives at the frontend. +2. Inspect the _id field of the notification object. +3. Also check the notification's entry in the notifications list after it appears. + +**Expected Result:** The _id field is a valid MongoDB ObjectId string (24 hex characters), not a numeric timestamp (e.g. not 1716000000000). Any component consuming useNotifications from use-notifications.ts must not overwrite the real _id with Date.now(). + +**Related Findings:** +- useNotifications hook in use-notifications.ts has fetchNotifications stubbed out (TODO comment) + +#### NOTIFICATION-014 — Creating a notification without specifying category does not cause schema validation error + +**Priority:** P0 + +**Preconditions:** Admin or service account with ability to POST /api/notifications without a category field. + +**Steps:** +1. Send POST /api/notifications with body { userId, title, message, type: 'info' } omitting the category field. +2. Check the response and any server logs for validation errors. +3. If the notification is created, inspect the saved document's category value. + +**Expected Result:** Either the notification is created with a valid enum value (one of: purchase_request | offer | payment | delivery | system) or the server returns a meaningful validation error. The value 'general' must not be silently stored if the schema enforces an enum, as it is not in the documented set. + +**Related Findings:** +- Notification category 'general' is used in code but not listed in documented category enum + +#### NOTIFICATION-015 — Dual router registration: all documented notification endpoints are handled by the correct controller + +**Priority:** P0 + +**Preconditions:** Access to app.ts and both notificationControllerRoutes.ts and routes.ts. Test environment running. + +**Steps:** +1. Inspect app.ts to confirm which router is mounted first for /notifications. +2. Send requests to: GET /notifications, GET /notifications/unread-count, PATCH /notifications/:id/read, PATCH /notifications/mark-all-read, DELETE /notifications/:id. +3. For each response, confirm the response shape matches the intended controller's documented behavior. +4. If possible, temporarily disable one router registration and verify behavior changes. + +**Expected Result:** All five endpoint paths return responses from the authoritative controller. No endpoint silently shadows another. Response bodies match documented schemas. + +**Related Findings:** +- Dual router registration creates ambiguity about which controller handles /notifications + +#### NOTIFICATION-016 — Happy path: offline user receives notification on next sign-in + +**Priority:** P1 + +**Preconditions:** User A is signed out. Another actor can trigger a notification for User A. + +**Steps:** +1. While User A is signed out, trigger an action that creates a notification for User A. +2. Sign in as User A. +3. Open the notifications drawer. + +**Expected Result:** The notification appears in the drawer with isRead: false. The badge shows the correct unread count. The notification was persisted to MongoDB despite the socket emit being lossy (no replay mechanism). + +#### NOTIFICATION-017 — Happy path: mark single notification read updates isRead and readAt, decrements badge + +**Priority:** P1 + +**Preconditions:** User has at least one unread notification with a known _id. + +**Steps:** +1. Record the current badge count. +2. Send PATCH /api/notifications/{id}/read. +3. Send GET /api/notifications to re-fetch the list. +4. Check the notification document in MongoDB. + +**Expected Result:** PATCH returns HTTP 200 with the notification object containing isRead: true and readAt set to a recent timestamp. GET /api/notifications shows the same notification as read. MongoDB document matches. Badge decrements by 1. + +#### NOTIFICATION-018 — Happy path: delete notification removes it from list permanently + +**Priority:** P1 + +**Preconditions:** User has at least one notification with a known _id. + +**Steps:** +1. Send DELETE /api/notifications/{id}. +2. Send GET /api/notifications. +3. Attempt to re-send DELETE /api/notifications/{id}. + +**Expected Result:** First DELETE returns HTTP 200 or 204. GET /api/notifications does not include the deleted notification. Second DELETE returns HTTP 404. No MongoDB document exists for that _id. + +#### NOTIFICATION-019 — Unauthenticated requests to all notification endpoints return 401 + +**Priority:** P1 + +**Preconditions:** No Authorization header. + +**Steps:** +1. Send GET /api/notifications (no auth). +2. Send GET /api/notifications/unread-count (no auth). +3. Send PATCH /api/notifications/any-id/read (no auth). +4. Send PATCH /api/notifications/mark-all-read (no auth). +5. Send DELETE /api/notifications/any-id (no auth). + +**Expected Result:** All five requests return HTTP 401. No notification data is exposed. + +#### NOTIFICATION-020 — User A cannot mark or delete User B's notifications + +**Priority:** P1 + +**Preconditions:** User A and User B both have notifications. User A has a valid JWT. Obtain a notification _id belonging to User B. + +**Steps:** +1. As User A, send PATCH /api/notifications/{userB-notification-id}/read. +2. As User A, send DELETE /api/notifications/{userB-notification-id}. + +**Expected Result:** Both requests return HTTP 403 or 404. User B's notification is unchanged. + +#### NOTIFICATION-021 — GET /notifications returns only the authenticated user's notifications regardless of userId query param + +**Priority:** P1 + +**Preconditions:** User A and User B both have notifications. User A has a valid JWT. User B's userId is known. + +**Steps:** +1. As User A, send GET /api/notifications?userId={userB-id}&page=1&limit=20. +2. Inspect all returned notification documents. + +**Expected Result:** All returned notifications belong to User A only. No User B notifications are returned. The userId query param is either ignored or validated against the token and rejected with 403 if mismatched. + +**Related Findings:** +- getNotifications action passes userId as a query param; backend authenticates by token + +#### NOTIFICATION-022 — Bulk mark-read endpoint accepts notification ID array and marks each read + +**Priority:** P1 + +**Preconditions:** User has at least 3 unread notifications. Note their _ids. + +**Steps:** +1. Send PATCH /api/notifications/bulk/mark-read with body { notificationIds: [id1, id2, id3] } and valid Authorization. +2. Send GET /api/notifications to verify read state. + +**Expected Result:** HTTP 200 with a per-ID success/failure result. All three notifications now have isRead: true in the database. No atomic rollback occurs on partial failure (individual errors are reported per ID). + +**Related Findings:** +- PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete are undocumented + +#### NOTIFICATION-023 — Bulk delete endpoint accepts notification ID array and removes each document + +**Priority:** P1 + +**Preconditions:** User has at least 3 notifications. Note their _ids. + +**Steps:** +1. Send DELETE /api/notifications/bulk/delete with body { notificationIds: [id1, id2, id3] } and valid Authorization. +2. Send GET /api/notifications to verify removal. +3. Attempt to delete one of the same IDs again. + +**Expected Result:** HTTP 200 with per-ID result. All three notifications are absent from GET /api/notifications. Second deletion attempt for an already-deleted ID returns an error in the per-ID result without crashing the endpoint. + +**Related Findings:** +- PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete are undocumented + +#### NOTIFICATION-024 — Bulk mark-read and bulk delete are not reachable from the frontend UI + +**Priority:** P1 + +**Preconditions:** Access to the frontend source: actions/notification.ts and src/lib/axios.ts. + +**Steps:** +1. Search actions/notification.ts for any function calling /notifications/bulk/mark-read or /notifications/bulk/delete. +2. Search src/lib/axios.ts endpoints object for bulk keys. +3. Attempt to trigger bulk operations through the UI. + +**Expected Result:** No frontend action function or axios endpoint entry references the bulk paths. The UI provides no way to call these endpoints. This is expected given the finding — document this gap for future frontend implementation. + +**Related Findings:** +- PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete are undocumented + +#### NOTIFICATION-025 — Notification bell badge reconciles against GET /notifications/unread-count when drawer is opened + +**Priority:** P1 + +**Preconditions:** User has unread notifications. The React state unread count may be stale. + +**Steps:** +1. Sign in and note the initial badge count. +2. Using a second session or API call, mark notifications as read without the first session knowing. +3. Open the bell-icon dropdown in the first session. +4. Observe the badge count after the drawer opens. + +**Expected Result:** Badge count is reconciled with the server's current unread count after the drawer opens. It does not display the stale client-side value. + +#### NOTIFICATION-026 — Notification with actionUrl navigates to the correct URL on click + +**Priority:** P1 + +**Preconditions:** User has a notification with a non-null actionUrl. + +**Steps:** +1. Open the notifications drawer. +2. Click on a notification that has an actionUrl. +3. Observe the browser URL after navigation. + +**Expected Result:** Browser navigates to the notification's actionUrl. The notification is marked as read (isRead: true) after the click. + +#### NOTIFICATION-027 — Notification without actionUrl does not navigate on click + +**Priority:** P1 + +**Preconditions:** User has a notification with actionUrl: null or actionUrl: ''. + +**Steps:** +1. Click on a notification that has no actionUrl. +2. Observe whether the browser navigates. + +**Expected Result:** No navigation occurs. The notification is marked read. No unhandled error is thrown from the navigation handler. + +#### NOTIFICATION-028 — Frontend joins user-{userId} socket room on app mount + +**Priority:** P1 + +**Preconditions:** WebSocket debug logging enabled or proxy capturing socket frames. + +**Steps:** +1. Sign in as a user. +2. Observe outgoing socket messages immediately after the app mounts. +3. Check for a join-user-room emit with the correct userId. + +**Expected Result:** The socket emits join-user-room with the authenticated user's userId. The server-side confirms room join. Subsequent new-notification events arrive correctly. + +#### NOTIFICATION-029 — level-up socket event triggers visible feedback and persists a notification document + +**Priority:** P1 + +**Preconditions:** User is signed in with socket connected. A scenario exists to trigger PointsService.addPoints causing a level-up. + +**Steps:** +1. Perform the action that causes a level-up. +2. Observe the frontend for any toast or notification badge update. +3. Send GET /api/notifications as the user. + +**Expected Result:** If level-up creates a notification document, it appears in GET /api/notifications with category: system (or appropriate category) and the badge increments. If it is a socket-only fire-and-forget event, GET /api/notifications does not show a new entry — document this as the intended behavior. + +**Related Findings:** +- level-up and referral-signup socket events have no persistence path documented + +#### NOTIFICATION-030 — referral-signup and referral-reward socket events fire on referral completion + +**Priority:** P1 + +**Preconditions:** A referral flow can be completed: User A refers User B. B signs up via the referral link. + +**Steps:** +1. Sign in as User A with socket connected and listen for referral-signup and referral-reward events. +2. Complete User B's signup via the referral link. +3. Observe socket events received on User A's connection. + +**Expected Result:** User A receives referral-signup when B signs up. User A also receives referral-reward when the reward is credited. Both events carry appropriate payloads. Confirm whether either event results in a persisted notification document. + +**Related Findings:** +- Doc lists referral-signup socket event; backend also emits referral-reward which is undocumented + +#### NOTIFICATION-031 — Notifications older than 90 days are auto-deleted and not returned by GET /notifications + +**Priority:** P1 + +**Preconditions:** Access to MongoDB to insert a notification with createdAt set to 91 days ago, or wait for the TTL index to fire in a test environment with a shortened TTL. + +**Steps:** +1. Insert a notification document into MongoDB with createdAt = now - 91 days. +2. Wait for the MongoDB TTL background task to run (or use a shortened TTL in test env). +3. Send GET /api/notifications as the owner user. +4. Search for the old notification by its _id. + +**Expected Result:** The notification is not returned by GET /api/notifications after TTL expiry. The document no longer exists in MongoDB. No error is shown in the UI — the notification simply disappears from history. + +**Related Findings:** +- 90-day TTL auto-deletion of notifications is not documented + +#### NOTIFICATION-032 — Purchase request transition to pending_payment produces no notification + +**Priority:** P1 + +**Preconditions:** A purchase request exists that can be transitioned to pending_payment status. + +**Steps:** +1. Trigger a status transition to pending_payment. +2. Send GET /api/notifications for the buyer and seller. +3. Check MongoDB for any new notification documents created at transition time. + +**Expected Result:** No new notification is created for pending_payment. This is the current behavior — document whether this is intentional or a gap requiring a new notification template. + +**Related Findings:** +- pending_payment and seller_paid statuses have no notification templates + +#### NOTIFICATION-033 — Purchase request transition to seller_paid produces no notification + +**Priority:** P1 + +**Preconditions:** A purchase request exists that can be transitioned to seller_paid status. + +**Steps:** +1. Trigger a status transition to seller_paid. +2. Send GET /api/notifications for the buyer and seller. +3. Check MongoDB for any new notification documents. + +**Expected Result:** No new notification is created for seller_paid. Document whether this is intentional. + +**Related Findings:** +- pending_payment and seller_paid statuses have no notification templates + +#### NOTIFICATION-034 — All valid notification types are accepted: info, success, warning, error + +**Priority:** P1 + +**Preconditions:** Service or admin account can create notifications. + +**Steps:** +1. Create a notification with type: 'info'. +2. Create a notification with type: 'success'. +3. Create a notification with type: 'warning'. +4. Create a notification with type: 'error'. +5. Attempt to create a notification with type: 'critical' (invalid). + +**Expected Result:** All four valid types are accepted and persisted. The invalid type 'critical' returns a validation error. The correct type value is stored in MongoDB and returned by GET /api/notifications. + +#### NOTIFICATION-035 — All valid notification categories are accepted: purchase_request, offer, payment, delivery, system + +**Priority:** P1 + +**Preconditions:** Service or admin account can create notifications. + +**Steps:** +1. Create one notification for each category: purchase_request, offer, payment, delivery, system. +2. Attempt to create a notification with category: 'general'. +3. Attempt to create a notification with category: 'unknown'. + +**Expected Result:** Five valid categories are accepted. 'general' and 'unknown' return validation errors if the schema enforces an enum. If 'general' is silently accepted, this is a schema enforcement gap that must be resolved. + +**Related Findings:** +- Notification category 'general' is used in code but not listed in documented category enum + +#### NOTIFICATION-036 — Paginated notification list respects page and limit parameters + +**Priority:** P2 + +**Preconditions:** User has at least 25 notifications. + +**Steps:** +1. Send GET /api/notifications?page=1&limit=10. +2. Send GET /api/notifications?page=2&limit=10. +3. Send GET /api/notifications?page=3&limit=10. + +**Expected Result:** Page 1 returns the 10 most recent notifications. Page 2 returns the next 10. Page 3 returns the remaining notifications (up to 5). No notification appears in more than one page. Total across pages matches the known notification count. + +#### NOTIFICATION-037 — Notifications drawer displays all notification fields correctly + +**Priority:** P2 + +**Preconditions:** User has notifications of multiple types and categories. + +**Steps:** +1. Open the notifications drawer. +2. Inspect each notification entry for: title, message, type icon or color, category label, relative timestamp, read/unread visual state. + +**Expected Result:** Each notification displays the correct title, message, and type-specific styling (e.g. error type shows in red, success in green). Timestamps are human-readable. Unread notifications are visually distinct from read ones. + +#### NOTIFICATION-038 — Toast notification appears for a new real-time notification in the active tab + +**Priority:** P2 + +**Preconditions:** User is signed in. The active browser tab is in the foreground. A new notification is triggered for the user. + +**Steps:** +1. Trigger a server-side action that creates a notification for the user. +2. Observe the active tab for a toast (notistack) notification. + +**Expected Result:** A toast appears briefly with the notification title and message. The bell badge increments. The toast auto-dismisses after the configured duration. + +#### NOTIFICATION-039 — User preferences notification opt-outs are not enforced — notifications fire regardless + +**Priority:** P2 + +**Preconditions:** User has emailNotifications or pushNotifications set to false in User.preferences.notifications. + +**Steps:** +1. Set the user's notification preferences to disable push and email. +2. Trigger an action that would normally create a notification. +3. Check GET /api/notifications for a new entry. +4. Check whether the socket push was sent. + +**Expected Result:** A notification is created in MongoDB and the socket push is emitted regardless of the user's preferences. This is the known current behavior (preferences not enforced). Document this as a gap — no regression from the current state. + +#### NOTIFICATION-040 — Notifications from all documented originating services are created correctly + +**Priority:** P2 + +**Preconditions:** Test flows exist for: offer submission, payment confirmation, delivery update, system message. + +**Steps:** +1. Trigger an offer submission — observe buyer/seller notification. +2. Trigger a payment action — observe payment notification. +3. Trigger a delivery update — observe delivery notification. +4. Have admin send a system notification. + +**Expected Result:** Each action creates a notification with the correct category (offer, payment, delivery, system respectively), the correct type (info/success/warning/error), a meaningful title and message, and a non-null actionUrl pointing to the relevant resource. + +#### NOTIFICATION-041 — Notification actionUrl is enforced by factory methods and not null for standard notification types + +**Priority:** P2 + +**Preconditions:** Access to create notifications via standard service factory methods. + +**Steps:** +1. Trigger each documented notification type (offer, payment, delivery, purchase_request) through the normal application flow. +2. Retrieve each notification via GET /api/notifications. +3. Inspect the actionUrl field. + +**Expected Result:** All notifications created through factory methods have a non-null, valid actionUrl. No notification created by a factory method has actionUrl: null or actionUrl: ''. + +#### NOTIFICATION-042 — Bulk mark-read with a mix of valid and invalid IDs reports per-ID errors without aborting + +**Priority:** P2 + +**Preconditions:** User has 2 unread notifications. One valid _id and one non-existent _id are prepared. + +**Steps:** +1. Send PATCH /api/notifications/bulk/mark-read with body { notificationIds: [valid-id, 'nonexistent-id-123'] }. + +**Expected Result:** HTTP 200 with a per-ID result array. The valid ID is marked read. The nonexistent ID reports an error or not-found in the result. The valid notification's isRead is confirmed true in MongoDB. No 500 error thrown. + +**Related Findings:** +- PATCH /notifications/bulk/mark-read and DELETE /notifications/bulk/delete are undocumented + +#### NOTIFICATION-043 — Race condition: two simultaneous mark-all-read requests do not cause duplicate updates + +**Priority:** P2 + +**Preconditions:** User has at least 5 unread notifications. + +**Steps:** +1. Send two simultaneous PATCH /api/notifications/mark-all-read requests with the same JWT. +2. After both complete, send GET /api/notifications. +3. Check unread count. + +**Expected Result:** Both requests succeed (idempotent). All notifications show isRead: true. Unread count is 0. No notification is in an inconsistent state. + +#### NOTIFICATION-044 — Marking a notification read that is already read is idempotent + +**Priority:** P2 + +**Preconditions:** User has a notification with isRead: true. + +**Steps:** +1. Send PATCH /api/notifications/{id}/read for an already-read notification. +2. Inspect the response and the MongoDB document. + +**Expected Result:** HTTP 200 returned. The notification remains isRead: true. The readAt timestamp is not changed. No error thrown. + +#### NOTIFICATION-045 — Deleting a notification that does not exist returns 404 + +**Priority:** P2 + +**Preconditions:** Valid JWT. A non-existent notification _id (e.g. a valid ObjectId format that doesn't exist in the DB). + +**Steps:** +1. Send DELETE /api/notifications/{nonexistent-id}. + +**Expected Result:** HTTP 404 with a meaningful error message. No server crash. + +#### NOTIFICATION-046 — PATCH /notifications/:id/read with an invalid (non-ObjectId) ID returns 400 + +**Priority:** P2 + +**Preconditions:** Valid JWT. + +**Steps:** +1. Send PATCH /api/notifications/not-a-valid-objectid/read. + +**Expected Result:** HTTP 400 with a validation error. MongoDB is not queried with an invalid ObjectId. + +#### NOTIFICATION-047 — Notification list pagination with out-of-range page returns empty array, not an error + +**Priority:** P2 + +**Preconditions:** User has fewer than 20 notifications. + +**Steps:** +1. Send GET /api/notifications?page=999&limit=20. + +**Expected Result:** HTTP 200 with an empty notifications array. No 404 or 500. The response still includes the correct unreadCount. + +#### NOTIFICATION-048 — Socket new-notification event payload contains all required fields + +**Priority:** P2 + +**Preconditions:** Socket listener is attached to user room. A new notification is triggered. + +**Steps:** +1. Capture the raw new-notification socket event payload. +2. Verify all fields are present: _id, userId, title, message, type, category, isRead, createdAt. + +**Expected Result:** Payload contains all documented fields. type is one of info|success|warning|error. category is one of the valid values. isRead is false. createdAt is a valid ISO timestamp. + +#### NOTIFICATION-049 — unread-count-update event payload contains unreadCount and timestamp + +**Priority:** P2 + +**Preconditions:** Socket listener attached. An action that triggers unread-count-update is performed (create, mark read, or mark all read). + +**Steps:** +1. Capture the raw unread-count-update socket event payload. +2. Verify field structure. + +**Expected Result:** Payload contains { unreadCount: , timestamp: }. unreadCount is a non-negative integer matching the actual unread count in MongoDB. + +**Related Findings:** +- unread-count-update socket event is undocumented but actively used + +#### NOTIFICATION-050 — chat-notification socket event does NOT create a document in the notifications collection + +**Priority:** P2 + +**Preconditions:** User A and User B are in a chat. Socket listener active. + +**Steps:** +1. User B sends a chat message to User A. +2. Observe the chat-notification socket event on User A's connection. +3. Send GET /api/notifications as User A immediately after. + +**Expected Result:** User A receives chat-notification socket event. GET /api/notifications does NOT include a new notification entry for the chat message. Chat-notification is socket-only and drives the chat-list badge only, not the bell-icon notifications drawer. + +#### NOTIFICATION-051 — User preferences fields emailNotifications and pushNotifications exist in schema + +**Priority:** P3 + +**Preconditions:** Access to user creation or profile update endpoint. + +**Steps:** +1. Create or update a user setting User.preferences.notifications.emailNotifications = false and pushNotifications = false. +2. Retrieve the user profile. +3. Trigger a notification-generating action. +4. Confirm a notification is still created (since preferences are not enforced). + +**Expected Result:** Preference fields are stored in the User document. Notifications are still created despite opt-outs, confirming the known enforcement gap. No system error occurs when preferences are set. + +#### NOTIFICATION-052 — Email digest: emailDigested field defaults to false on new notifications + +**Priority:** P3 + +**Preconditions:** A notification can be created. + +**Steps:** +1. Create a new notification via any service. +2. Inspect the MongoDB document for the emailDigested field. + +**Expected Result:** If emailDigested exists on the document, it is false. If it does not exist yet (field not implemented), document this for future implementation. No error is thrown by the absence of the field. + +#### NOTIFICATION-053 — High-volume fan-out: creating notifications for 50 users simultaneously completes without errors + +**Priority:** P3 + +**Preconditions:** Test environment with 50 user accounts. Ability to trigger a batch notification event. + +**Steps:** +1. Trigger an event that causes notificationService.createNotification to be called for 50 different users in rapid succession. +2. Monitor server logs for errors, timeouts, or dropped socket emits. +3. Verify each user's notification appears in GET /api/notifications. + +**Expected Result:** All 50 notifications are persisted to MongoDB. Socket emits complete without errors (some may be lossy if users are offline — this is expected). Server remains responsive. No duplicate notifications are created. + +#### NOTIFICATION-054 — Dispute flow: no notification is created for dispute status changes + +**Priority:** P3 + +**Preconditions:** A dispute can be opened on a transaction. + +**Steps:** +1. Open a dispute on a transaction. +2. Check GET /api/notifications for the buyer and seller. +3. Update the dispute status (e.g. admin resolves it). +4. Re-check GET /api/notifications. + +**Expected Result:** No notification appears for dispute events. This is the current behavior (TODO in DisputeService). Document this gap — users currently have no notification for dispute state changes. + +#### NOTIFICATION-055 — GET /api/notifications/unread-count returns correct count as notifications are read + +**Priority:** P3 + +**Preconditions:** User has 5 unread notifications. + +**Steps:** +1. Send GET /api/notifications/unread-count — confirm { unreadCount: 5 }. +2. Mark 2 notifications read via PATCH /notifications/:id/read. +3. Send GET /api/notifications/unread-count again. +4. Mark all remaining read via PATCH /notifications/mark-all-read. +5. Send GET /api/notifications/unread-count once more. + +**Expected Result:** Counts are 5, then 3, then 0. Each reflects the true unread state in MongoDB. + +---