Files
baya-monorepo/archive/docs/flows/testing-setup.md
T
2026-08-02 20:01:31 +03:30

28 KiB
Raw Blame History

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,1433Baya (+ 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 1433TcpTestSucceeded: 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/search307 → /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_otp200, 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=mock the 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 promise MOCK 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:

  1. telegram-otp-bot/.env API_KEY must equal Seams:Sms:Telegram:ApiKey in appsettings.Development.json. On the verification machine it did not: the local .env still holds ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86 — byte-for-byte the key published in .env.example. TelegramSmsSender.cs:35 hardcodes 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.
  2. api.telegram.org is filtered in Iran, so TELEGRAM_PROXY_URL must 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 429verify_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-grantedPOST /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=1019, category=30 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, not phoneNumber. phoneNumber returns 400 with "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 401 while untouched accounts kept working. AppUserManagerImplementation.VerifyUserCode calls UpdateSecurityStampAsync on every successful verification, and the bearer handler's OnTokenValidated runs ValidateSecurityStampAsync — 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 0002no 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 clarify-chain/open-contradictions.md are settled by rows 14 and 8.


Troubleshooting

Symptom Cause Fix
request_otp500, log shows Telegram OTP relay delivery failed (http 502) Provider=telegram, relay not running Boot with Seams__Sms__Provider=mock
request_otp429; verify_otp429 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/ready503 object-storage probe file lock (Windows) Cosmetic. Check entries.sql-app instead
dev/last_otp404 "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.