# 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 <accessToken> 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=<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 <accessToken>.

**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: '<adminUserId>' } (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: '<adminB_id>' }.
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: ['<seller_id>'] }
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: ['<user_b>', '<user_c>'] } (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: ['<buyer_id>', '<seller_id>', '<admin_id>'], 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: '<id>', sellerId: '<id>' }
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: ['<seller_id>'], relatedTo: { type: 'PurchaseRequest', id: '<pr_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: '<string of 5001 characters>' }
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: '<unique_id>' }
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/<filename>)
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: ['<msg1_id>', '<msg2_id>'] }
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: '<new_user_id>' }
3. Assert HTTP 201 and the participant is added
4. POST /api/chat/:chatId/participants with body { participants: ['<new_user_id>'] } (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: '<user_b_id>' }
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: '<user_a_id>' } — assert socket joins room user-{userId}
3. Emit user-online with { userId: '<user_a_id>' } — 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: '<messageId>' }
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: '<id>', sellerId: '<seller_id>' }
3. Assert HTTP 201 and chat is linked to the purchase request
4. Attempt POST /api/chat with body { type: 'direct', participantIds: ['<seller_id>'], relatedTo: { type: 'PurchaseRequest', id: '<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=<valid-xpub>&registrationAddress=<address-derived-at-index-0>.
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: <expected>, 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: '<valid-id>' }.
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: '<same-id>' }.
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: '<id>', transactionHash: '<txHash>', amount: '<amount>', currency: '<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=<xprv-value>&registrationAddress=<any>.
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: '<id1>' } — verify success.
2. POST /api/trezor/addresses/next with { purpose: 'release', paymentId: '<id2>' } — verify success.
3. POST /api/trezor/addresses/next with { purpose: 'refund', paymentId: '<id3>' } — verify success.
4. POST /api/trezor/addresses/next with { purpose: 'other', paymentId: '<id4>' } — 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: '<id>' }.
2. POST /api/trezor/addresses/next with { purpose: '', paymentId: '<id>' }.
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: <number>}. 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: <new-value>}
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: <admin-user-id>}
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: '<valid-id>' } 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: <number>, timestamp: <ISO string or epoch> }. 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.

---
