NestJS backend (apps/api/src/features + core): auth/identities, properties/search/map/favorites, chat, notifications, plans/payments/invoices, reviews, and core Twilio/S3/Prisma services. These stories focus on cross-cutting correctness that a UI-only pass would miss.
JWT and Admin guards correctly gate every protected endpoint
P0
Verify that every non-public endpoint is actually protected by AuthGuard('jwt') (and admin/* endpoints additionally by AdminGuard), so that no authenticated-only or admin-only data (users, favorites, chat, admin/users) is reachable with a missing, expired, or non-admin token.
AC-01Calling any protected endpoint with no Authorization header returns 401, not a 200 with empty/default data.
AC-02Calling any protected endpoint with an expired or tampered JWT returns 401.
AC-03Calling any admin/* endpoint with a valid JWT for a non-admin user returns 403, not 200.
AC-04Public endpoints (GET companies, GET offices, GET company/:id, GET office/:id) remain accessible without a token, as intended.
Test Scenarios
TC-01Script a pass over the known protected endpoint list with no token and confirm 401 across the board (favorites, chat, notifications, devices, plans, wallet, invoices, settings).
TC-02Take a valid non-admin JWT and hit every admin/* endpoint (users, users/:id, PATCH status, DELETE, PATCH identities/:id/status) — confirm 403 on all.
TC-03Tamper one character of a valid JWT's signature and confirm 401, not a crash or 500.
TC-04Confirm the public company/office listing endpoints still return data with zero auth headers sent.
Verify that the identity-type guard prevents a user's individual identity from calling company/office-scoped endpoints (and vice versa) even while authenticated with a valid JWT, so that identity-switching (mobile-identity-switch) is enforced server-side, not just hidden client-side.
AC-01Acting as an individual identity, calling an endpoint that requires a company/office identity type returns a clear 403, not partial/empty data.
AC-02Switching identity via POST auth/identity/switch and retrying the same call succeeds once the active identity matches.
AC-03Data returned always belongs to the currently-active identity, never a different identity owned by the same account.
Test Scenarios
TC-01As an authenticated individual identity, directly call a company-scoped endpoint (bypassing the mobile UI) and confirm a 403 rather than data leakage.
TC-02Switch to a company identity, retry the same call, and confirm it now succeeds and returns only that company identity's data.
TC-03Create two identities (individual + company) under one account, fetch data as each, and diff the results to confirm zero overlap where there shouldn't be any.
Verify that POST /favorites/:propertyId (toggle), GET /favorites, GET /favorites/ids, and GET /favorites/check/:propertyId always agree with each other for the same user/property, so that the mobile app (mobile-favorites) never renders a heart icon that contradicts the favorites list.
AC-01After toggling favorite on, GET /favorites/check/:propertyId returns true, the property's ID is present in GET /favorites/ids, and the full property appears in GET /favorites.
AC-02After toggling it off, all three reflect false/absent consistently.
AC-03Toggling a property that doesn't exist (deleted) returns a sane error, not a 500 or a silently 'successful' favorite of nothing.
Test Scenarios
TC-01Toggle favorite on a property, then call all three GET endpoints and confirm they agree.
TC-02Toggle it off and repeat the check.
TC-03Call POST /favorites/:propertyId with a non-existent or already-deleted property ID and confirm a clean error response.
TC-04Favorite the same property twice in a row (toggle on, toggle on again — i.e. two calls) and confirm the second call toggles it off rather than erroring or double-favoriting.
ChatGateway events stay in sync with the REST chat endpoints
P0
Verify that the WebSocket events (join_conversation, leave_conversation, send_message → new_message/conversation_updated/error) and the REST endpoints (POST conversations, GET conversations, GET conversations/:id/messages, POST conversations/:id/read) never disagree about a conversation's message history or read state, so mobile chat (mobile-chat-realtime) is provably correct at the transport layer, not just in manual UI testing.
AC-01A message sent via the send_message socket event is retrievable immediately after via GET conversations/:id/messages.
AC-02join_conversation/leave_conversation correctly scope which sockets receive new_message for a given conversation — a socket that left shouldn't receive further events for it.
AC-03Sending a message to a conversation the sender isn't a participant in emits error, not new_message.
AC-04POST conversations/:id/read correctly zeroes unread count returned by whatever endpoint mobile uses to badge the chat list.
Test Scenarios
TC-01Open two socket connections as two different accounts in the same conversation; send from one, confirm the other receives new_message and conversation_updated.
TC-02Have one connection call leave_conversation, then send a message from the other — confirm the departed socket receives nothing further for that conversation.
TC-03Attempt send_message from an account with no membership in that conversation and confirm an error event, not a silently-accepted message.
TC-04Send several messages, call POST conversations/:id/read, then re-fetch unread state and confirm it's zero.
Tap payment webhook is idempotent and can't be spoofed
P0
Verify that POST payments/webhook correctly validates the Tap webhook signature/source and is idempotent under retries, so that a replayed or forged webhook call can't mark an invoice paid without a real payment, and a legitimate retry (Tap's own retry behavior) doesn't double-credit an account.
AC-01A webhook call missing valid Tap signing/auth is rejected, not processed as a real payment event.
AC-02Replaying the exact same legitimate webhook payload twice results in the invoice being marked paid once, not double-processed (e.g. double wallet credit).
AC-03GET payments/status/:invoiceId reflects the correct final state after webhook processing, matching what POST payments/charge + the redirect flow shows the user (mobile-payments).
Test Scenarios
TC-01Send a webhook payload with an invalid/missing signature and confirm it's rejected, and no invoice status changes.
TC-02Send a valid webhook payload twice in a row (simulating Tap's retry behavior) and confirm the invoice/wallet only reflects the payment once.
TC-03Run a full charge → redirect → webhook cycle and cross-check GET payments/status/:invoiceId matches the invoice list shown in mobile more/invoices.
Each search strategy (standard/exchange/auction/deal/company) returns correctly-scoped results
P1
Verify that POST search/advanced correctly dispatches to the right strategy (standard/exchange/auction/deal/company) and that each strategy's results only include listings actually matching that category, so that, e.g., an exchange search never returns a plain sale listing just because it matched on keyword/location.
AC-01Each of the 5 strategies returns only listings of its own category, correctly filtered by any additional params (location, price range, etc.) passed for that strategy.
AC-02GET search (the quick/simple search) returns results consistent with what an equivalent POST search/advanced standard-strategy call would return for the same query.
AC-03An invalid/unrecognized strategy value returns a clear validation error, not a silent fallback to a different strategy.
Test Scenarios
TC-01Run the same location/keyword query through each of the 5 strategies and confirm result categories never mix (no sale listings in exchange results, etc.).
TC-02Compare GET search's quick results against POST search/advanced with strategy: "standard" for an identical query string.
TC-03Send an unrecognized strategy string and confirm a validation error rather than results from an unintended default strategy.
Verify that twilio.service.ts's mock-SMS fallback (used when Twilio credentials aren't configured) is only ever active in development/test environments and never silently active in production, so that production OTP delivery can never fall back to a logged-but-not-sent 'mock' code that would let anyone log the OTP and take over an account.
AC-01In an environment with valid Twilio credentials configured, OTPs are actually sent via Twilio, and the mock fallback path is never taken.
AC-02If Twilio credentials are missing, the mock fallback logs clearly and obviously (so it's caught in review/monitoring) rather than failing silently.
AC-03Production deployment configuration is verified to always have valid Twilio credentials present — this should be an explicit deploy-checklist/monitoring item, not just a code-review assumption.
Test Scenarios
TC-01In a local/dev environment with Twilio env vars unset, trigger an OTP request and confirm the mock fallback logs the code clearly to the console/logs.
TC-02In staging/production-like config with real Twilio credentials, trigger an OTP request and confirm it's delivered via actual SMS, not logged as a mock.
TC-03Add or confirm existence of a startup check/alert that fires if the production environment ever boots without valid Twilio credentials configured.
Media upload endpoints have no auth guard — verify intentional or escalate as a security bug
P0
Flag that POST media/upload/avatar and POST media/upload/listings (apps/api/src/media/media.controller.ts) currently have NO @UseGuards(AuthGuard('jwt')) at all — unlike almost every other feature controller in the API — so that this is either confirmed as an intentional public-upload design or escalated as a security bug before it's assumed to require a token like everything else tested in this section.
AC-01Confirm with backend/security whether these two endpoints are meant to be callable with zero authentication.
AC-02If unintentional: file as a P0 security bug — anyone with the URL could currently upload arbitrary files to S3 via these endpoints without ever logging in.
AC-03If intentional (e.g. rate-limited, or gated some other way not visible in the controller itself): document the actual protection mechanism here so this story stops flagging it every pass.
AC-04Once resolved either way, the standard file-type/size validation below still needs to hold regardless of auth state.
Test Scenarios
TC-01Send a POST to /media/upload/avatar and /media/upload/listings with NO Authorization header at all and confirm whether the request succeeds — if it does, this is the headline finding to report, not a footnote.
TC-02If publicly callable, check for any other protection (rate limiting, CORS, IP allowlisting) before concluding it's fully open.
TC-03Upload a valid small JPEG/PNG as an avatar and confirm it appears correctly on the profile (functional check, independent of the auth question).
TC-04Upload a non-image file (e.g. a .txt or .pdf) to both endpoints and confirm rejection with a clear error, not a generic 500.
TC-05Upload an oversized image (beyond the 5MB avatar limit seen in the validator) and confirm a clean rejection rather than a hung request.
TC-06Upload multiple listing images in one add-listing flow and confirm all are correctly associated with that listing, none dropped or mismatched.