diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md new file mode 100644 index 0000000..eefd375 --- /dev/null +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -0,0 +1,140 @@ +# Run the whole app locally — Balinyaar bring-up runbook + +The one copy-pasteable procedure for standing up **both** projects on one machine and watching a real, +authenticated request cross the wire. Established by **Refinement Phase 0** +([refinement-phase-0-bring-up.md](refinement-phase-0-bring-up.md)). + +After this you have: the API on `https://localhost:5002` (against a local SQL Server), the web client on +`http://localhost:3000`, and a working phone-OTP login backed by the real backend. **Auth is the only real +domain** until [Refinement Phase 4](refinement-phase-4-frontend-de-mock.md) — everything else in the UI is +still an in-browser mock. + +--- + +## Prerequisites + +- **.NET 10 SDK** (`dotnet --version`) — preview is fine. +- **Node.js 20+** and **npm** (`node --version`). +- **Docker** (Desktop or engine) for the local database — or your own SQL Server on `localhost:1433`. + +--- + +## One-time setup + +### 1. Trust the ASP.NET Core HTTPS dev certificate + +Without this the browser (and `fetch`) rejects `https://localhost:5002` and every call fails with an opaque +network error. + +```bash +dotnet dev-certs https --trust +``` + +Accept the OS prompt. (macOS/Windows trust the cert; on Linux see the .NET docs for the per-distro step.) + +### 2. Start a local SQL Server + +From `server/`: + +```bash +cd server +docker compose up -d +``` + +This runs SQL Server 2022 (Developer edition) on `localhost:1433` with a **dev-only** SA password +(`Balinyaar_Dev1433`, defined in `server/docker-compose.yml` — not a secret, never used in production). +Give it ~20–30s on first start (`docker compose ps` shows `healthy`). + +> Already have a SQL Server? Skip this and point the connection string in step 3 at it instead. + +### 3. Point the API at the local database (via user-secrets — never a committed file) + +The committed `appsettings*.json` carry a **placeholder** connection string on purpose. Supply the real +local one through `dotnet user-secrets` so no working credential ever lands in git. From the API project: + +```bash +cd server/src/API/Baya.Web.Api +dotnet user-secrets set "ConnectionStrings:SqlServer" "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;" +``` + +The `Password` must match `MSSQL_SA_PASSWORD` in `docker-compose.yml`. User-secrets auto-load only in the +Development environment, so this never affects a deployed build. + +> **Env-var alternative** (e.g. for CI/containers): set `ConnectionStrings__SqlServer` (double underscore = +> the `:` config separator) instead of using user-secrets. +> PowerShell: `$env:ConnectionStrings__SqlServer = "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"` +> bash: `export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"` + +--- + +## Run it (two terminals) + +### Terminal 1 — backend (from `server/`) + +```bash +dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj +``` + +On boot the API applies all EF migrations and seeds roles + an admin user + a sandbox payment gateway +against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at +`https://localhost:5002/swagger`. + +### Terminal 2 — frontend (from `client/`) + +```bash +cd client +npm install # first time only +npm run dev +``` + +Serves **`http://localhost:3000`**. It reads the API base URL from `client/.env.development` +(`NEXT_PUBLIC_API_URL = https://localhost:5002`). If you run the API on a different port/scheme, create +`client/.env.local` with your `NEXT_PUBLIC_API_URL=…` (it overrides `.env.development`, is git-ignored). + +--- + +## Log in (the real round-trip) + +1. Open **`http://localhost:3000/fa/login`**. +2. Enter an Iranian mobile number (e.g. `09120000001`) and request the code. +3. Get the 6-digit OTP one of two ways: + - **Read the server console** (Terminal 1) — SMS is mocked, so the code is logged: + `MOCK SMS — OTP code 123456 for phone ending in 0001`. + - **Or hit the Development-only helper** (handy for scripts/e2e): + `GET https://localhost:5002/api/v1/dev/last_otp/09120000001` → + `{ "data": { "phone": "09120000001", "code": "123456" }, ... }`. + This endpoint returns **404 outside Development** and is superseded by real SMS in + [Refinement Phase 8](refinement-phase-8-external-rails.md). +4. Enter the code and submit → you land on the customer home. +5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and + `GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the + console. That is the first real authenticated request between the two projects. + +--- + +## Good to know + +- **The API speaks HTTP/2** (Kestrel `Protocols: Http2`, for gRPC). Browsers negotiate h2-over-TLS + automatically, so `fetch` just works; for `curl` add `--http2`. +- **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock + (`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4. +- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough. Rich demo data + (nurses, variants, search rows) arrives in [Refinement Phase 1](refinement-phase-1-database-and-seed.md). +- **Allowed browser origins** are configuration-driven (`Cors:AllowedOrigins`), defaulting to + `http://localhost:3000` in Development. A deployed environment lists its real web origin(s). + +## Stopping / resetting + +```bash +docker compose down # stop the DB, keep its data +docker compose down -v # stop the DB and wipe the volume (fresh migrate + seed next run) +``` + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). | +| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. | +| Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. | +| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with ``). | diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 78f96dd..0c4464c 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,27 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## refinement-phase-0 — Local end-to-end bring-up & the integration seam — 2026-07-12 +- **Shipped (integration/plumbing — no business logic):** **CORS** (`Baya.WebFramework/ServiceConfiguration/ + CorsServiceExtension.cs` → `AddCorsPolicies`, policy `BalinyaarWebClient` from `Cors:AllowedOrigins`, default + `http://localhost:3000`; `app.UseCors` after `UseRouting` / before `UseRateLimiter`; no `AllowCredentials`). + **Local DB story** — `server/docker-compose.yml` rewritten to SQL Server 2022 Developer on `localhost:1433` + (dev-only SA password); committed `appsettings*.json` connection strings replaced with **non-working + placeholders** (real value via `dotnet user-secrets`); added `` to the API csproj. + **Development-only OTP helper** `GET /api/v1/dev/last_otp/{phone}` (`DevController` + `DevOtpStore` + + `DevCapturingSmsSender`, wired only in Development via `AddDevelopmentOtpCapture`; **404 outside + Development**). Wrote `dev/post-phase/refinement/RUNBOOK.md`. +- **Contracts:** none produced; swagger snapshot **not** regenerated (only new path is the dev-only helper). +- **Mocked:** no new seam; `ISmsSender` (`LoggingSmsSender`) row updated in mocks-registry (interim OTP channel + + the Development-only capture affordance). +- **Gate:** build clean (0 new warnings) / tests green (**369**: 4 identity + 248 foundation + 117 API, incl. + 3 new CORS/dev-otp integration tests + 8 dev-otp store/decorator unit tests). +- **Handoff:** backend/handoff/after-refinement-phase-0.md +- **Notes for frontend:** the client now really reaches the API cross-origin (CORS unblocked). **No mock flag + was flipped — `auth` is still the only real domain** (that's Phase 4). `client/.env.development` already + points at `https://localhost:5002`; the API speaks HTTP/2 (browsers negotiate h2-over-TLS automatically). + Read the OTP from the server console or `GET /api/v1/dev/last_otp/{phone}` (Development only). + ## backend-phase-15 — Messaging (tickets), partner centers & admin backoffice — 2026-07-10 - **Shipped (FINAL backend phase):** new `messaging` schema — `Tickets` (`UNIQUE(reference_code)`, status/ category, nullable `booking_id`/`refund_id`), `TicketParticipants` (`UNIQUE(ticket_id, user_id)`, soft-remove diff --git a/dev/shared-working-context/backend/handoff/after-refinement-phase-0.md b/dev/shared-working-context/backend/handoff/after-refinement-phase-0.md new file mode 100644 index 0000000..6e0cd8f --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-refinement-phase-0.md @@ -0,0 +1,35 @@ +# Handoff — after refinement-phase-0 (Local end-to-end bring-up) + +**Date:** 2026-07-12 · **Track:** integration (both projects) · **Unlocks:** every other refinement phase. + +## What the frontend can now do +- **Actually call the backend cross-origin.** The API now has a CORS policy (`BalinyaarWebClient`) allowing + `http://localhost:3000` (config `Cors:AllowedOrigins`, default in Dev) with the four headers the client + sends (`Authorization`, `Content-Type`, `Accept-Language`, `Idempotency-Key`). A browser at `:3000` calling + `https://localhost:5002` is no longer blocked by same-origin policy. +- **Stand the stack up in ~5 minutes** via `dev/post-phase/refinement/RUNBOOK.md` (dev-cert trust → local SQL + Server via `docker compose` → connection string via `dotnet user-secrets` → `dotnet run` + `npm run dev`). +- **Complete a real login end-to-end.** Request an OTP, read the 6-digit code from the **server console** + (`MOCK SMS — OTP code … for phone ending in …`) or from the Development-only helper + `GET /api/v1/dev/last_otp/{phone}`, verify → real tokens + `/me`. + +## What did NOT change (important) +- **No `USE_*_MOCK` flag was flipped.** `auth` is still the only real client domain; the home/search/bookings + are still in-browser mocks until [Refinement Phase 4](../../../post-phase/refinement/refinement-phase-4-frontend-de-mock.md). +- **No client app code changed** — only verified `client/.env.development` (already `https://localhost:5002`) + and added the runbook. If you run the API elsewhere, override with `client/.env.local`. +- **Auth crypto, the money path, and the `ApiResult` envelope are untouched.** + +## New endpoint (Development only — not a contract) +- `GET /api/v1/dev/last_otp/{phone}` → `{ data: { phone, code } }` when a code was issued, else 404. **404 in + every non-Development environment** (the capture isn't even wired there). For manual/e2e login only; + superseded by real SMS in [Refinement Phase 8](../../../post-phase/refinement/refinement-phase-8-external-rails.md). + Do not build client features on it. + +## Gotchas +- The API binds **HTTP/2** (`Kestrel:Protocols = Http2`, for gRPC). Browsers negotiate h2-over-TLS via ALPN + automatically, so `fetch` works; `curl` needs `--http2`. +- The dev HTTPS cert **must be trusted** (`dotnet dev-certs https --trust`) or `fetch` to `:5002` fails with an + opaque network error. +- Committed `appsettings*.json` connection strings are **placeholders** — the API will not boot until you set + `ConnectionStrings:SqlServer` via user-secrets (or the `ConnectionStrings__SqlServer` env var). diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index c9af183..37f85dd 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -9,7 +9,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status | | --- | --- | --- | --- | --- | --- | -| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams` | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 | +| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 | | `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 | | `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🟡 | | `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 | diff --git a/dev/shared-working-context/reports/refinement-phase-0-report.md b/dev/shared-working-context/reports/refinement-phase-0-report.md new file mode 100644 index 0000000..f0015c5 --- /dev/null +++ b/dev/shared-working-context/reports/refinement-phase-0-report.md @@ -0,0 +1,78 @@ +# Refinement Phase 0 — Local end-to-end bring-up & the integration seam — Report (2026-07-12) + +## What was built +Removed the three hard integration blockers so the client and server actually talk on one machine, and +proved it with one real authenticated round-trip. **Plumbing only — no business logic, money path, auth +crypto, or envelope shape changed.** + +- **CORS (§3.1).** New `Baya.WebFramework/ServiceConfiguration/CorsServiceExtension.cs` — + `AddCorsPolicies(configuration)` builds the named policy `BalinyaarWebClient` from + `Cors:AllowedOrigins` (string array), defaulting to `http://localhost:3000` when unset. Allows exactly the + four headers the client sends (`Authorization`, `Content-Type`, `Accept-Language`, `Idempotency-Key`) + + `AllowAnyMethod()`; **no `AllowCredentials()`** (the client uses a bearer header, not a cookie). Registered + in the service chain and `app.UseCors(CorsServiceExtension.PolicyName)` placed **after `UseRouting()` and + before `UseRateLimiter()`** so pre-flight `OPTIONS` is answered before the limiter/auth run. +- **Local database story (§3.2).** `server/docker-compose.yml` rewritten to a single-purpose SQL Server 2022 + (Developer edition) on `localhost:1433` with a **dev-only** `MSSQL_SA_PASSWORD` + a named volume + + healthcheck. (The old compose referenced an unbuildable `bobby-baya` app image and exposed `1435`.) The + committed `appsettings.json` / `appsettings.Development.json` connection strings (`SqlServer` + `logDb`) are + now **non-working placeholders** (`Password=SET_VIA_USER_SECRETS_OR_ENV`); the real local value comes from + `dotnet user-secrets`. Added `baya-web-api` to the API csproj (it was + missing — user-secrets weren't actually wired before). +- **Development-only OTP retrieval (§3.3).** `GET /api/v1/dev/last_otp/{phone}` (`DevController`) returns the + most recent OTP for a phone so a browser / e2e flow can log in without an SMS gateway. Backed by a new + Development-only `DevOtpStore` + `DevCapturingSmsSender` (an `ISmsSender` decorator that captures the code + then delegates to the log-only `LoggingSmsSender`), wired **only** in Development by + `AddDevelopmentOtpCapture()`. The endpoint **404s in every non-Development environment** and the capture is + not even registered there — two independent guarantees it can't leak a code. It does **not** weaken the + `otp` rate-limit or the per-phone resend window. +- **Client env & runbook (§3.4).** Verified `client/.env.development` already has + `NEXT_PUBLIC_API_URL = https://localhost:5002` (matches the API's bound URL) — **no client code changed, no + mock flag flipped.** Wrote `dev/post-phase/refinement/RUNBOOK.md` — the copy-pasteable "run the whole app + locally" procedure (dev-cert trust, compose, user-secrets, both run commands, OTP-from-logs / dev-endpoint, + DevTools verification, troubleshooting). + +## What is now testable (and exactly how) +- **Automated (in the suite, +11 tests → 369 total):** 3 new `Baya.Test.Api` integration tests + (`CorsAndDevBringUpTests`): a pre-flight `OPTIONS /api/v1/auth/request_otp` from `http://localhost:3000` + reflects `Access-Control-Allow-Origin: http://localhost:3000`; the same from a foreign origin gets **no** + allow-origin header; `GET /api/v1/dev/last_otp/...` returns **404** in the (non-Development) test host. Plus + 8 `Baya.Test.Foundation` unit tests (`DevOtpBringUpTests`) covering the store's capture/latest/ + spelling-insensitive lookup + the decorator's capture-and-still-delegate behaviour. +- **Manual (the §7 proof):** follow `RUNBOOK.md` — `docker compose up -d`, set the user-secret, `dotnet run`, + `npm run dev`, open `/fa/login`, request an OTP, read the code from the server console (or + `GET /api/v1/dev/last_otp/{phone}`), submit → land on the customer home with **200s** on + `/auth/request_otp`, `/auth/verify_otp`, `/api/v1/me` and **no CORS error**. Negative check: remove + `app.UseCors(...)` → the same flow fails with a CORS error. + +## What is mocked / waiting on a real service +- No new seam. The existing **`ISmsSender`** (`LoggingSmsSender`) stays the interim OTP channel (logs the + code); its `mocks-registry.md` row is updated to note the Development-only `DevCapturingSmsSender` + + `/dev/last_otp` affordance. Real SMS is [Refinement Phase 8](refinement-phase-8-external-rails.md). +- The `DevOtpStore` / `DevCapturingSmsSender` / `/dev/last_otp` endpoint are a **Development-only dev + affordance, not a seam** (per the phase §4) — superseded by real SMS in Phase 8. + +## Contracts +- **None produced.** This phase ships plumbing (CORS middleware) + a Development-only diagnostic endpoint the + frontend does not consume as a contract, so no `dev/contracts/domains/*.md` was written and the + `swagger.v1.json` snapshot was **not** regenerated (the only new path is the dev-only helper Phase 8 + removes — regenerating would add churn for a path no client binds to). Auth remains the one real domain + the frontend consumes, unchanged. + +## Docs updated +- `server/CLAUDE.md` "Startup wiring" — added `AddCorsPolicies(config)` to the registration list, the + Development-only `AddDevelopmentOtpCapture()` note, and `CORS` in the pipeline order (after routing, before + the rate limiter). Project map — noted the Development-only `Dev` controller. +- `dev/post-phase/refinement/RUNBOOK.md` — new local-run runbook. +- `dev/shared-working-context/reports/mocks-registry.md` — `ISmsSender` row updated. + +## Follow-ups for later phases +- **Phase 1** — local-dev demo seed (nurses/variants/search rows) so discovery/booking aren't empty on the + real path. +- **Phase 4** — flip the 21 `USE_*_MOCK` flags (this phase changed none). +- **Phase 5** — rotate the leaked remote `sa` credentials + the dev-grade `IdentitySettings` keys (still + committed as dev placeholders here); set real production `Cors:AllowedOrigins`. +- **Phase 8** — real SMS gateway; removes the `/dev/last_otp` helper + the `DevCapturingSmsSender` decorator. +- Note for deployed envs: `Cors:AllowedOrigins` must list the real web origin(s); an empty array falls back + to the localhost dev origin (safe — a real user's origin won't match, so cross-origin is effectively denied + until configured). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 3b23fe4..1e7dc6a 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -89,7 +89,7 @@ src/ │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -515,15 +515,19 @@ RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks) AddWebFrameworkServices() // API versioning + snake_case routing +AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev) AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive) AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() ConfigureGrpcPluginServices() +// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each +// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. ``` -Pipeline order: exception handler → Swagger → routing → **rate limiter → authentication → -authorization** → controllers → metrics → health checks → gRPC. `UseRateLimiter()` is placed -**before** `UseAuthentication()` so over-limit auth/OTP attempts are rejected (`429`) before hitting -the auth stack. +Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication → +authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits +**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the +limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP +attempts are rejected (`429`) before hitting the auth stack. When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — never inline registrations there directly. diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d28b55c..96ba404 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,22 +1,37 @@ -version: "3.9" # optional since v1.27.0 +# Local development database for the Balinyaar API. +# +# Brings up a throwaway SQL Server 2022 (Developer edition) on localhost:1433 so a developer with no +# access to any remote database can boot the API. On first run Program.cs applies all EF migrations and +# seeds roles + an admin user + a sandbox gateway against this empty instance. +# +# The SA password below is a well-known DEV-ONLY value: it is NOT a secret, is used only on localhost, +# and never reaches a deployed environment. Point the API at this instance via `dotnet user-secrets` +# (see dev/post-phase/refinement/RUNBOOK.md) — never by editing a committed appsettings*.json. +# +# docker compose up -d +# # then set ConnectionStrings:SqlServer via user-secrets (see the runbook), then `dotnet run` +# +# The API itself runs on the host via `dotnet run` (not in a container) for the local-dev loop — this +# compose file intentionally provisions only the database. + services: - web_api: - image: bobby-baya - container_name: bobby-baya-app - environment: - "ASPNETCORE_URLS": "https://+;http://+" - "ASPNETCORE_Kestrel__Certificates__Default__Password": "Strong@Password" - "ASPNETCORE_Kestrel__Certificates__Default__Path": "/https/baya.pfx" - ports: - - "5000:80" - - "5001:443" - volumes: - - ~/.aspnet/https:/https - sql: - image: "mcr.microsoft.com/mssql/server:2022-latest" - container_name: sql_server2022 - ports: # not actually needed, because the two services are on the same network - - "1435:1433" + db: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: balinyaar-sqlserver environment: - - ACCEPT_EULA=y - - SA_PASSWORD=A&VeryComplex123Password + ACCEPT_EULA: "Y" + MSSQL_PID: "Developer" + MSSQL_SA_PASSWORD: "Balinyaar_Dev1433" + ports: + - "1433:1433" + volumes: + - balinyaar-mssql-data:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q 'SELECT 1' || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + balinyaar-mssql-data: diff --git a/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj b/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj index 561f463..95b4f3d 100644 --- a/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj +++ b/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj @@ -6,6 +6,8 @@ true true $(NoWarn);1591 + + baya-web-api diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/DevController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/DevController.cs new file mode 100644 index 0000000..7083b60 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/DevController.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using AppModels = Baya.Application.Models.Common; + +namespace Baya.Web.Api.Controllers.V1; + +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Display(Description = "Development-only helpers (return 404 outside the Development environment)")] +public sealed class DevController(IHostEnvironment environment) : BaseController +{ + /// + /// Development-only: returns the most recent OTP for so a browser or an + /// automated end-to-end flow can complete phone-OTP login without an SMS gateway (the code is otherwise + /// only written to the server log by LoggingSmsSender). Returns 404 in every non-Development + /// environment — the capture is not even wired there — so it can never leak a code in staging/production. + /// It does not touch the OTP rate-limit or the per-phone resend window. Superseded by the real SMS gateway + /// in refinement Phase 8. + /// + [HttpGet("[action]/{phone}")] + [ProducesOkApiResponseType] + public IActionResult LastOtp(string phone) + { + if (!environment.IsDevelopment()) + return NotFound(); + + var code = HttpContext.RequestServices.GetService()?.GetLatest(phone); + + return code is null + ? OperationResult(AppModels.OperationResult.NotFoundResult("No OTP has been issued for this phone yet.")) + : OperationResult(AppModels.OperationResult.SuccessResult(new DevLastOtpResult(phone, code))); + } +} + +/// The most recent OTP captured for a phone (Development only). +public record DevLastOtpResult(string Phone, string Code); diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 8959033..07f69a7 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -72,8 +72,14 @@ builder.Services.AddApplicationServices() .AddPersistenceServices(configuration) .AddCrossCuttingSeams(configuration) .AddWebFrameworkServices() + .AddCorsPolicies(configuration) .AddRateLimitingPolicies(); +// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login +// without an SMS gateway. Nothing here is wired in any other environment. +if (builder.Environment.IsDevelopment()) + builder.Services.AddDevelopmentOtpCapture(); + builder.Services.RegisterValidatorsAsServices(); builder.Services.AddExceptionHandler(); @@ -114,6 +120,10 @@ app.UseSwaggerAndUi(); app.UseRouting(); +// After UseRouting and before the rate limiter / authentication so a pre-flight OPTIONS is answered +// (and not rejected as 429/401) before the browser sends the real cross-origin request. +app.UseCors(CorsServiceExtension.PolicyName); + app.UseRateLimiter(); app.UseAuthentication(); diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 0fe82b8..1c4331b 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -1,7 +1,7 @@ { "ConnectionStrings": { - "SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;", - "logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" + "SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;", + "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;" }, "IdentitySettings": { "SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", @@ -25,6 +25,9 @@ "ResolvedConfidence": 0.9 } }, + "Cors": { + "AllowedOrigins": [ "http://localhost:3000" ] + }, "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { diff --git a/server/src/API/Baya.Web.Api/appsettings.json b/server/src/API/Baya.Web.Api/appsettings.json index 0fe82b8..179337f 100644 --- a/server/src/API/Baya.Web.Api/appsettings.json +++ b/server/src/API/Baya.Web.Api/appsettings.json @@ -1,7 +1,7 @@ { "ConnectionStrings": { - "SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;", - "logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" + "SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;", + "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;" }, "IdentitySettings": { "SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", @@ -25,6 +25,9 @@ "ResolvedConfidence": 0.9 } }, + "Cors": { + "AllowedOrigins": [] + }, "AllowedHosts": "*", "Kestrel": { "EndpointDefaults": { diff --git a/server/src/API/Baya.WebFramework/ServiceConfiguration/CorsServiceExtension.cs b/server/src/API/Baya.WebFramework/ServiceConfiguration/CorsServiceExtension.cs new file mode 100644 index 0000000..0cb0246 --- /dev/null +++ b/server/src/API/Baya.WebFramework/ServiceConfiguration/CorsServiceExtension.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.WebFramework.ServiceConfiguration; + +public static class CorsServiceExtension +{ + /// The single named CORS policy the browser SPA is allowed through. Registered in DI by + /// and applied in the pipeline by app.UseCors(PolicyName). + public const string PolicyName = "BalinyaarWebClient"; + + /// Configuration key holding the allowed browser origins (a string array). + public const string AllowedOriginsKey = "Cors:AllowedOrigins"; + + /// Fallback origin when is unset — the Next.js client's default + /// dev URL. A deployed environment lists its real web origin(s) in configuration. + private const string DefaultDevelopmentOrigin = "http://localhost:3000"; + + // The client (client/src/lib/api/client.ts + the payment hooks) sets exactly these request headers on + // its cross-origin calls; the pre-flight response must echo them back or the browser blocks the real + // request. Kept explicit rather than AllowAnyHeader so the surface is auditable. + private static readonly string[] AllowedHeaders = + [ + "Authorization", + "Content-Type", + "Accept-Language", + "Idempotency-Key" + ]; + + /// + /// Registers the browser CORS policy from (a string array), falling back + /// to the Next.js dev origin when unset so Development is permissive to localhost only. Credentials are + /// NOT allowed: the client authenticates with a bearer Authorization header, not a cookie, so + /// AllowCredentials() is unnecessary — and combining it with a wildcard origin is forbidden by the + /// CORS spec anyway. Pair with app.UseCors() placed after + /// UseRouting() and before the rate limiter / authentication, so a pre-flight OPTIONS is answered + /// before those run. + /// + public static IServiceCollection AddCorsPolicies(this IServiceCollection services, IConfiguration configuration) + { + var origins = configuration.GetSection(AllowedOriginsKey).Get(); + if (origins is null || origins.Length == 0) + origins = [DefaultDevelopmentOrigin]; + + services.AddCors(options => + { + options.AddPolicy(PolicyName, policy => + policy.WithOrigins(origins) + .WithHeaders(AllowedHeaders) + .AllowAnyMethod()); + }); + + return services; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevCapturingSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevCapturingSmsSender.cs new file mode 100644 index 0000000..867db5a --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevCapturingSmsSender.cs @@ -0,0 +1,22 @@ +using Baya.Application.Contracts.Common; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Development-only decorator: forwards to the real (mock) sender so the code is +/// still logged, and additionally captures it in so the Development-only +/// /api/v1/dev/last_otp/{phone} endpoint can serve it to a browser / e2e test. Registered ONLY in the +/// Development environment (see DevelopmentSeamExtensions.AddDevelopmentOtpCapture); it changes no +/// auth behaviour — the OTP is generated, validated and rate-limited exactly as before. +/// +public sealed class DevCapturingSmsSender(ISmsSender inner, DevOtpStore store) : ISmsSender +{ + public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default) + { + store.Capture(phone, code); + return inner.SendOtpAsync(phone, code, cancellationToken); + } + + public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default) + => inner.SendAsync(phone, message, cancellationToken); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevOtpStore.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevOtpStore.cs new file mode 100644 index 0000000..0589636 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/DevOtpStore.cs @@ -0,0 +1,52 @@ +#nullable enable +using System.Collections.Concurrent; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Development-only, in-memory capture of the most recent OTP per phone. It exists so a browser or an +/// automated end-to-end test can complete phone-OTP login without a real SMS gateway — OTP "delivery" is +/// , which only writes the code to the server log. It is populated ONLY when +/// is registered, which happens only in the Development environment (see +/// DevelopmentSeamExtensions.AddDevelopmentOtpCapture). It is never wired outside Development, and the +/// endpoint that exposes it (/api/v1/dev/last_otp/{phone}) is additionally gated on +/// IHostEnvironment.IsDevelopment(). Superseded by the real SMS gateway in refinement Phase 8. +/// +public sealed class DevOtpStore +{ + // Bounded so a long-lived dev session can't grow the map without limit; only the newest code per phone + // matters for completing a login. + private const int MaxEntries = 500; + + private readonly ConcurrentDictionary _codesByPhone = new(); + + public void Capture(string phone, string code) + { + var key = NormalizeKey(phone); + if (key is null) + return; + + if (_codesByPhone.Count >= MaxEntries && !_codesByPhone.ContainsKey(key)) + _codesByPhone.Clear(); + + _codesByPhone[key] = code; + } + + public string? GetLatest(string phone) + { + var key = NormalizeKey(phone); + return key is not null && _codesByPhone.TryGetValue(key, out var code) ? code : null; + } + + // Digits-only, last 10 → matches IranianPhone's canonical 09xxxxxxxxx regardless of how the caller + // spelled it (+98 / 0098 / 98 / 0 prefix). Kept self-contained so this dev helper needs no dependency on + // the Application layer's internal phone normalizer. + private static string? NormalizeKey(string? phone) + { + if (string.IsNullOrWhiteSpace(phone)) + return null; + + var digits = new string(phone.Where(char.IsAsciiDigit).ToArray()); + return digits.Length >= 10 ? digits[^10..] : null; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs new file mode 100644 index 0000000..6afbf1e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs @@ -0,0 +1,31 @@ +using Baya.Application.Contracts.Common; +using Baya.Infrastructure.CrossCutting.Seams; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration; + +public static class DevelopmentSeamExtensions +{ + /// + /// Development-only wiring for the OTP bring-up bridge. Registers and decorates + /// the registered by AddCrossCuttingSeams (the log-only + /// ) with , so each OTP is also captured + /// in memory for /api/v1/dev/last_otp/{phone} to serve. MUST be called only inside + /// builder.Environment.IsDevelopment(): nothing here is wired in any other environment, which — + /// together with the endpoint's own IsDevelopment() guard — makes the OTP echo impossible to enable + /// outside Development. Superseded by the real SMS gateway in refinement Phase 8. + /// + public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services) + { + services.AddSingleton(); + + // Re-register ISmsSender as the capturing decorator over a fresh LoggingSmsSender (built through DI so + // it still gets its ILogger). The last registration wins for a single resolve, so callers transparently + // get the decorator; the code is still logged exactly as before, just also captured for the dev endpoint. + services.AddSingleton(sp => new DevCapturingSmsSender( + ActivatorUtilities.CreateInstance(sp), + sp.GetRequiredService())); + + return services; + } +} diff --git a/server/src/Tests/Baya.Test.Api/CorsAndDevBringUpTests.cs b/server/src/Tests/Baya.Test.Api/CorsAndDevBringUpTests.cs new file mode 100644 index 0000000..fc2567f --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/CorsAndDevBringUpTests.cs @@ -0,0 +1,56 @@ +using System.Net; + +namespace Baya.Test.Api; + +/// +/// Refinement Phase 0 — proves the integration seam the browser depends on: the named CORS policy answers a +/// pre-flight from the client's origin (and only that origin), and the Development-only OTP helper is a hard +/// 404 in any non-Development environment (the factory boots as "Testing"). +/// +public class CorsAndDevBringUpTests(BayaApiFactory factory) : IClassFixture +{ + private const string AllowedOrigin = "http://localhost:3000"; + private const string CorsOriginHeader = "Access-Control-Allow-Origin"; + + [Fact] + public async Task Preflight_FromAllowedOrigin_ReflectsTheOrigin() + { + var client = factory.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Options, "/api/v1/auth/request_otp"); + request.Headers.Add("Origin", AllowedOrigin); + request.Headers.Add("Access-Control-Request-Method", "POST"); + request.Headers.Add("Access-Control-Request-Headers", "authorization,content-type"); + + var response = await client.SendAsync(request); + + Assert.True(response.Headers.TryGetValues(CorsOriginHeader, out var origins), + "the pre-flight for an allowed origin must carry Access-Control-Allow-Origin"); + Assert.Equal(AllowedOrigin, Assert.Single(origins)); + } + + [Fact] + public async Task Preflight_FromForeignOrigin_IsNotAllowed() + { + var client = factory.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Options, "/api/v1/auth/request_otp"); + request.Headers.Add("Origin", "https://evil.example"); + request.Headers.Add("Access-Control-Request-Method", "POST"); + + var response = await client.SendAsync(request); + + Assert.False(response.Headers.Contains(CorsOriginHeader), + "a foreign origin must never be echoed back as allowed"); + } + + [Fact] + public async Task DevLastOtp_OutsideDevelopment_Returns404() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/dev/last_otp/09120000001"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/DevOtpBringUpTests.cs b/server/src/Tests/Baya.Test.Foundation/DevOtpBringUpTests.cs new file mode 100644 index 0000000..717adec --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/DevOtpBringUpTests.cs @@ -0,0 +1,72 @@ +using Baya.Application.Contracts.Common; +using Baya.Infrastructure.CrossCutting.Seams; +using NSubstitute; + +namespace Baya.Test.Foundation; + +/// +/// Refinement Phase 0 — the Development-only OTP bring-up bridge. The store captures the latest code per +/// phone (spelling-insensitive) and the capturing sender both captures and still delegates to the real +/// (log-only) sender, changing no delivery behaviour. +/// +public class DevOtpBringUpTests +{ + [Fact] + public void Store_CapturesAndReturnsLatestCode() + { + var store = new DevOtpStore(); + + store.Capture("09120000001", "111111"); + store.Capture("09120000001", "222222"); + + Assert.Equal("222222", store.GetLatest("09120000001")); + } + + [Theory] + [InlineData("09120000001")] + [InlineData("+989120000001")] + [InlineData("00989120000001")] + [InlineData("9120000001")] + public void Store_MatchesAnyPhoneSpelling(string lookup) + { + var store = new DevOtpStore(); + store.Capture("09120000001", "424242"); + + Assert.Equal("424242", store.GetLatest(lookup)); + } + + [Fact] + public void Store_ReturnsNullForUnknownPhone() + { + var store = new DevOtpStore(); + store.Capture("09120000001", "123456"); + + Assert.Null(store.GetLatest("09350000009")); + } + + [Fact] + public async Task CapturingSender_CapturesTheCodeAndStillDelegates() + { + var inner = Substitute.For(); + var store = new DevOtpStore(); + var sender = new DevCapturingSmsSender(inner, store); + + await sender.SendOtpAsync("09120000001", "135790"); + + Assert.Equal("135790", store.GetLatest("09120000001")); + await inner.Received(1).SendOtpAsync("09120000001", "135790", Arg.Any()); + } + + [Fact] + public async Task CapturingSender_PlainMessageDelegatesWithoutCapturing() + { + var inner = Substitute.For(); + var store = new DevOtpStore(); + var sender = new DevCapturingSmsSender(inner, store); + + await sender.SendAsync("09120000001", "your booking is confirmed"); + + Assert.Null(store.GetLatest("09120000001")); + await inner.Received(1).SendAsync("09120000001", "your booking is confirmed", Arg.Any()); + } +}