28 KiB
Testing setup — boot the stack, get a code, log in
Everything you need to run Balinyaar locally and walk any flow in index.md. Executed, not transcribed: every command, URL, status code and console line below was run against this repo on the date in the stamp. Where a predecessor doc says something different, this file says so and says which is right.
Last verified: 2026-08-02 against commit
c841bde, on Windows 11 / .NET SDK 10.0.300-preview / Node 24.11.1.
Predecessors, and their standing: RUNBOOK.md and manual-testing-plan.md are superseded by this file. Both contain instructions that no longer work — see What the old docs get wrong.
The five-minute path
Three terminals. The third one is not optional — see Getting an OTP.
# 1 — API. Plain HTTP on :5002. Needs the SMS provider overridden, or login 500s.
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# PowerShell: $env:Seams__Sms__Provider="mock"; dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# 2 — client
cd client && npm install && npm run dev # http://localhost:3000/fa
# 3 — read the OTP (the console does NOT print the code)
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
Then open http://localhost:3000/fa/login, enter 09120000010, and paste the code from terminal 3.
No database setup step. The committed dev config already points at a seeded remote SQL Server.
Prerequisites
| Verified working | Notes | |
|---|---|---|
| .NET SDK | 10.0.300-preview.0.26177.108 |
NETSDK1057 (preview SDK) is an expected warning |
| Node | 24.11.1 / npm 11.6.2 |
README says 18+; 24 is fine |
| SQL Server | none locally | the dev config uses a remote instance — nothing to install |
| Docker | not required | only for the optional local-DB path, which is unverified here |
dotnet build Baya.sln completes with 0 errors, 95 warnings on a clean clone. The warnings are
pre-existing (NU1903 vulnerability advisories on Microsoft.OpenApi / SQLitePCLRaw, NU1510,
NETSDK1057). They are not yours; don't "fix" them.
Configuration — where it lives
dotnet user-secrets is not used and is not read. The <UserSecretsId> was removed from
Baya.Web.Api.csproj in 5885280. Any instruction to dotnet user-secrets set … is dead — the command
will error with "could not find UserSecretsId", and even a leftover secrets.json on your machine is inert.
| What | Where | Read when |
|---|---|---|
| Server config + keys | server/src/API/Baya.Web.Api/appsettings.Development.json |
process start |
| Server base file | appsettings.json — placeholders only, guarded by StartupSecretsGuard |
process start |
| Client dev config | client/.env.development |
next dev |
| Client prod config | client/.env.production |
next build — inlined into the bundle |
| Deployment overrides | docker-compose.yml (Seams__… env vars) |
container start |
Any server key can be overridden by an environment variable using __ for : —
Seams__Sms__Provider, ConnectionStrings__SqlServer. That is how the five-minute path avoids editing a
committed file. Full matrix: docs/integration/config-matrix.md.
The four crypto values, and why they are load-bearing
"IdentitySettings": { "SecretKey": …, "Encryptkey": … } // signs + encrypts the JWE access token
"Seams": { "FieldEncryption": { "Key": …, "HashKey": … } } // decrypts PII; derives users.PhoneHash
Seams:FieldEncryption:Key and :HashKey must match whatever the target database was encrypted under.
Every phone, address, IBAN and clinical note in Baya was written with the committed values. Boot against
that database with different ones and you get silent failure first — every phone lookup misses, so every
login says "no such account" — then Padding is invalid and cannot be removed on the first PII read. The
appsettings file carries a "//" comment saying exactly this. Do not rotate them.
IdentitySettings may be changed freely; it only invalidates tokens already issued.
The repo contains live credentials on purpose — a pre-launch trade for a demo deployment. Rotating them is a "Going to Production" step in DEPLOY.md, not a local setup step.
Which database
The committed dev config points at a remote SQL Server: 87.107.152.16,1433 → Baya (+ Baya_Logs).
It is the same instance the deployed demo uses, it is already migrated and already seeded, and it is
shared — your writes are visible to everyone else pointed at it.
Verified reachable (Test-NetConnection … -Port 1433 → TcpTestSucceeded: True) and the API's
sql-app health check reports Healthy.
The local alternative — UNVERIFIED
server/docker-compose.yml provisions SQL Server 2022 on
localhost:1433 with the dev-only SA password Balinyaar_Dev1433. Point the API at it with:
export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"
The API self-migrates and self-seeds on boot, so an empty instance is enough. This path was not executed for this stamp — Docker is not installed on the verification machine. Treat the remote path as the tested one and this as documented-but-unproven.
Two things that are true either way: the local DB starts empty, so you get a freshly dated demo world
(see staleness); and it is encrypted under whatever
Seams:FieldEncryption values you boot with, so it is not interchangeable with the remote one.
Boot
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
http://localhost:5002 — plain HTTP. launchSettings.json binds no TLS. There is no
https://localhost:5002, and dotnet dev-certs https --trust has nothing to trust; skip it. Swagger is at
http://localhost:5002/swagger, the OpenAPI doc at /swagger/v1/swagger.json (178 paths).
A healthy boot logs, in order:
[INF] Demo world already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Demo lifecycle already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Recurring job scheduler starting with 7 job(s): booking_request_expiry, notification_retention,
verification_expiry_scan, no_show_sweep, weekly_payout_generation, moadian_reconciliation,
audit_log_retention
[INF] مودیان reconciliation scanned 8 invoice(s); 0 reached registered
Noise you can ignore: ~11 WRN Entity 'X' has a global query filter … lines, and two
WRN HTTP/2 is not enabled for 127.0.0.1:5002 lines (expected — HTTP/2 needs TLS; HTTP/1.1 is used).
Health
| Endpoint | Expected | Actual |
|---|---|---|
GET /healthz/live |
200 Healthy |
✅ 200 |
GET /healthz/ready |
200 Healthy |
❌ 503 Unhealthy on Windows |
/healthz/ready fails on the object-storage probe, not the database:
IOException: The process cannot access the file
'C:\Users\<you>\AppData\Local\Temp\balinyaar-object-storage\healthz\object-storage-probe'
because it is being used by another process.
at LocalDiskObjectStorage.DeleteAsync … at ObjectStorageWriteHealthCheck…
sql-app reports Healthy in the same response. It is a real code defect, not configuration:
ObjectStorageWriteHealthCheck.cs:29 opens the probe blob with await using var and line 33 deletes it
while the FileStream handle is still open. POSIX unlink permits that, so the Linux container passes;
Windows File.Delete throws. Do not read a red /healthz/ready as "the app is broken" — check the
entries object. Phase 4 backlog material.
Seams:ObjectStorage:RootPath is "" in dev, which resolves to %TEMP%\balinyaar-object-storage
(LocalDiskObjectStorage.cs:18-20). Deployed, docker-compose.yml sets /app/data/object-storage.
Client
cd client && npm install && npm run dev
✓ Ready in 6.8s on http://localhost:3000. Routes are locale-prefixed: /fa and /en return 200,
bare / returns 404 under next dev. That is a Turbopack dev-server quirk on the root path, not a
routing bug — confirmed by building for production:
cd client && npm run build && PORT=3001 npm run start
next build exits 0. Against :3001, / → 307 → /fa; guest /fa returns 200 and renders the welcome
landing itself (byte-for-byte the same page as /fa/welcome, differing only in the URL — which is the
middleware rewrite, not a redirect); /fa/search → 307 → /fa/login?next=%2Fsearch. Never judge
root-path or guest routing from next dev.
client/.env.development already carries NEXT_PUBLIC_API_URL = http://localhost:5002, correctly on HTTP.
Do not copy .env.sample — it still says https://localhost:5002 and will break every call with an
opaque network error.
Getting an OTP
This is where a fresh clone fails, so read the whole section.
The problem
appsettings.Development.json ships with:
"Seams": { "Sms": { "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", … } } }
With nothing listening on :5010, POST /api/v1/auth/request_otp returns 500 Server Error:
[WRN] Telegram OTP relay delivery failed for phone ending 0010 — http 502
[ERR] Telegram OTP relay delivery failed (http 502).
at TelegramSmsSender.PostAsync(…) TelegramSmsSender.cs:line 73
This is deliberate — the relay fails loudly rather than pretending an undelivered code was sent. In the browser it surfaces as an error toast on the login screen and you never reach the code step.
The code is still generated and persisted before the send, so dev/last_otp works even while request_otp
500s. That is a usable-but-ugly fallback, not a fix.
The fix — one of three
| Option | Command | Result |
|---|---|---|
| A (recommended) | boot with Seams__Sms__Provider=mock |
request_otp → 200, no relay needed |
| B | set "Provider": "mock" in appsettings.Development.json |
same, but shows up in git status |
| C | run the Telegram relay | code arrives in Telegram — see below |
Option A verified:
POST /api/v1/auth/request_otp {"phone":"09120000002"} → HTTP 200
{"data":{"otpSent":true,"resendAvailableInSeconds":120,"codeLength":6,"expiresInSeconds":60},"isSuccess":true}
Reading the code
GET /api/v1/dev/last_otp/{phone} is the only way to read the code. It is anonymous and
Development-only (404 in any other environment).
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
# {"data":{"phone":"09120000010","code":"724740"},"isSuccess":true,"statusCode":200,…}
The capture bridge is registered only for a capture-safe sender — mock, telegram, or unset
(Program.cs:94-100). Select the real kavenegar gateway and the endpoint stops returning codes, by design.
The server console does NOT print the OTP. With
Provider=mockthe line is[INF] MOCK SMS — OTP issued to phone ending in 0002— the phone suffix only, no code. RUNBOOK.md and manual-testing-plan.md both promiseMOCK SMS — OTP code 123456 for phone ending in 0001. That string does not exist in the codebase. Verified: zero 6-digit sequences appear anywhere in a full boot+login log.
Option C — the Telegram relay
cd telegram-otp-bot && npm start # zero dependencies, no npm install
Two things must line up, and on the verification machine neither did:
telegram-otp-bot/.envAPI_KEYmust equalSeams:Sms:Telegram:ApiKeyinappsettings.Development.json. On the verification machine it did not: the local.envstill holdsab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86— byte-for-byte the key published in.env.example.TelegramSmsSender.cs:35hardcodes that exact string as a rejected placeholder, so the relay would reject the API's calls even with everything running. Copy the appsettings value into.env.api.telegram.orgis filtered in Iran, soTELEGRAM_PROXY_URLmust point at a working proxy.
It broadcasts every code to every configured chat id, so it is a shared inbox for a trusted group, not an SMS gateway.
Limits that will bite you
| Limit | Value | Source |
|---|---|---|
| OTP length | 6 digits | IdentityDefaults.OtpCodeLength |
| OTP validity | 60 s | IdentityDefaults.OtpExpirySeconds |
| Resend window | 120 s per phone | platform_configs.auth_otp_resend_seconds |
| Wrong attempts | 5, then code: "otp_locked" |
Lockout.MaxFailedAccessAttempts |
| Endpoint rate limit | 5 requests / 60 s per IP | otp policy, RateLimitingServiceExtension.cs |
The otp rate-limit policy covers request_otp and verify_otp. Five combined calls inside one
minute and both endpoints return 429 — verify_otp with an empty body, which looks like a crash. A
scripted login is 2 calls, so you get two logins per minute, total. See
Scripting logins.
Demo accounts
Read out of DemoWorldDefinitions.cs
and DemoLifecycleDefinitions.cs
— the seeders are the authority, not any doc — and each one confirmed by a live GET /api/v1/me.
| Phone | /me roles |
Who | Demonstrates |
|---|---|---|---|
09120000001 |
nurse |
زهرا عزیزی (f) | verified · 3 priced variants · whole-city Tehran + districts 1, 3 · 2 credentials · bank account |
09120000002 |
nurse |
علی کریمی (m) | verified · 2 variants · districts 3, 6, 12 · sponsored by the partner center |
09120000003 |
nurse |
مریم احمدی (f) | unverified (status: in_review, isBookable: false, blocking moh_competency_license + criminal_record) — must never appear in search |
09120000010 |
customer |
سارا محمدی (f) | 2 patients · 1 Tehran address · owns 6 of the 8 seeded bookings |
09120000011 |
customer |
رضا حسینی (m) | 1 infant patient · 1 address · owns the BNPL + payout-eligible bookings |
09120000020 |
super_admin |
نگار مدیری (f) | the full backoffice — but see the RBAC gap below |
09120000021 |
finance |
کامران مالی (m) | scoped backoffice; the client's useAdminCapabilities() shows only money consoles |
09120000030 |
customer |
بهنام رستگار (m) | owns مرکز پرستاری آرامش (merchant-of-record). No partner role — /me returns ["customer"]; navigate to /fa/partner manually |
Admin sub-roles are server-granted — POST /me/select_role only accepts customer and nurse
(RoleNames.SelfAssignable).
⚠ The seeded admins cannot reach any admin endpoint
Every [Authorize(DynamicPermission)] route returns 403 for 09120000020 and 09120000021. Verified
against all 16 admin GET operations in the live swagger — platform_config, audit, holidays,
admin_verifications, admin_payouts, admin_refunds, admin_evv, admin_bnpl, admin/tickets,
admin/reviews, admin_cancellation_policies, admin/partner-centers. All 403. The same token gets
200 from /me, and nurse/customer endpoints work normally, so this is not a token problem.
Root cause, in DynamicPermissionService.CanAccess:
if (user.IsInRole("admin")) return true;
var key = $"{area}:{controller}:";
return user.FindAll(ConstantPolicies.DynamicPermission).Any(c => c.Value.Equals(key, …));
It grants on the literal role "admin" or on a per-controller DynamicPermission claim. The demo
admins hold super_admin / finance (RoleNames.cs:13-17), and refinement-phase-5 stopped auto-seeding the
admin/qw123321 account — so no seeded account satisfies either branch.
Consequence for testing: the entire admin backoffice is untestable end-to-end on the real path. The
client hides this because USE_ADMIN_MOCK = true, so the console renders fully on in-browser fake data.
See admin-backoffice.md. Workaround: add both "Seed": { "AdminUsername", "AdminPassword" } keys to appsettings.Development.json before boot to mint a literal-admin account, and
call the API directly (the web login is phone-OTP only).
The seeded world, and how stale it is
DemoWorldSeeder builds the personas; DemoLifecycleSeeder layers 8 bookings plus the money, reviews,
tickets, notifications and records behind them. Both are idempotent and Development-only, and log
already seeded — no-op on every subsequent boot.
Live counts on the shared remote DB at this stamp:
| Thing | Count | Read with |
|---|---|---|
| Bookings | 8 (6 for …010, 2 for …011) |
GET /bookings/list?role=customer |
| Booking requests | 15 (11 nurse 1, 4 nurse 2) | GET /booking_requests/list |
| Search rows | 27 across 3 nurses; category=1&city=101 → 9, category=3 → 0 |
GET /search/nurses (anonymous) |
| Patients / addresses | 2 / 1 for …010 |
GET /patients/list, GET /customer_addresses/list |
| Notifications | 8, 6 unread | GET /notifications/get_notifications |
| Tickets | 9 | GET /tickets |
| Reviews | nurse 1 → averageRating: 5, publishedCount: 1 |
GET /nurses/1/reviews (anonymous) |
| Refunds | booking 7 → succeeded, psp_card, 2000000 |
GET /refunds/by_booking/7 |
| Payouts | nurse 1: paid 3187500, clawback outstanding 212500; nurse 2: eligible 2720000 |
GET /nurse_payouts/earnings_balance |
| Care records | 2 for patient 1 | GET /patients/1/care_records |
⚠ It has aged out — and this is not cosmetic
DemoLifecycleDefinitions expresses every timestamp as an offset from seed time, and the seeder anchors
to the first run's epoch, never to wall-clock now. The seeder's own comment is explicit:
"A wipe + reseed is what moves the demo world forward in time."
This world was seeded on 2026-07-26. It is 7 days old. What that has already broken:
| Scenario as designed | State today | Effect |
|---|---|---|
B1 upcoming — scheduled +3 d |
scheduled 2026-07-29, in the past | nothing is actually "upcoming" |
B3 completed_in_window — dispute window open |
window closed 2026-07-28 | the dispute/review-moderation path can't be walked |
B2 in_progress — session 3 checked in today |
scheduled 2026-07-26 | mid-engagement is 7 days stale |
One pending request awaiting the nurse |
expired_no_response (the booking_request_expiry job ran) |
— |
Two accepted requests awaiting payment |
payment_deadline_expired |
— |
There is no longer a single pending or accepted booking request in the world. Confirmed across both
nurses: statuses are only converted, rejected_by_nurse, cancelled_by_customer, expired_no_response,
payment_deadline_expired.
So booking-request and checkout-and-payment have nothing seeded to act on. Create a fresh request yourself (customer → search → C4/C5) — that path works and is the intended way to exercise both — or reseed.
The scheduler is running while you test
RecurringJobSchedulerHostedService starts with the API and gives each of the 7 jobs its own loop. Every
job fires once immediately at boot, then on its own cadence, re-reading its interval from
platform_configs each tick. It does not run under the Testing environment.
Three of them will change state under you:
| Job | Cadence | What you will notice |
|---|---|---|
booking_request_expiry |
every 60 s (hardcoded) | A request you leave un-actioned flips to expired_no_response, then payment_deadline_expired. This is what aged the seeded world out, and it will do the same to yours — accept and pay promptly |
no_show_sweep |
hourly | A session whose start time passed with no EVV check-in gets flagged missed |
weekly_payout_generation |
at boot, then 7 d | Creates a draft payout batch you did not ask for. Generation only — it never moves money; process stays an explicit admin action |
The other four (notification_retention, verification_expiry_scan, moadian_reconciliation,
audit_log_retention) are no-ops on a freshly seeded database. moadian_reconciliation logs
scanned 8 invoice(s); 0 reached registered because IMoadianClient is on its mock.
Reset
There is no in-app reseed. Both seeders guard on natural keys (a persona's phone, a ticket's reference code, the partner permit number, a request's customer/nurse/variant/date tuple), so re-running never duplicates and never refreshes. Moving the world forward in time means dropping the data first.
| Target | Procedure | Verified |
|---|---|---|
| Local Docker DB | cd server && docker compose down -v && docker compose up -d, then dotnet run |
✗ no Docker here |
| Remote shared DB | DROP DATABASE Baya (and Baya_Logs), then boot — MigrateAsync + both seeders run |
✗ not attempted |
Do not drop the shared remote database casually. It backs the balinyaar.ir demo deployment and is used
by other people. If you need a clean, freshly-dated world, use a local instance.
Schema-only, without booting the app:
cd server
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence \
--startup-project src/API/Baya.Web.Api
Reference and demo seeds still run on the next app boot.
Scripting logins
Two API calls per login, both on the otp policy, 5 per 60 s per IP. Space logins ≥ 40 s apart or you
will 429. This script minted tokens for all 8 demo accounts:
login() {
P="$1"
curl -s -X POST http://localhost:5002/api/v1/auth/request_otp \
-H "Content-Type: application/json" -d "{\"phone\":\"$P\"}" > /dev/null
C=$(curl -s "http://localhost:5002/api/v1/dev/last_otp/$P" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.code")
curl -s -X POST http://localhost:5002/api/v1/auth/verify_otp \
-H "Content-Type: application/json" \
-d "{\"phone\":\"$P\",\"code\":\"$C\",\"deviceInfo\":\"cli\"}" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.accessToken"
}
for P in 09120000001 09120000002 09120000003 09120000010 \
09120000011 09120000020 09120000021 09120000030; do
echo "T_$P=$(login $P)"; sleep 40
done
Then curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me.
Then curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me.
Four things to get right:
- The field is
phone, notphoneNumber.phoneNumberreturns400with"The Phone field is required." Authorization: Bearer <token>, never a cookie. The client stores the JWE in a cookie it reads itself and sends as a header; the server's CORS policy does not allow credentials.- Access tokens last 60 minutes (
IdentitySettings.ExpirationMinutes). - One live token per account. Logging in again kills the previous one. Verified: after re-running the
script, the earlier tokens for the re-minted phones all returned
401while untouched accounts kept working.AppUserManagerImplementation.VerifyUserCodecallsUpdateSecurityStampAsyncon every successful verification, and the bearer handler'sOnTokenValidatedrunsValidateSecurityStampAsync— so a new login invalidates every token previously issued to that user. Two people cannot share a demo account, and a second browser profile will silently log the first out.
What the old docs get wrong
Each row was checked against running code. This file is right; the predecessor is stale.
| # | Claim | Where | Reality |
|---|---|---|---|
| 1 | Set crypto keys with dotnet user-secrets |
manual-testing-plan | Removed in 5885280; the store is not read. Edit appsettings.Development.json or use __ env vars |
| 2 | API on https://localhost:5002 |
RUNBOOK ×6, manual-testing-plan | http://. No TLS binding exists |
| 3 | Run dotnet dev-certs https --trust |
RUNBOOK setup step 1 | Nothing to trust. Skip |
| 4 | client/.env.development has https://localhost:5002 |
RUNBOOK | The file says http://. But .env.sample still says https:// — a live trap |
| 5 | Seams:Sms:Provider "committed default is mock" |
RUNBOOK, Telegram step 3 | It is telegram, so request_otp 500s on a fresh clone |
| 6 | Console prints MOCK SMS — OTP code 123456 … |
RUNBOOK, manual-testing-plan | It prints MOCK SMS — OTP issued to phone ending in 0002 — no code. Use dev/last_otp |
| 7 | "Only auth is real; 21 of 22 domains are mocked" |
RUNBOOK "Good to know" | Stale by refinement-phase-4. 15 of 22 are real, 7 mocked — see index.md |
| 8 | docker compose down -v resets the world |
RUNBOOK | Only for the local-DB path. The committed config uses a remote DB where it does nothing |
| 9 | Demo world shows "upcoming" / open dispute windows | manual-testing-plan | Seeded 2026-07-26 and aged out; no pending/accepted requests remain |
| 10 | 09120000020 gives "full backoffice" |
RUNBOOK | 403 on every admin endpoint — see the RBAC gap |
Contradictions C-1, C-3, C-4 and C-5 from _plan/open-contradictions.md are settled by rows 1–4 and 8.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
request_otp → 500, log shows Telegram OTP relay delivery failed (http 502) |
Provider=telegram, relay not running |
Boot with Seams__Sms__Provider=mock |
request_otp → 429; verify_otp → 429 with an empty body |
otp policy, 5/60 s per IP |
Wait 60 s. Space scripted logins ≥ 40 s |
400 "The Phone field is required." |
Sent phoneNumber |
The field is phone |
403 on every /api/v1/admin* route |
DynamicPermission doesn't recognise super_admin |
No clean workaround — see the RBAC gap |
/healthz/ready → 503 |
object-storage probe file lock (Windows) | Cosmetic. Check entries.sql-app instead |
dev/last_otp → 404 "No OTP has been issued for this phone yet." |
No request_otp for that phone yet, or not Development |
Call request_otp first (a 500 from it still stores the code) |
Login "succeeds" but the account is unknown / Padding is invalid |
Seams:FieldEncryption doesn't match the DB |
Restore the committed values |
Refusing to start: required secret configuration is missing… |
ConnectionStrings blank or still SET_VIA_USER_SECRETS_OR_ENV |
Use appsettings.Development.json or ConnectionStrings__SqlServer |
Every curl returns 502 |
A machine-wide HTTP_PROXY/HTTPS_PROXY intercepting localhost |
curl --noproxy '*', or clear NO_PROXY |
Client 404 on http://localhost:3000/ |
Locale prefixes; next dev root-path quirk |
Use /fa. Verify root behaviour with a prod build |
| Browser: blocked by CORS | Origin not in Cors:AllowedOrigins |
http://localhost:3000 is listed by default |
dotnet user-secrets: "could not find UserSecretsId" |
Expected | Edit appsettings instead |
A 500 returns a raw stack trace instead of the ApiResult envelope |
ExceptionHandler returns false in Development on purpose |
Expected locally. Don't document the error shape from a dev response |
| A booking request flipped status while you were reading it | booking_request_expiry runs every 60 s |
Expected. Act on requests promptly |
A token that worked a minute ago now 401s |
Someone (or another tab) logged into the same demo account — the security stamp rotated | Use one account per tester, or re-login |
Where to go next
index.md — the flow atlas: what is built, what is mocked, and how to walk each journey.