Compare commits

36 Commits

Author SHA1 Message Date
Aaron Po
5028a43f4b Cover the split-screen auth shell in Storybook 2026-10-13 01:46:05 -04:00
Aaron Po
fffbe68dc2 List the action's rejected fields above the register form 2026-10-13 01:46:05 -04:00
Aaron Po
e6240bc889 Move focus into each new register step 2026-10-13 01:46:05 -04:00
Aaron Po
4a5abd4639 Build the register form as three steps 2026-10-13 01:46:05 -04:00
Aaron Po
d02f31b55c Declare the register steps and the schema slice each one collects 2026-10-13 01:46:05 -04:00
Aaron Po
e961ce4042 Move the login page onto the split-screen shell 2026-10-13 01:46:05 -04:00
Aaron Po
dfb67bcdfa Add the split-screen auth shell and its card components 2026-10-13 01:46:05 -04:00
Aaron Po
0ecc9a830e Hide the navbar auth actions on the login and register routes 2026-10-13 01:46:05 -04:00
Aaron Po
5ac55b8be0 Let route content fill the viewport under the navbar 2026-10-13 01:46:05 -04:00
Aaron Po
19791df6a5 Raise form inputs to a 44px touch target with 16px text 2026-10-13 01:46:05 -04:00
Aaron Po
0780349a79 Move the bar photo into shared assets 2026-10-13 01:46:05 -04:00
Aaron Po
c0cb013f54 Show a user's avatar on the public profile (#333) 2026-10-13 01:23:29 -04:00
Aaron Po
dfe248ea5f Create pull_request_template.md 2026-10-11 21:40:41 -04:00
Aaron Po
ff775e3b8a Raise frontend contrast, unify the page frame, and add browser identity (#328) 2026-10-08 00:17:45 -04:00
Aaron Po
0e51630439 Make the production compose stack deployable and disclose the AI-generated catalogue (#329) 2026-10-08 00:17:45 -04:00
Aaron Po
bd0e9e4a20 Create landing page (#321) 2026-10-03 18:46:32 -04:00
Aaron Po
c5972b9f04 Features/public biography endpoint (#316) 2026-09-26 19:51:10 -04:00
Aaron Po
e1b6cba1d4 Upgrade MediatR to 12.5.0 and forward cancellation tokens through pipeline behaviors (#320) 2026-09-26 19:50:44 -04:00
Aaron Po
0ff42c3c70 Add authenticated user profile page with locked-down account endpoint (#315) 2026-09-23 00:58:54 -04:00
Aaron Po
a6a9046457 Fix CA1873 warning and consolidate test fixtures (#313) 2026-09-12 22:14:55 -04:00
Aaron Po
40ae75808c Add ValidationBehavior test coverage (#311) 2026-09-12 21:08:24 -04:00
Aaron Po
fbede956e9 Fix seed pipeline crash and make seeding failures fail CI (#309) 2026-09-12 17:11:02 -04:00
Aaron Po
3793fca65e Features/exceptions pipeline integration (#308) 2026-09-12 16:24:14 -04:00
Aaron Po
78d0eaa739 Move Infrastructure.Configuration into Configuration (#291) 2026-09-12 15:59:37 -04:00
Aaron Po
180ed41e68 Move Infrastructure.FileUpload into Features.PhotoUpload (#290) 2026-09-12 00:22:19 -04:00
Aaron Po
ce2c90070e Update web docs for Infrastructure.Email move (#289) 2026-09-11 23:59:47 -04:00
Aaron Po
de63e9f216 Move Infrastructure.Email into Features.Emails (#288) 2026-09-11 23:46:04 -04:00
Aaron Po
7187631ebc Move Infrastructure.Jwt into Features.Users (#287) 2026-09-11 23:14:30 -04:00
Aaron Po
5fa78fbdf6 Move Infrastructure.PasswordHashing into Features.Users (#286) 2026-09-11 22:21:22 -04:00
Aaron Po
f85ff79a2c Move email templates into Features.Emails (#283) 2026-09-11 02:11:50 -04:00
Aaron Po
a3ec7d63f2 Add validator tests for brewery and user commands (#282) 2026-09-06 17:48:03 -04:00
Aaron Po
9d5a463d93 Rename auth feature to Features.Users (#281) 2026-09-06 01:23:51 -04:00
Aaron Po
d958933250 Replace shared email commands with MediatR notifications (#280) 2026-09-05 22:37:00 -04:00
Aaron Po
4aa7ba5d9b Update doc comments in Features.Breweries source (#279) 2026-09-05 14:57:39 -04:00
Aaron Po
272a25fb05 docs(database): update City dbml doc to reflect composite unique constraint (#278) 2026-09-05 00:44:59 -04:00
Aaron Po
d9411eb00e refactor(database): update seed pipeline to use dapper (#277) 2026-09-05 00:37:14 -04:00
340 changed files with 8394 additions and 2474 deletions

101
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,101 @@
## Summary
<!--
State WHAT is changing and WHY, in two to five sentences.
A reader should understand the change without opening the diff.
Describe the problem first, then the outcome.
-->
## Type of change
<!-- Tick the one option that best describes this PR. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no change in behaviour)
- [ ] Performance improvement
- [ ] Documentation
- [ ] Tests only
- [ ] Build, CI or dependency update
- [ ] Other (please specify):
## Related issues
<!--
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
Fixes #123
Fixes #124, fixes #125
Fixes owner/other-repo#456
Closing keywords only take effect when the PR targets the repository's default branch.
Use "Related to #123" if the PR should not close the issue.
If there is no issue, write "N/A" and explain the motivation in the Summary.
-->
## Approach and context
<!--
Explain how the change works and why this approach was chosen.
Include, where relevant:
- The decisions you made that are not obvious from the code.
- Alternatives you considered and why you rejected them.
- Known limitations or shortcomings, and any planned follow-up work.
- Background such as benchmark results or design documents.
Summarise the key points of any external link. Links may be inaccessible to future readers.
-->
## How this was tested
<!--
Describe what you ran and what you observed. Be specific, and state only what you actually did.
For example: the automated tests added or changed, manual steps and their outcome,
environments or platforms covered, and anything you were unable to test.
For a bug fix, note the test that fails without the fix.
-->
## Compatibility and risk
- [ ] This change is backwards compatible.
- [ ] This change is **breaking** (complete the details below).
- [ ] This change deprecates existing behaviour (complete the details below).
<!--
If breaking or deprecating, describe: what changes, who is affected,
and the migration path or recommended alternative.
Also note any deployment, configuration, data migration or rollback considerations.
-->
## User-facing change
<!--
Does this PR change behaviour that users, operators or API consumers will notice?
If not, write "None".
If so, write one or two sentences suitable for release notes, in plain language and from the
user's point of view. If users must take action when upgrading, state clearly that action is required.
-->
```release-note
```
## Notes for reviewers
<!--
Help reviewers focus. For example:
- Where to start, or a suggested order in which to read the files.
- Areas where you would particularly value scrutiny, or where you are uncertain.
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
or a code generator), so that reviewers can reproduce the steps.
- Screenshots or recordings for visual changes (include before and after where useful).
-->
## Checklist
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
- [ ] I have added or updated tests that cover this change.
- [ ] The build succeeds and the tests pass locally.
- [ ] The code follows the existing patterns and style of this repository.
- [ ] I have updated documentation, changelogs or API references where required.
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.

View File

@@ -33,7 +33,7 @@ jobs:
if: always() if: always()
run: | run: |
status=0 status=0
for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)" code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
echo "$name exited with $code" echo "$name exited with $code"
if [ "$code" != "0" ]; then if [ "$code" != "0" ]; then

View File

@@ -77,7 +77,7 @@ flowchart TB
subgraph SHARED["Shared Projects"] subgraph SHARED["Shared Projects"]
direction LR direction LR
SH1["Shared.Contracts<br/>ResponseBody envelope"] SH1["Shared.Contracts<br/>ResponseBody envelope"]
SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"] SH2["Shared.Application<br/>ValidationBehavior"]
end end
subgraph DOMAIN["Domain Layer"] subgraph DOMAIN["Domain Layer"]
@@ -89,16 +89,14 @@ flowchart TB
subgraph INFRA["Infrastructure Layer"] subgraph INFRA["Infrastructure Layer"]
direction LR direction LR
I1["Database.Connection"] I1["Database.Connection"]
I2["Infrastructure.Jwt"]
I3["Infrastructure.PasswordHashing"] I3["Infrastructure.PasswordHashing"]
I4["Infrastructure.Email /<br/>Email.Templates"] I4["Email.Templates"]
I5["Infrastructure.FileUpload"]
end end
DB[("Database (SQL Server)")] DB[("Database (SQL Server)")]
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3 S3 == "ProjectReference<br/>(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1
S1 -- "ProjectReference<br/>(avatar upload)" --> S5 S1 -- "ProjectReference<br/>(avatar upload)" --> S5
SLICES --> SHARED SLICES --> SHARED
@@ -133,7 +131,6 @@ own
- Every `Features.*` project (for controller/MediatR discovery) - Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**: **Rules**:
@@ -167,19 +164,21 @@ brewery photo upload command) rather than bound to an HTTP route directly.
- `Domain.Entities`, `Domain.Exceptions` - `Domain.Entities`, `Domain.Exceptions`
- `Database.Connection` (generic ADO.NET connection/command plumbing) plus - `Database.Connection` (generic ADO.NET connection/command plumbing) plus
whichever infrastructure project the slice needs (`Infrastructure.Jwt`/ whichever infrastructure project the slice needs (`Infrastructure.PasswordHashing`
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/ for Users, `Infrastructure.Email.Templates` for Emails)
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users` references - No other `Features.*` project, with two exceptions: `Features.Users`
`Features.PhotoUpload` directly for avatar uploads references `Features.PhotoUpload` directly for avatar uploads, and
`Features.Emails` references `Features.Users` directly to handle
`UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification`
**Rules**: **Rules**:
- All business logic for that feature lives in its command/query handlers - All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through MediatR - No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly - Read endpoints return a dedicated `Dto` rather than the domain entity
directly. The one exception is `UserController.GetAuthenticated`, which
returns the caller's own `UserAccount` entity
#### Shared projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
@@ -190,8 +189,8 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose | | Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns | | `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) | | `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
**Rules**: **Rules**:
@@ -208,11 +207,8 @@ whichever slices need them
| Project | Description | | Project | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. | | `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
| `Infrastructure.Jwt` | JWT token generation and validation. |
| `Infrastructure.PasswordHashing` | Argon2id password hashing. | | `Infrastructure.PasswordHashing` | Argon2id password hashing. |
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). | | `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
**Dependencies**: **Dependencies**:
@@ -270,7 +266,8 @@ everything needed to understand or change one capability lives in one project
- Controllers generally bind directly to the Command/Query as the request body; - Controllers generally bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes there is no separate request DTO + mapping step for writes
- The exception to this is for authenticated routes, when the current user id - The exception to this is for authenticated routes, when the current user id
needs to be extracted from the authentication token to be used in a command. needs to be extracted from the authentication token to be used in a command
or query.
- A single shared `ValidationBehavior<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the (`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes MediatR pipeline before any handler executes
@@ -385,6 +382,7 @@ web/frontend/
│ │ - catalog beers, beer styles │ │ - catalog beers, beer styles
│ │ - home landing page │ │ - home landing page
│ │ - theme theme switcher/guide │ │ - theme theme switcher/guide
│ │ - users public user profile page (/users/:id)
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery │ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
│ ├── routes.ts Route table (React Router 8 config-based routing) │ ├── routes.ts Route table (React Router 8 config-based routing)
│ ├── root.tsx App shell and global providers │ ├── root.tsx App shell and global providers
@@ -401,6 +399,8 @@ web/frontend/
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
search search
- Render the auth demo, account dashboard, and theme guide routes - Render the auth demo, account dashboard, and theme guide routes
- Render the public user profile route (`/users/:id`), including a user's
following list and (via brewery comments) links back to commenter profiles
- Manage cookie-backed website session state - Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, confirmation, and - Call the .NET API for login, registration, token refresh, confirmation, and
brewery/location data brewery/location data
@@ -566,10 +566,11 @@ are not seeded either.
**Environments**: **Environments**:
| Environment | Compose file | | Environment | Compose file |
| ----------- | -------------------------- | | --------------- | ------------------------------- |
| Development | `docker-compose.dev.yaml` | | Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` | | Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` | | Production | `docker-compose.prod.yaml` |
| Production seed | `docker-compose.prod.seed.yaml` |
For details, see [Docker Guide](docker.md). For details, see [Docker Guide](docker.md).

View File

@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` | | `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` | | `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | | `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` | | `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice ### Tables defined in the schema but not yet wired to a feature slice

View File

@@ -127,9 +127,9 @@ sequenceDiagram
TokenSvc-->>RegHandler: Confirmation Token TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc deactivate TokenSvc
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract) RegHandler->>Mediator: Publish(UserRegisteredNotification)
activate Mediator activate Mediator
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first) Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via UserRegisteredNotificationHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
@@ -147,15 +147,15 @@ sequenceDiagram
SMTP-->>EmailDispatcher: Success / throws SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP deactivate SMTP
deactivate EmailDispatcher deactivate EmailDispatcher
deactivate Mediator
alt Email sent successfully alt Email sent successfully
RegHandler->>RegHandler: emailSent = true Note right of Mediator: UserRegisteredNotificationHandler completes normally
else mediator.Send throws else EmailDispatcher throws
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
end end
deactivate Mediator
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent) RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken)
deactivate RegHandler deactivate RegHandler
Mediator-->>API: RegistrationPayload Mediator-->>API: RegistrationPayload
deactivate Mediator deactivate Mediator

View File

@@ -51,13 +51,20 @@ sequenceDiagram
TokenSvc-->>ResendHandler: confirmation token TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc deactivate TokenSvc
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand) ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification)
activate Mediator activate Mediator
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via ConfirmationEmailResendRequestedNotificationHandler
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration) EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body) EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher deactivate EmailDispatcher
alt Email sent successfully
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler completes normally
else EmailDispatcher throws
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler catches and logs the exception — the resend request is unaffected
end
deactivate Mediator deactivate Mediator
end end
end end

View File

@@ -18,8 +18,9 @@ erDiagram
uniqueidentifier CountryID FK uniqueidentifier CountryID FK
} }
%% CityName + StateProvinceID form a composite unique constraint (AK_CityStateProvince)
City { City {
uniqueidentifier CityID PK uniqueidentifier CityID PK
nvarchar CityName nvarchar CityName UK
uniqueidentifier StateProvinceID FK uniqueidentifier StateProvinceID FK,UK
} }

View File

@@ -41,7 +41,7 @@ no data flowing over them.
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | | **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. | | **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. | | **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | | **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | | **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | | `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |

View File

@@ -1,11 +1,13 @@
sequenceDiagram sequenceDiagram
box Production Environment (docker-compose.prod.yaml) box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdMig as Migrations (run-once) participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
end end
ProdMig-->>ProdDB: depends_on ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
ProdMig->>ProdDB: Run migrations ProdWeb-->>ProdAPI: depends_on: api.core healthy
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
ProdAPI->>ProdDB: Connect and serve ProdAPI->>ProdDB: Connect and serve
ProdAPI->>ProdS3: Store and read photos
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack

View File

@@ -16,13 +16,14 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies ## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two Solid arrows are compile-time project references — every slice-to-slice
places slices talk to each other without a direct reference: the email MediatR dependency is one now that the email MediatR contract has moved off
contract routed through `Shared.Application`, and the one place that pattern is `Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar
broken outright. Every DB-backed slice depends on the same three foundation uploads, and `Features.Emails` on `Features.Users` for the registration and
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn resend-confirmation notifications. Every DB-backed slice depends on the same
as one box instead of fifteen separate arrows; see the notes for what else each three foundation projects (`Shared.Contracts`, `Domain.Entities`,
of those foundation projects pulls in. `Database.Connection`) — drawn as one box instead of fifteen separate arrows;
see the notes for what else each of those foundation projects pulls in.
```mermaid ```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd" --8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -44,8 +45,6 @@ of those foundation projects pulls in.
commands/queries + handlers, validators, and Dapper repository commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management - `Features.Users` covers auth (`Commands/Authentication`), account management
(`Commands/Account`), and profile/avatar (`Commands/Profile`) (`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name
`Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked - `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
only via MediatR commands sent from other slices only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler - `Features.Locations` has no controller; one MediatR handler
@@ -54,17 +53,20 @@ of those foundation projects pulls in.
- `Features.Beers` is scaffolded (project + references only, no source files - `Features.Beers` is scaffolded (project + references only, no source files
yet) yet)
- **The one exception to slice isolation**: `Features.Users` takes a direct - **The exceptions to slice isolation**:
project reference to `Features.PhotoUpload` - `Features.Users` takes a direct project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- unlike the email flow, the command type lives in the target slice itself, - the command type lives in the target slice itself, not in
not in `Shared.Application` `Shared.Application`
- `Features.Emails` takes a direct project reference to `Features.Users`
- handles `UserRegisteredNotification` (published from
`RegisterUserHandler`) and `ConfirmationEmailResendRequestedNotification`
(published from `ResendConfirmationEmailHandler`)
- both notification types live in `Features.Users`
(`Features.Users.Notifications`), not in `Shared.Application`
- **Shared.Application**: - **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline) - `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline - Every slice (including `Features.Emails`) references it for the pipeline
behavior alone behavior alone
@@ -95,9 +97,9 @@ of those foundation projects pulls in.
- **Omitted from the diagram**: - **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including - `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) all of `Shared.Contracts` and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project - `Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Infrastructure.Email`, referenced by `Database.Connection`, `Features.PhotoUpload`,
`Infrastructure.FileUpload`, and `Features.Emails` directly) and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing - both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in them would just add a second copy of the same fan-in

View File

@@ -23,20 +23,16 @@ end
subgraph INFRA["Slice-specific Infrastructure"] subgraph INFRA["Slice-specific Infrastructure"]
direction LR direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"] PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"] EmailTemplates["Infrastructure.Email.Templates"]
end end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"] EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice
EmailsSlice -. handles email commands .-> SharedApp
BrewerySlice --> FOUNDATION BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION BeersSlice --> FOUNDATION
PhotoSlice --> FOUNDATION & FileUpload PhotoSlice --> FOUNDATION
UsersSlice --> FOUNDATION & JWT & PwdHash UsersSlice --> FOUNDATION & PwdHash
EmailsSlice --> Email & EmailTemplates EmailsSlice --> EmailTemplates
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")] DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]

View File

@@ -98,7 +98,7 @@ sqlserver # Test database
database.migrations # Fresh schema database.migrations # Fresh schema
database.seed # Test data database.seed # Test data
api.specs # Reqnroll BDD tests api.specs # Reqnroll BDD tests
unit.tests # All Features.*.Tests unit test projects unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image) frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
``` ```
@@ -134,27 +134,136 @@ containers, which would end the run prematurely. See
- Production logging levels - Production logging levels
- No database clearing - No database clearing
- Optimized build configurations - Optimized build configurations
- Health checks enabled - Health checks on every long-running service
- Restart policies (unless-stopped) - Restart policies that survive a reboot (see
[Restart tolerance](#restart-tolerance))
- Security hardening - Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are - Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
reachable only from other containers on `prodnet` proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
so the seed job can join it from its own Compose project)
- Seeding is a separate, explicitly run compose file
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
catalogue as it is. See [Production data](#production-data) below.
**Services**: **Services**:
```yaml ```yaml
sqlserver # Production SQL Server (internal only) sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API (internal only, no published ports) api.core # Production API (internal only, no published ports)
frontend # Production website (port 3000, the only public binding) seaweedfs # S3-compatible object storage (internal only)
frontend # Production website (127.0.0.1:5656, the only host binding)
``` ```
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
restart touches neither the schema nor the catalogue. Run them explicitly at
deploy time: see [Migrating production](#migrating-production) and
[Seeding production](#seeding-production-docker-composeprodseedyaml).
**Deploy Production**: **Deploy Production**:
```bash ```bash
# One time, before the first `up`: the data volumes are external, so Compose
# expects them to exist already.
docker volume create biergartendata-prod
docker volume create seaweedfsdata-prod
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
``` ```
Docker seeds each volume with the ownership the image expects and labels it for
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
enforcing distributions such as RHEL, AlmaLinux and Fedora.
#### Migrating production
`database.migrations` is a run-once job rather than a service in the prod stack,
so the schema is migrated when an operator asks for it instead of on every boot.
Build the image and run it against the network the prod stack creates:
```bash
docker build -t database.migrations \
-f web/backend/Database/Database.Migrations/Dockerfile \
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
web/backend
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
```
DbUp records every script it has applied, so a re-run applies only what is new.
Run this with `sqlserver` already up and healthy, and before the seed job.
#### Restart tolerance
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
`restart: unless-stopped`, so the stack returns on its own after a container
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
stack, so a reboot replays neither.
The daemon replays restart policies without replaying the `depends_on` graph, so
after a reboot the containers come back in arbitrary order and each one tolerates
a dependency that arrives late. `api.core` opens its database and S3 connections
per request rather than at startup, and `frontend` answers with its own error
states while `api.core` is still coming up.
Health checks report when the stack has converged:
| Service | Probe |
| --- | --- |
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
finish on shutdown instead of being recovered on the next start.
Deploy and wait for the stack to report healthy, or check convergence after a
reboot:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
```
#### Seeding production (`docker-compose.prod.seed.yaml`)
`database.seed` lives in its own compose file, run on demand once the prod stack
is up, `sqlserver` is healthy, and the schema is migrated:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
run --rm --build database.seed
```
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
external `biergarten-prodnet` network, which the main prod stack creates, so it
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
up first, because the seed project has no visibility into the other project's
health checks, so a run started before SQL Server is accepting connections fails.
#### Production data
A production deployment seeded this way serves the AI-generated fixture data
produced by the pipeline. Per
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
anyone interacting with an application seeded from it has to be told upfront that
the content is AI-generated.
The website meets that obligation with `AiDisclosureDialog`
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
the root route renders on every full page load, whichever route the visitor lands
on. It is part of the server-rendered HTML rather than mounted after hydration, so
the disclosure is on screen with the first paint. Acknowledging it hides the
dialog for the remainder of that page load; a new load shows it again.
Keep the seed job and the dialog together: any deployment that runs
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
ethics documentation.
### 4. Database only (`docker-compose.db.yaml`) ### 4. Database only (`docker-compose.db.yaml`)
**Purpose**: Run just the database for local backend development outside Docker **Purpose**: Run just the database for local backend development outside Docker
@@ -215,6 +324,12 @@ graph TD
In development, `api.core` also depends on `seaweedfs` In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
Production has a shorter chain, because the migration and seed jobs run outside
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
`frontend` waits for `api.core` to report healthy. This ordering applies to
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
on a host reboot.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
```yaml ```yaml
@@ -255,8 +370,10 @@ api.core:
**Production**: **Production**:
- `sqlserverdata-prod` - Production database files - `biergartendata-prod` - Database files, external; survives `down -v`
- `nuget-cache-prod` - Production NuGet cache - `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
See [Production data volumes](#production-data-volumes).
### Mounted volumes ### Mounted volumes
@@ -269,6 +386,41 @@ volumes:
Test results are written to host filesystem for CI/CD integration. Test results are written to host filesystem for CI/CD integration.
### Production data volumes
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
Compose removes only the volumes it owns, so `docker compose down -v` tears the
stack down and leaves the database and the uploaded photos in place. Deleting
them takes an explicit `docker volume rm`.
The trade-off is that Compose creates neither one, and `up` fails with a clear
error until both exist, hence the `docker volume create` step in
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
`web_`-prefixed volumes of the dev and test stacks.
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
`/var/lib/docker`.
Back up the database through SQL Server rather than by copying its files, since a
file copy taken while the engine is running can be torn:
```bash
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P '<db-password>' -C \
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
```
Object storage can be archived directly, with the stack stopped:
```bash
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
```
## Networks ## Networks
Each environment uses isolated bridge networks: Each environment uses isolated bridge networks:

View File

@@ -233,7 +233,7 @@ file (`development` in dev, `production` in prod).
### SMTP configuration (backend) ### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and Read by `Features.Emails/Services/SmtpEmailProvider.cs` for sending confirmation and
account emails. account emails.
```bash ```bash
@@ -246,7 +246,7 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten" SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
``` ```
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on - **Implementation**: `Features.Emails/Services/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web - **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025) UI on http://localhost:8025)
@@ -255,22 +255,30 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
### File storage configuration (backend) ### File storage configuration (backend)
Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and Read by `Features.PhotoUpload/Services/S3FileStorageProvider.cs` for uploading
retrieving photos via an S3-compatible object store. and retrieving photos via an S3-compatible object store.
```bash ```bash
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
SEAWEEDFS_BUCKET=my-bucket # Required, no default SEAWEEDFS_BUCKET=my-bucket # Required, no default
``` ```
- **Implementation**: `S3FileStorageProvider` throws on construction if any of - **Implementation**: `S3FileStorageProvider` throws on construction if any of
these four values is missing the four required values is missing
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) - **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted - **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
SeaweedFS requires and AWS S3 also accepts SeaweedFS requires and AWS S3 also accepts
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
used by `GetPresignedUrl` and reaching the API response as
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
this has to be the address the browser uses (`http://localhost:8333` with the
dev compose file, which publishes the S3 API port), separate from the
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
same value wherever one address serves both
## Docker-specific variables ## Docker-specific variables
@@ -317,6 +325,7 @@ from the template in place; edit them manually if you go that route.
- `web/docker-compose.dev.yaml` → `web/.env.dev` - `web/docker-compose.dev.yaml` → `web/.env.dev`
- `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod` - `web/docker-compose.prod.yaml` → `web/.env.prod`
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
## Variable reference table ## Variable reference table
@@ -340,6 +349,7 @@ from the template in place; edit them manually if you go that route.
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | | `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | | `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | | `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | | `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
@@ -414,6 +424,7 @@ SMTP_FROM_NAME=The Biergarten App
# SeaweedFS (S3-compatible storage) # SeaweedFS (S3-compatible storage)
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl> SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl> SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -76,10 +76,11 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
You can run individual test projects locally without Docker: You can run individual test projects locally without Docker:
### Feature slice unit tests ### Feature slice and shared unit tests
Each feature slice has its own test project, covering its command/query Each feature slice has its own test project, covering its command/query
handlers: handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
its own test project under `Shared/`:
```bash ```bash
cd web/backend cd web/backend
@@ -87,9 +88,10 @@ dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
# Or run all of them at once via the solution: # Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
``` ```
**Requirements**: **Requirements**:
@@ -247,13 +249,16 @@ API.Specs/
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios │ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios │ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ └── NotFound.feature # 404 handling │ ├── NotFound.feature # 404 handling
│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
├── Steps/ ├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features │ ├── AuthSteps.cs # Step definitions for the Auth features
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
│ └── ApiGeneralSteps.cs # Shared/general step definitions │ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/ ├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider │ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above) └── TestApiFactory.cs # Test server setup (swaps in the mocks above)
``` ```
@@ -278,17 +283,25 @@ Scenario: Successful user registration
Features.Users.Tests/ Features.Users.Tests/
├── Commands/ ├── Commands/
│ ├── RegisterUserHandlerTests.cs │ ├── RegisterUserHandlerTests.cs
│ ├── RegisterUserValidatorTests.cs
│ ├── ConfirmUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs │ ├── RefreshTokenHandlerTests.cs
│ ├── RefreshTokenValidatorTests.cs
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
│ ├── UpdateUsernameHandlerTests.cs │ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateUsernameValidatorTests.cs
│ ├── UpdateEmailHandlerTests.cs │ ├── UpdateEmailHandlerTests.cs
│ ├── UpdateEmailValidatorTests.cs
│ ├── UpdatePasswordHandlerTests.cs │ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdatePasswordValidatorTests.cs
│ ├── UpdateProfileHandlerTests.cs │ ├── UpdateProfileHandlerTests.cs
│ ├── UpdateProfileValidatorTests.cs
│ └── DeleteAccountHandlerTests.cs │ └── DeleteAccountHandlerTests.cs
├── Queries/ ├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs │ ├── GetAllUsersHandlerTests.cs
│ ├── GetPublicUserProfileByIdHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs │ └── GetUserByIdHandlerTests.cs
├── Services/ ├── Services/
│ ├── TokenServiceRefreshTests.cs │ ├── TokenServiceRefreshTests.cs
@@ -296,10 +309,13 @@ Features.Users.Tests/
└── TestSupport/ # shared test fixtures/helpers └── TestSupport/ # shared test fixtures/helpers
``` ```
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a `*ValidatorTests.cs` file beside each handler whose command or query has a
validator test file alongside its handler test). FluentValidation validator (`Features.Breweries.Tests` has one of these in
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
differs: its handlers respond to MediatR notifications, so its test files live
under `Notifications/`.
## Writing tests ## Writing tests

View File

@@ -22,7 +22,7 @@ The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
### Infrastructure layer ### Infrastructure layer
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs) #### [ITokenInfrastructure](../../web/backend/Features/Features.Users/Services/ITokenInfrastructure.cs)
Low-level JWT operations. Low-level JWT operations.
@@ -32,7 +32,7 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format - `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** **Implementation:**
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs) [JwtInfrastructure.cs](../../web/backend/Features/Features.Users/Services/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256) - Algorithm: HS256 (HMAC-SHA256)
@@ -76,12 +76,11 @@ directly, bypassing the `Features.Users` slice entirely.
- Extracts and validates claims (Sub, UniqueName) - Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure - Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` `TokenService` and the lower-level `ITokenInfrastructure` (JWT
extension method, except for the lower-level `ITokenInfrastructure` (JWT signing/verification) it depends on are both registered by `Features.Users`'
signing/verification) it depends on, which is registered by the host own `AddFeaturesUsers()` extension method. `JwtAuthenticationHandler`
(`API.Core/ServiceCollectionExtensions.cs`, via `AddCoreInfrastructure()`, (host-level auth middleware) also depends on `ITokenInfrastructure` directly,
called from `Program.cs`) since `JwtAuthenticationHandler` (host-level auth resolving it from the same DI container.
middleware) also depends on it directly.
### Integration points ### Integration points

View File

@@ -68,8 +68,12 @@ WEBSITE_BASE_URL=http://localhost:3000
# ====================== # ======================
# SeaweedFS (S3) Configuration # SeaweedFS (S3) Configuration
# ====================== # ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage # Credentials and default bucket for the local SeaweedFS S3-compatible storage.
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -15,20 +15,19 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Infrastructure.Configuration; using Features.Users.Services;
using Infrastructure.Jwt; using Configuration;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;

View File

@@ -25,6 +25,7 @@ COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Except
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
@@ -32,13 +33,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Configuration/Configuration.csproj", "Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -1,17 +1,17 @@
using API.Core.Authentication; using API.Core.Authentication;
using Database.Connection.DependencyInjection; using Database.Connection.DependencyInjection;
using Features.Auth.Controllers;
using Features.Breweries.Controllers; using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection; using Features.Emails.DependencyInjection;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection; using Features.ImageUploads.DependencyInjection;
using Features.Locations.Controllers;
using Features.Locations.DependencyInjection;
using Features.Users.Controllers;
using Features.Users.DependencyInjection; using Features.Users.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
@@ -26,6 +26,7 @@ internal static class ServiceCollectionExtensions
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>()) .AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
.AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly) .AddApplicationPart(typeof(AuthController).Assembly)
.AddApplicationPart(typeof(CityController).Assembly)
.Services; .Services;
public IServiceCollection AddValidationAndMediatR() public IServiceCollection AddValidationAndMediatR()
@@ -44,6 +45,8 @@ internal static class ServiceCollectionExtensions
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });
@@ -96,14 +99,11 @@ internal static class ServiceCollectionExtensions
.AddFeaturesBreweries() .AddFeaturesBreweries()
.AddFeaturesUsers() .AddFeaturesUsers()
.AddFeaturesEmails() .AddFeaturesEmails()
.AddFeaturesPhotoUpload(); .AddFeaturesPhotoUpload()
.AddFeaturesLocations();
public IServiceCollection AddCoreInfrastructure() => public IServiceCollection AddCoreInfrastructure() =>
services services.AddScoped<GlobalExceptionFilter>();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
.AddScoped<ITokenInfrastructure, JwtInfrastructure>()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<GlobalExceptionFilter>();
public IServiceCollection AddJwtAuthentication() public IServiceCollection AddJwtAuthentication()
{ {

View File

@@ -38,8 +38,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />

View File

@@ -15,6 +15,7 @@ COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Except
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
@@ -22,13 +23,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Configuration/Configuration.csproj", "Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -0,0 +1,130 @@
Feature: Public User Profile
As a client of the API
I want to read a user's public-facing profile
So that a public profile page can render without exposing private account fields
Background:
Given the API is running
Scenario: Public profile exposes only allowlisted fields
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Public profile exposes the uploaded avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Public profile exposes the current avatar after it is replaced
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I have uploaded a new avatar, replacing the previous one
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
And the public profile response should not carry my previous avatar
Scenario: Fetching the avatar without authentication succeeds
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: A different user can read someone else's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Listing user accounts exposes each account's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my current avatar
Scenario: Listing user accounts exposes each account's biography
Given I have registered a new account
And I have a valid access token for my account
And I have set my biography to "Drinks lagers."
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my biography
Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by a non-existent ID
Then the response has HTTP status 404
Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: A different user can read someone else's public profile
Given I have registered a new account
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Fetching my own account without authentication is rejected
When I retrieve my own account without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected
When I list user accounts without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts as an authenticated user excludes private fields
Given I have registered a new account
And I have a valid access token for my account
When I list user accounts
Then the response has HTTP status 200
And the response JSON should contain "avatarUrl"
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: The caller can fetch their own account
Given I have registered a new account
And I have a valid access token for my account
When I retrieve my own account
Then the response has HTTP status 200
And the public profile response should match the registered account
Scenario: The caller's own account omits the avatar graph
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve my own account
Then the response has HTTP status 200
And the response JSON should not contain "userAvatar"
Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account
And I am logged in as a different user
When I retrieve my own account
Then the response has HTTP status 200
And the account response should be for a different account than the registered one

View File

@@ -1,4 +1,4 @@
using Infrastructure.Email; using Features.Emails.Services;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;

View File

@@ -0,0 +1,55 @@
using Features.ImageUploads.Services;
namespace API.Specs.Mocks;
/// <summary>
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
/// exercise photo-backed features without object storage.
/// </summary>
public class MockFileStorageProvider : IFileStorageProvider
{
private readonly List<string> _keys = [];
private readonly Dictionary<string, byte[]> _objects = new();
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
/// <summary>The key of the most recent upload.</summary>
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
public string LastUploadedKey =>
_keys.Count > 0
? _keys[^1]
: throw new InvalidOperationException("no file has been uploaded");
public Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
using MemoryStream buffer = new();
content.CopyTo(buffer);
if (!_objects.ContainsKey(key))
_keys.Add(key);
_objects[key] = buffer.ToArray();
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
_objects.Remove(key);
_keys.Remove(key);
return Task.CompletedTask;
}
/// <summary>
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
/// carrying the key in its path, so specs can assert the shape callers actually receive.
/// </summary>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
}
}

View File

@@ -167,4 +167,86 @@ public class ApiGeneralSteps(ScenarioContext scenario)
actualValue actualValue
); );
} }
[Then("the response JSON should not contain {string}")]
public void ThenTheResponseJsonShouldNotContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
AssertFieldAbsent(root, field);
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("payload", out JsonElement payloadElem))
AssertFieldAbsent(payloadElem, field);
}
[Then("the response JSON should contain {string}")]
public void ThenTheResponseJsonShouldContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
!root.TryGetProperty(field, out _) &&
root.TryGetProperty("payload", out JsonElement payloadElem))
{
AssertFieldPresent(payloadElem, field);
return;
}
AssertFieldPresent(root, field);
}
/// <summary>
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response.
/// </summary>
private static void AssertFieldAbsent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeFalse("Expected field '{0}' to be absent from the response", field);
break;
case JsonValueKind.Array:
foreach (JsonElement item in element.EnumerateArray())
AssertFieldAbsent(item, field);
break;
}
}
/// <summary>
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
/// </summary>
private static void AssertFieldPresent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeTrue("Expected field '{0}' to be present in the response", field);
break;
case JsonValueKind.Array:
element
.EnumerateArray()
.Should()
.NotBeEmpty(
"Expected at least one item to assert field '{0}' against",
field
);
foreach (JsonElement item in element.EnumerateArray())
AssertFieldPresent(item, field);
break;
}
}
} }

View File

@@ -1,8 +1,8 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Features.Users.Services;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Reqnroll; using Reqnroll;
@@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario["accessToken"] = accessToken;
}
[Given("I am logged in")] [Given("I am logged in")]
public async Task GivenIAmLoggedIn() public async Task GivenIAmLoggedIn()
{ {

View File

@@ -1,7 +1,6 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Database.Connection; using Database.Connection;
using Features.Locations.Dtos;
using Features.Locations.Repository; using Features.Locations.Repository;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -23,11 +22,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
private const string FixtureStateProvinceIsoCode = "FX-TS"; private const string FixtureStateProvinceIsoCode = "FX-TS";
private const string FixtureCityName = "Fixture City"; private const string FixtureCityName = "Fixture City";
// GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
// city (see its remarks); scenarios run in parallel across feature classes, so provisioning
// the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url) private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{ {
HttpRequestMessage requestMessage = new(method, url); HttpRequestMessage requestMessage = new(method, url);
@@ -53,24 +47,21 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
.Services.GetRequiredService<ISqlConnectionFactory>(); .Services.GetRequiredService<ISqlConnectionFactory>();
LocationRepository repository = new(connectionFactory); LocationRepository repository = new(connectionFactory);
CityLocation fixtureCity = new( Guid countryId =
FixtureCityName, await repository.GetCountryIdAsync(FixtureCountryIsoCode)
?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode);
Guid stateProvinceId =
await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode)
?? await repository.CreateStateProvinceAsync(
FixtureStateProvinceName, FixtureStateProvinceName,
FixtureStateProvinceIsoCode, FixtureStateProvinceIsoCode,
FixtureCountryName, countryId
FixtureCountryIsoCode
); );
await CityProvisioningLock.WaitAsync(); Guid cityId =
Guid cityId; await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode)
try ?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId);
{
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
}
finally
{
CityProvisioningLock.Release();
}
Scenario[CityIdKey] = cityId; Scenario[CityIdKey] = cityId;
} }
@@ -271,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(requestMessage); await SendAsync(requestMessage);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario[AccessTokenKey] = accessToken;
}
[Then("retrieving the brewery by ID should now return HTTP status {int}")] [Then("retrieving the brewery by ID should now return HTTP status {int}")]
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode) public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
{ {

View File

@@ -0,0 +1,290 @@
using System.Text.Json;
using API.Specs.Mocks;
using Features.ImageUploads.Services;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Repository;
using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string CurrentAvatarKeyKey = "currentAvatarKey";
private const string PreviousAvatarKeyKey = "previousAvatarKey";
private const string BiographyKey = "biography";
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage)
{
HttpClient client = GetClient();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{
HttpRequestMessage requestMessage = new(method, url);
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
return requestMessage;
}
private Guid GetRegisteredUserId()
{
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
}
/// <summary>
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
/// 1 KiB minimum.
/// </summary>
private static IFormFile CreateAvatarFile()
{
byte[] content = new byte[2048];
PngSignature.CopyTo(content, 0);
MemoryStream stream = new(content);
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
};
}
/// <summary>
/// Uploads an avatar and records the object key the storage provider actually received, rather
/// than re-deriving the key template the handler uses.
/// </summary>
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
Scenario[PreviousAvatarKeyKey] = existing;
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
}
private MockFileStorageProvider GetStorageProvider()
{
return (MockFileStorageProvider)
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
}
private string GetCurrentAvatarKey()
{
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
}
private string GetPreviousAvatarKey()
{
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException(
"only one avatar has been uploaded in this scenario"
);
}
/// <summary>
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
/// storage key would resolve against the page's own origin, so the shape is the contract here,
/// not just the value.
/// </summary>
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
{
avatarUrl.Should().NotBeNullOrWhiteSpace();
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
.Should()
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
return uri;
}
private string GetAvatarUrlFromResponse()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
}
[Given("I have uploaded an avatar")]
public async Task GivenIHaveUploadedAnAvatar()
{
await UploadAvatarAsync();
}
[Given("I have uploaded a new avatar, replacing the previous one")]
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
{
await UploadAvatarAsync();
}
/// <summary>
/// Writes the biography straight through the repository: registration creates the profile row
/// with an empty biography, and no endpoint edits it yet.
/// </summary>
[Given("I have set my biography to {string}")]
public async Task GivenIHaveSetMyBiographyTo(string biography)
{
using IServiceScope scope = GetFactory().Services.CreateScope();
IUserProfileRepository profiles =
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
await profiles.UpdateBiographyAsync(
GetRegisteredUserId(),
biography,
CancellationToken.None
);
Scenario[BiographyKey] = biography;
}
[Then("the public profile response should carry my current avatar")]
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
}
[Then("the public profile response should not carry my previous avatar")]
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
{
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
}
[Then("my listed account should carry my current avatar")]
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
GetCurrentAvatarKey()
);
}
[Then("my listed account should carry my biography")]
public void ThenMyListedAccountShouldCarryMyBiography()
{
string expected =
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
? biography
: throw new InvalidOperationException("no biography was set in this scenario");
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
}
/// <summary>Finds the registered account in a listing response.</summary>
private JsonElement GetMyListedAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
return doc
.RootElement.EnumerateArray()
.Should()
.ContainSingle(item =>
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
)
.Subject.Clone();
}
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by ID without authentication")]
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
{
await SendAsync(
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by a non-existent ID")]
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
);
}
[When("I retrieve my own account without authentication")]
public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
}
[When("I list user accounts without authentication")]
public async Task WhenIListUserAccountsWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
}
[When("I list user accounts")]
public async Task WhenIListUserAccounts()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
}
[When("I retrieve my own account")]
public async Task WhenIRetrieveMyOwnAccount()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
}
[Then("the public profile response should match the registered account")]
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
? u
: throw new InvalidOperationException("registered username not found in scenario");
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username);
}
[Then("the account response should be for a different account than the registered one")]
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("userAccountId")
.GetGuid()
.Should()
.NotBe(GetRegisteredUserId());
}
}

View File

@@ -1,7 +1,7 @@
using API.Core; using API.Core;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Infrastructure.Email; using Features.ImageUploads.Services;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -33,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory<Program>
services.Remove(emailDispatcherDescriptor); services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>(); services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IFileStorageProvider)
);
if (fileStorageDescriptor != null)
services.Remove(fileStorageDescriptor);
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
}); });
} }
} }

View File

@@ -1,4 +1,11 @@
<Solution> <Solution>
<Folder Name="/Docker/">
<File Path="../docker-compose.db.yaml"/>
<File Path="../docker-compose.min.yaml"/>
<File Path="../docker-compose.dev.yaml"/>
<File Path="../docker-compose.prod.yaml"/>
<File Path="../docker-compose.test.yaml"/>
</Folder>
<Folder Name="/API/"> <Folder Name="/API/">
<Project Path="API/API.Core/API.Core.csproj" /> <Project Path="API/API.Core/API.Core.csproj" />
<Project Path="API/API.Specs/API.Specs.csproj" /> <Project Path="API/API.Specs/API.Specs.csproj" />
@@ -12,25 +19,21 @@
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" /> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" /> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder> </Folder>
<Folder Name="/Features/Users/"> <Folder Name="/Configuration/">
<Project Path="Features\Features.Users.Tests\Features.Users.Tests.csproj" /> <Project Path="Configuration/Configuration.csproj" />
<Project Path="Features\Features.Users\Features.Users.csproj" />
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder> </Folder>
<Folder Name="/Features/" /> <Folder Name="/Features/" />
<Folder Name="/Features/Users/">
<Project Path="Features/Features.Users.Tests/Features.Users.Tests.csproj" />
<Project Path="Features/Features.Users/Features.Users.csproj" />
</Folder>
<Folder Name="/Features/Breweries/"> <Folder Name="/Features/Breweries/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" /> <Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" /> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Features/Emails/"> <Folder Name="/Features/Emails/">
<Project Path="Features/Features.Emails/Features.Emails.csproj" /> <Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Templates/Features.Emails.Templates.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Features/Locations/"> <Folder Name="/Features/Locations/">
@@ -44,5 +47,6 @@
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
<Project Path="Shared/Shared.Application/Shared.Application.csproj" /> <Project Path="Shared/Shared.Application/Shared.Application.csproj" />
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Configuration</RootNamespace> <RootNamespace>Configuration</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationHelpers public static class ConfigurationHelpers
{ {

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationKeys public static class ConfigurationKeys
{ {
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
public const string SmtpFromName = "SMTP_FROM_NAME"; public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL"; public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
/// <summary>
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
/// the container-internal endpoint is only usable by callers that can resolve that host.
/// </summary>
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID"; public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY"; public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET"; public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";

View File

@@ -21,6 +21,6 @@
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" /> <EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -1,5 +1,5 @@
using Database.Connection; using Database.Connection;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -5,11 +5,11 @@ CREATE TABLE Geolocation.StateProvince
StateProvinceName NVARCHAR(100) NOT NULL, StateProvinceName NVARCHAR(100) NOT NULL,
ISO3166_2 CHAR(6) NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
CountryID UNIQUEIDENTIFIER NOT NULL, CountryID UNIQUEIDENTIFIER NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
ISO3166_2 CHAR(6) NOT NULL,
RowVersion ROWVERSION, RowVersion ROWVERSION,
CONSTRAINT PK_StateProvince CONSTRAINT PK_StateProvince
@@ -18,6 +18,9 @@ CREATE TABLE Geolocation.StateProvince
CONSTRAINT AK_StateProvince_ISO3166_2 CONSTRAINT AK_StateProvince_ISO3166_2
UNIQUE (ISO3166_2), UNIQUE (ISO3166_2),
CONSTRAINT AK_StateProvince_Country
UNIQUE (StateProvinceName, CountryID),
CONSTRAINT FK_StateProvince_Country CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID) FOREIGN KEY (CountryID)
REFERENCES Geolocation.Country (CountryID) REFERENCES Geolocation.Country (CountryID)

View File

@@ -14,7 +14,9 @@ CREATE TABLE Geolocation.City
CONSTRAINT FK_City_StateProvince CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID) FOREIGN KEY (StateProvinceID)
REFERENCES Geolocation.StateProvince (StateProvinceID) REFERENCES Geolocation.StateProvince (StateProvinceID),
CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID)
); );
CREATE CREATE

View File

@@ -0,0 +1,371 @@
using Database.Seed.DatabaseHelpers;
using Database.Seed.Sqlite;
using Database.Seed.SourceDataModels;
using Domain.Entities;
using Features.Breweries.Commands.CreateBrewery;
using Features.Locations.Commands.CreateCity;
using Features.Locations.Commands.CreateCountry;
using Features.Locations.Commands.CreateStateProvince;
using Features.Locations.Queries.GetCity;
using Features.Locations.Queries.GetCountry;
using Features.Locations.Queries.GetStateProvince;
using Features.Users.Commands.Authentication.RegisterUser;
using Features.Users.Commands.Profile.UpdateBiography;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Dtos;
using idunno.Password;
using MediatR;
using Microsoft.AspNetCore.Http;
using Spectre.Console;
using City = Database.Seed.SourceDataModels.City;
namespace Database.Seed;
public class BiergartenDataSeeder
{
private readonly IMediator _mediator;
private readonly SeedRepository _reader;
public BiergartenDataSeeder(IMediator mediator, SeedRepository reader)
{
_mediator = mediator;
_reader = reader;
}
public async Task Run(CancellationToken cancellationToken = default)
{
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
AnsiConsole.WriteLine();
SeedData seedData = null!;
IReadOnlyList<Guid> userIds = [];
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading seed data...",
async ctx =>
{
seedData = await _reader.ReadSeedDataAsync(cancellationToken);
ctx.Status($"Done.");
}
);
AnsiConsole.MarkupLine(
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
);
AnsiConsole.Write(BuildBreweryDisplayTable(seedData.Breweries));
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
AnsiConsole.Write(BuildUserDisplayTable(seedData.Users));
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Cities.Count}[/] cities.");
AnsiConsole.WriteLine();
AnsiConsole.WriteLine("Seed data loaded successfully.");
AnsiConsole.Write(
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading user data into target database...",
async ctx =>
{
userIds = await LoadUsersIntoDatabaseAsync(seedData.Users, cancellationToken);
ctx.Status("User data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading avatars into target database...",
async ctx =>
{
await LoadAvatarsIntoDatabaseAsync(
userIds,
seedData.Users,
ctx,
cancellationToken
);
ctx.Status("Avatar data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading city data into target database...",
async ctx =>
{
await LoadCitiesIntoDatabaseAsync(seedData.Cities, ctx, cancellationToken);
ctx.Status("City data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading brewery data into target database...",
async ctx =>
{
await LoadBreweriesIntoDatabaseAsync(
seedData.Breweries,
userIds,
ctx,
cancellationToken
);
ctx.Status("Brewery data loaded into target database.");
}
);
}
private async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IReadOnlyList<UserRecord> users,
CancellationToken cancellationToken
)
{
List<Guid> userAccountIds = [];
foreach (UserRecord userRecord in users)
{
cancellationToken.ThrowIfCancellationRequested();
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9)
string password = PasswordGenerator.Generate(
length: 64,
numberOfDigits: 12,
numberOfSymbols: 12,
allowRepeatedCharacters: true
);
RegistrationPayload registration = await _mediator.Send(
new RegisterUserCommand(
userRecord.User.Username,
userRecord.User.FirstName,
userRecord.User.LastName,
userRecord.Email,
DateTime.Parse(userRecord.DateOfBirth),
password
),
cancellationToken
);
userAccountIds.Add(registration.UserAccountId);
}
return userAccountIds;
}
private async Task LoadAvatarsIntoDatabaseAsync(
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx,
CancellationToken cancellationToken
)
{
for (int i = 0; i < userIds.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
Guid userId = userIds[i];
await _mediator.Send(
new UpdateBiographyCommand(userId, users[i].User.Bio),
cancellationToken
);
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
};
await _mediator.Send(new UploadAvatarCommand(userId, file), cancellationToken);
}
}
private async Task LoadCitiesIntoDatabaseAsync(
IReadOnlyList<City> cities,
StatusContext ctx,
CancellationToken cancellationToken
)
{
Dictionary<string, Guid> countryIdsByIso31661 = [];
Dictionary<string, Guid> stateProvinceIdsByIso31662 = [];
foreach (City city in cities)
{
cancellationToken.ThrowIfCancellationRequested();
ctx.Status(
$"Loading city {city.CityName}, {city.StateProvinceName}, {city.CountryName}..."
);
if (!countryIdsByIso31661.TryGetValue(city.ISO_3166_1, out Guid countryId))
{
countryId =
await _mediator.Send(new GetCountryQuery(city.ISO_3166_1), cancellationToken)
?? await _mediator.Send(
new CreateCountryCommand(city.CountryName, city.ISO_3166_1),
cancellationToken
);
countryIdsByIso31661[city.ISO_3166_1] = countryId;
}
if (!stateProvinceIdsByIso31662.TryGetValue(city.ISO_3166_2, out Guid stateProvinceId))
{
stateProvinceId =
await _mediator.Send(
new GetStateProvinceQuery(city.ISO_3166_2),
cancellationToken
)
?? await _mediator.Send(
new CreateStateProvinceCommand(
city.StateProvinceName,
city.ISO_3166_2,
countryId
),
cancellationToken
);
stateProvinceIdsByIso31662[city.ISO_3166_2] = stateProvinceId;
}
await _mediator.Send(
new CreateCityCommand(city.CityName, stateProvinceId),
cancellationToken
);
}
}
private async Task LoadBreweriesIntoDatabaseAsync(
IReadOnlyList<BreweryResult> breweries,
IReadOnlyList<Guid> posterUserIds,
StatusContext ctx,
CancellationToken cancellationToken
)
{
if (posterUserIds.Count == 0)
throw new InvalidOperationException(
"Cannot load breweries without any registered users."
);
for (int i = 0; i < breweries.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
BreweryResult breweryRecord = breweries[i];
BreweryAddress address =
breweryRecord.Address
?? throw new InvalidOperationException("Brewery address was not loaded.");
City city =
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
Guid cityId =
await _mediator.Send(
new GetCityQuery(city.CityName, city.ISO_3166_2),
cancellationToken
)
?? throw new InvalidOperationException(
$"City '{city.CityName}' "
+ $"({city.ISO_3166_2}) was not found. "
+ "Cities must be loaded before breweries."
);
await _mediator.Send(
new CreateBreweryCommand(
posterUserIds[i % posterUserIds.Count],
breweryRecord.NameEn,
breweryRecord.DescriptionEn,
new CreateBreweryLocation(
cityId,
address.AddressLine1,
null,
address.PostalCode,
new CoordinateData(address.Latitude, address.Longitude)
)
),
cancellationToken
);
}
}
private static Table BuildBreweryDisplayTable(IReadOnlyList<BreweryResult> breweries)
{
Table table = new Table()
.AddColumn("Brewery Name (EN)")
.AddColumn("Brewery Name (Local)")
.AddColumn("City")
.AddColumn("State/Province")
.AddColumn("Country")
.AddColumn("Longitude")
.AddColumn("Latitude");
foreach (BreweryResult brewery in breweries)
{
BreweryAddress address =
brewery.Address
?? throw new InvalidOperationException("Brewery address was not loaded.");
City city =
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
table.AddRow(
brewery.NameEn,
brewery.NameLocal,
city.CityName,
city.StateProvinceName,
city.CountryName,
address.Longitude.ToString("F6"),
address.Latitude.ToString("F6")
);
}
return table;
}
private static Table BuildUserDisplayTable(IReadOnlyList<UserRecord> users)
{
Table table = new Table()
.AddColumn("Username")
.AddColumn("First Name")
.AddColumn("Last Name")
.AddColumn("Email")
.AddColumn("Date of Birth");
foreach (UserRecord user in users)
{
table.AddRow(
user.User.Username,
user.User.FirstName,
user.User.LastName,
user.Email,
user.DateOfBirth
);
}
return table;
}
}

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="SkiaSharp" Version="4.151.1" /> <PackageReference Include="SkiaSharp" Version="4.151.1" />
@@ -24,9 +24,11 @@
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" /> <None
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,24 +0,0 @@
#region
using Database.Connection;
using Microsoft.Extensions.Configuration;
#endregion
namespace Database.Seed.DatabaseHelpers;
public static class ConnectionStrings
{
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
SqlConnectionStringHelper.TryGetStandardDbConnectionString(
configuration,
out string connectionString
)
? connectionString
: throw new InvalidOperationException(
"Database connection string not configured. Set DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars."
);
public static string SqliteConnectionString =>
"Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite";
}

View File

@@ -1,4 +1,4 @@
using Features.Auth.Services; using Features.Users.Services;
namespace Database.Seed.DatabaseHelpers; namespace Database.Seed.DatabaseHelpers;

View File

@@ -1,14 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryAddress
{
public required City City { get; init; }
public required double Longitude { get; init; }
public required double Latitude { get; init; }
public required string AddressLine1 { get; init; }
public required string PostalCode { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryRecord
{
public required BreweryAddress Address { get; init; }
public required BreweryResult Brewery { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record BreweryResult
{
public required string NameEn { get; init; }
public required string DescriptionEn { get; init; }
public required string NameLocal { get; init; }
public required string DescriptionLocal { get; init; }
}

View File

@@ -1,18 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record City
{
public required string CityName { get; init; }
public required string StateProvince { get; init; }
public required string Iso31662 { get; init; }
public required string Country { get; init; }
public required string Iso31661 { get; init; }
public IReadOnlyList<string> LocalLanguages { get; init; } = [];
public required PostalCodeSpec PostalCode { get; init; }
}

View File

@@ -1,10 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record PostalCodeSpec
{
public required string CountryFormatRegex { get; init; }
public IReadOnlyList<string> CityRegexes { get; init; } = [];
public IReadOnlyList<string> Examples { get; init; } = [];
}

View File

@@ -1,10 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record UserAddress
{
public required City City { get; init; }
public required double Longitude { get; init; }
public required double Latitude { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record UserRecord
{
public required UserAddress Address { get; init; }
public required UserResult User { get; init; }
public required string Email { get; init; }
public required string DateOfBirth { get; init; }
}

View File

@@ -1,16 +0,0 @@
namespace Database.Seed.PipelineData;
public sealed record UserResult
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required string Gender { get; init; }
public required string Username { get; init; }
public required string Bio { get; init; }
public required float ActivityWeight { get; init; }
}

View File

@@ -1,34 +1,25 @@
using Database.Connection.DependencyInjection; using Database.Connection.DependencyInjection;
using Database.Seed.DatabaseHelpers; using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData;
using Database.Seed.Sqlite; using Database.Seed.Sqlite;
using Domain.Entities;
using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Commands.Profile.UpdateBiography;
using Features.Auth.Commands.Profile.UploadAvatar;
using Features.Users.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Services;
using Features.Breweries.Commands.CreateBrewery; using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection; using Features.ImageUploads.DependencyInjection;
using Features.Locations.Commands.GetOrCreateCity;
using Features.Locations.DependencyInjection; using Features.Locations.DependencyInjection;
using Features.Locations.Dtos; using Features.Locations.Queries.GetCountry;
using idunno.Password; using Features.Users.Commands.Profile.UploadAvatar;
using Infrastructure.FileUpload; using Features.Users.DependencyInjection;
using Features.Users.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Spectre.Console; using Spectre.Console;
return await RunAsync(); namespace Database.Seed;
static async Task<int> RunAsync() internal class Program
{ {
try private static ServiceProvider BuildServiceProvider()
{ {
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
@@ -39,104 +30,42 @@ static async Task<int> RunAsync()
.AddFeaturesLocations() .AddFeaturesLocations()
.AddFeaturesUsers() .AddFeaturesUsers()
.AddFeaturesPhotoUpload() .AddFeaturesPhotoUpload()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<ITokenService, NoOpTokenService>() .AddScoped<ITokenService, NoOpTokenService>()
.AddMediatR(cfg => .AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>() cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>() .RegisterServicesFromAssemblyContaining<GetCountryQuery>()
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>() .RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>() .RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
); );
await using ServiceProvider provider = services.BuildServiceProvider(); return services.BuildServiceProvider();
}
public static async Task<int> Main()
{
using CancellationTokenSource cts = new();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
// ReSharper disable once AccessToDisposedClosure
cts.Cancel();
};
try
{
await using ServiceProvider provider = BuildServiceProvider();
IMediator mediator = provider.GetRequiredService<IMediator>(); IMediator mediator = provider.GetRequiredService<IMediator>();
SeedRepository reader = new SeedRepository(
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
AnsiConsole.WriteLine();
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
SeedData seedData = null!;
IReadOnlyList<Guid> userIds = [];
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading seed data...",
async ctx =>
{
seedData = await reader.ReadSeedDataAsync();
ctx.Status(
$"Loaded {seedData.Breweries.Count} breweries and {seedData.Users.Count} users."
); );
} await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
);
AnsiConsole.MarkupLine(
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
);
AnsiConsole.Write(BuildBreweryTable(seedData.Breweries));
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
AnsiConsole.Write(BuildUserTable(seedData.Users));
AnsiConsole.WriteLine();
AnsiConsole.WriteLine("Seed data loaded successfully.");
AnsiConsole.Write(
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading user data into target database...",
async ctx =>
{
userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users);
ctx.Status("User data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading avatars into target database...",
async ctx =>
{
await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx);
ctx.Status("Avatar data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading brewery data into target database...",
async ctx =>
{
await LoadBreweriesIntoDatabaseAsync(
mediator,
seedData.Breweries,
userIds,
ctx
);
ctx.Status("Brewery data loaded into target database.");
}
);
return 0; return 0;
} }
catch (OperationCanceledException)
{
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
return 1;
}
catch (Exception ex) catch (Exception ex)
{ {
AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
@@ -144,159 +73,4 @@ static async Task<int> RunAsync()
return 1; return 1;
} }
} }
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<UserRecord> users
)
{
List<Guid> userAccountIds = [];
foreach (UserRecord userRecord in users)
{
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9), which idunno.Password rejects outright.
string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true);
RegistrationPayload registration = await mediator.Send(
new RegisterUserCommand(
userRecord.User.Username,
userRecord.User.FirstName,
userRecord.User.LastName,
userRecord.Email,
DateTime.Parse(userRecord.DateOfBirth),
password
)
);
userAccountIds.Add(registration.UserAccountId);
}
return userAccountIds;
}
static async Task LoadAvatarsIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx
)
{
for (int i = 0; i < userIds.Count; i++)
{
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
Guid userId = userIds[i];
await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio));
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
};
await mediator.Send(new UploadAvatarCommand(userId, file));
}
}
static async Task LoadBreweriesIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<BreweryRecord> breweries,
IReadOnlyList<Guid> posterUserIds,
StatusContext ctx
)
{
if (posterUserIds.Count == 0)
throw new InvalidOperationException("Cannot load breweries without any registered users.");
for (int i = 0; i < breweries.Count; i++)
{
BreweryRecord breweryRecord = breweries[i];
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
Guid cityId = await mediator.Send(
new GetOrCreateCityCommand(
new CityLocation(
breweryRecord.Address.City.CityName,
breweryRecord.Address.City.StateProvince,
breweryRecord.Address.City.Iso31662,
breweryRecord.Address.City.Country,
breweryRecord.Address.City.Iso31661
)
)
);
await mediator.Send(
new CreateBreweryCommand(
posterUserIds[i % posterUserIds.Count],
breweryRecord.Brewery.NameEn,
breweryRecord.Brewery.DescriptionEn,
new CreateBreweryLocation(
cityId,
breweryRecord.Address.AddressLine1,
null,
breweryRecord.Address.PostalCode,
new CoordinateData(
breweryRecord.Address.Latitude,
breweryRecord.Address.Longitude
)
)
)
);
}
}
static Table BuildBreweryTable(IReadOnlyList<BreweryRecord> breweries)
{
Table table = new Table()
.AddColumn("Brewery Name (EN)")
.AddColumn("Brewery Name (Local)")
.AddColumn("City")
.AddColumn("State/Province")
.AddColumn("Country")
.AddColumn("Longitude")
.AddColumn("Latitude");
foreach (BreweryRecord brewery in breweries)
{
table.AddRow(
brewery.Brewery.NameEn,
brewery.Brewery.NameLocal,
brewery.Address.City.CityName,
brewery.Address.City.StateProvince,
brewery.Address.City.Country,
brewery.Address.Longitude.ToString("F6"),
brewery.Address.Latitude.ToString("F6")
);
}
return table;
}
static Table BuildUserTable(IReadOnlyList<UserRecord> users)
{
Table table = new Table()
.AddColumn("Username")
.AddColumn("First Name")
.AddColumn("Last Name")
.AddColumn("Email")
.AddColumn("Date of Birth");
foreach (UserRecord user in users)
{
table.AddRow(
user.User.Username,
user.User.FirstName,
user.User.LastName,
user.Email,
user.DateOfBirth
);
}
return table;
} }

View File

@@ -0,0 +1,104 @@
namespace Database.Seed.SourceDataModels;
public sealed record PostalCodeSpec(
string CountryFormatRegex,
IReadOnlyList<string> CityRegexes
);
public sealed class City
{
// Identity
public int Id { get; init; }
// Attributes
public string CityName { get; init; } = string.Empty;
// Resharper disable InconsistentNaming
public string ISO_3166_1 { get; init; } = string.Empty;
public string ISO_3166_2 { get; init; } = string.Empty;
public string CountryName { get; init; } = string.Empty;
public string StateProvinceName { get; init; } = string.Empty;
public double Longitude { get; init; }
public double Latitude { get; init; }
public IReadOnlyList<string> LocalLanguages { get; init; } = [];
public PostalCodeSpec? PostalCode { get; init; }
}
public sealed class BreweryAddress
{
// Identity
public int Id { get; init; }
// FK References
public int CityId { get; init; }
public int BreweryId { get; init; } // FK lives on this side in the DB (brewery_addresses.brewery_id)
// Attributes
public double Longitude { get; init; }
public double Latitude { get; init; }
public string AddressLine1 { get; init; } = string.Empty;
public string PostalCode { get; init; } = string.Empty;
// Navigation properties
public City? City { get; init; }
public BreweryResult? Brewery { get; init; }
}
public sealed class BreweryResult
{
// Identity
public int Id { get; init; }
// Attributes
public string NameEn { get; init; } = string.Empty;
public string DescriptionEn { get; init; } = string.Empty;
public string NameLocal { get; init; } = string.Empty;
public string DescriptionLocal { get; init; } = string.Empty;
// Navigation properties
public BreweryAddress? Address { get; init; }
}
public sealed class UserAddress
{
// Identity
public int Id { get; init; }
// FK References
public int CityId { get; init; }
public int UserId { get; init; }
// Attributes
public double Longitude { get; init; }
public double Latitude { get; init; }
// Navigation properties
public City? City { get; init; }
}
public sealed class UserResult
{
// Identity
public int Id { get; init; }
// Attributes
public string FirstName { get; init; } = string.Empty;
public string LastName { get; init; } = string.Empty;
public string Gender { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string Bio { get; init; } = string.Empty;
public float ActivityWeight { get; init; }
}
public sealed class UserRecord
{
// Attributes
public string Email { get; init; } = string.Empty;
public string DateOfBirth { get; init; } = string.Empty;
// Navigation properties
public UserAddress Address { get; init; } = new();
public UserResult User { get; init; } = new();
}

View File

@@ -1,215 +1,259 @@
using System.Text.Json; using System.Text.Json;
using Database.Seed.PipelineData;
using Dapper;
using Database.Seed.SourceDataModels;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
namespace Database.Seed.Sqlite; namespace Database.Seed.Sqlite;
public sealed record SeedData( public sealed record SeedData(
IReadOnlyList<BreweryRecord> Breweries, IReadOnlyList<City> Cities,
IReadOnlyList<BreweryResult> Breweries,
IReadOnlyList<UserRecord> Users IReadOnlyList<UserRecord> Users
); );
public sealed class PipelineSeedDataReader public sealed class SeedRepository : IDisposable
{ {
private readonly string _connectionString; private readonly SqliteConnection connection;
public SeedRepository(string connectionString)
{
connection = new(connectionString);
}
public PipelineSeedDataReader(string connectionString) public void Dispose()
{ {
_connectionString = connectionString; connection.Dispose();
} }
/// <summary> /// <summary>
/// Reads breweries and users in a single connection, loading the cities /// Reads breweries and users in a single connection, loading the cities
/// lookup only once and sharing it between both reads. /// lookup only once and sharing it between both reads.
/// </summary> /// </summary>
public async Task<SeedData> ReadSeedDataAsync(CancellationToken cancellationToken = default) public async Task<SeedData> ReadSeedDataAsync(
CancellationToken cancellationToken = default
)
{ {
await using SqliteConnection connection = new(_connectionString);
await connection.OpenAsync(cancellationToken); await connection.OpenAsync(cancellationToken);
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync( IReadOnlyDictionary<int, City> cities = await ReadCitiesAsync(
connection, connection,
cancellationToken cancellationToken
); );
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync( IReadOnlyList<BreweryResult> breweries = await ReadBreweryRecordsAsync(
connection, connection,
cities, cities,
cancellationToken cancellationToken
); );
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync( IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
connection, connection,
cities, cities,
cancellationToken cancellationToken
); );
return new SeedData(breweries, users); return new SeedData([.. cities.Values], breweries, users);
} }
private static async Task<IReadOnlyList<BreweryRecord>> ReadBreweryRecordsAsync( private sealed record BreweryRow(
long Id,
string NameEn,
string DescriptionEn,
string NameLocal,
string DescriptionLocal,
long AddressId,
long CityId,
double Longitude,
double Latitude,
string AddressLine1,
string PostalCode
);
private static async Task<IReadOnlyList<BreweryResult>> ReadBreweryRecordsAsync(
SqliteConnection connection, SqliteConnection connection,
IReadOnlyDictionary<long, City> cities, IReadOnlyDictionary<int, City> cities,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
const string sql = """ const string sql = """
SELECT b.name_en, b.description_en, b.name_local, b.description_local, SELECT
ba.city_id, ba.longitude, ba.latitude, ba.address_line1, ba.postal_code b.id AS Id,
FROM breweries b b.name_en AS NameEn,
JOIN brewery_addresses ba ON ba.brewery_id = b.id; b.description_en AS DescriptionEn,
b.name_local AS NameLocal,
b.description_local AS DescriptionLocal,
ba.id AS AddressId,
ba.city_id AS CityId,
ba.longitude AS Longitude,
ba.latitude AS Latitude,
ba.address_line1 AS AddressLine1,
ba.postal_code AS PostalCode
FROM
breweries b
INNER JOIN
brewery_addresses ba ON ba.brewery_id = b.id;
"""; """;
await using SqliteCommand command = new(sql, connection); CommandDefinition command = new(sql, cancellationToken: cancellationToken);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); IEnumerable<BreweryRow> rows = await connection.QueryAsync<BreweryRow>(command);
const int nameEnIndex = 0; return [.. rows.Select(row => new BreweryResult
const int descriptionEnIndex = 1;
const int nameLocalIndex = 2;
const int descriptionLocalIndex = 3;
const int cityIdIndex = 4;
const int longitudeIndex = 5;
const int latitudeIndex = 6;
const int addressLine1Index = 7;
const int postalCodeIndex = 8;
List<BreweryRecord> records = [];
while (await reader.ReadAsync(cancellationToken))
records.Add(
new BreweryRecord
{ {
Brewery = new BreweryResult Id = (int)row.Id,
{ NameEn = row.NameEn,
NameEn = reader.GetString(nameEnIndex), DescriptionEn = row.DescriptionEn,
DescriptionEn = reader.GetString(descriptionEnIndex), NameLocal = row.NameLocal,
NameLocal = reader.GetString(nameLocalIndex), DescriptionLocal = row.DescriptionLocal,
DescriptionLocal = reader.GetString(descriptionLocalIndex),
},
Address = new BreweryAddress Address = new BreweryAddress
{ {
City = cities[reader.GetInt64(cityIdIndex)], Id = (int)row.AddressId,
Longitude = reader.GetDouble(longitudeIndex), CityId = (int)row.CityId,
Latitude = reader.GetDouble(latitudeIndex), BreweryId = (int)row.Id,
AddressLine1 = reader.GetString(addressLine1Index), Longitude = row.Longitude,
PostalCode = reader.GetString(postalCodeIndex), Latitude = row.Latitude,
AddressLine1 = row.AddressLine1,
PostalCode = row.PostalCode,
City = cities[(int)row.CityId],
}, },
})];
} }
);
return records; private sealed record UserRow(
} long Id,
string FirstName,
string LastName,
string Gender,
string Username,
string Bio,
double ActivityWeight,
string Email,
string DateOfBirth,
long AddressId,
long CityId,
double Longitude,
double Latitude
);
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync( private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
SqliteConnection connection, SqliteConnection connection,
IReadOnlyDictionary<long, City> cities, IReadOnlyDictionary<int, City> cities,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
const string sql = """ const string sql = """
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio, SELECT
u.activity_weight, u.email, u.date_of_birth, u.id AS Id,
ua.city_id, ua.longitude, ua.latitude u.first_name AS FirstName,
FROM users u u.last_name AS LastName,
JOIN user_addresses ua ON ua.user_id = u.id; u.gender AS Gender,
u.username AS Username,
u.bio AS Bio,
u.activity_weight AS ActivityWeight,
u.email AS Email,
u.date_of_birth AS DateOfBirth,
ua.id AS AddressId,
ua.city_id AS CityId,
ua.longitude AS Longitude,
ua.latitude AS Latitude
FROM
users u
INNER JOIN
user_addresses ua ON ua.user_id = u.id;
"""; """;
await using SqliteCommand command = new(sql, connection); CommandDefinition command = new(sql, cancellationToken: cancellationToken);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); IEnumerable<UserRow> rows = await connection.QueryAsync<UserRow>(command);
const int firstNameIndex = 0; return [.. rows.Select(row => new UserRecord
const int lastNameIndex = 1;
const int genderIndex = 2;
const int usernameIndex = 3;
const int bioIndex = 4;
const int activityWeightIndex = 5;
const int emailIndex = 6;
const int dateOfBirthIndex = 7;
const int cityIdIndex = 8;
const int longitudeIndex = 9;
const int latitudeIndex = 10;
List<UserRecord> records = [];
while (await reader.ReadAsync(cancellationToken))
records.Add(
new UserRecord
{ {
User = new UserResult Email = row.Email,
{ DateOfBirth = row.DateOfBirth,
FirstName = reader.GetString(firstNameIndex),
LastName = reader.GetString(lastNameIndex),
Gender = reader.GetString(genderIndex),
Username = reader.GetString(usernameIndex),
Bio = reader.GetString(bioIndex),
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
},
Email = reader.GetString(emailIndex),
DateOfBirth = reader.GetString(dateOfBirthIndex),
Address = new UserAddress Address = new UserAddress
{ {
City = cities[reader.GetInt64(cityIdIndex)], Id = (int)row.AddressId,
Longitude = reader.GetDouble(longitudeIndex), CityId = (int)row.CityId,
Latitude = reader.GetDouble(latitudeIndex), UserId = (int)row.Id,
Longitude = row.Longitude,
Latitude = row.Latitude,
City = cities[(int)row.CityId],
}, },
User = new UserResult
{
Id = (int)row.Id,
FirstName = row.FirstName,
LastName = row.LastName,
Gender = row.Gender,
Username = row.Username,
Bio = row.Bio,
ActivityWeight = (float)row.ActivityWeight,
},
})];
} }
private sealed record CityRow(
long Id,
string CityName,
string StateProvince,
string Iso31662,
string Country,
string Iso31661,
double Longitude,
double Latitude,
string LocalLanguagesJson,
string PostalCodeCountryFormatRegex,
string PostalCodeCityRegexesJson
); );
return records; private static async Task<IReadOnlyDictionary<int, City>> ReadCitiesAsync(
}
private static async Task<IReadOnlyDictionary<long, City>> ReadCitiesAsync(
SqliteConnection connection, SqliteConnection connection,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
const string sql = """ const string sql = """
SELECT SELECT
id, id AS Id,
city, city AS CityName,
state_province, state_province AS StateProvince,
iso3166_2, iso3166_2 AS Iso31662,
country, country AS Country,
iso3166_1, iso3166_1 AS Iso31661,
local_languages_json, longitude AS Longitude,
postal_code_country_format_regex, latitude AS Latitude,
postal_code_city_regex_json local_languages_json AS LocalLanguagesJson,
postal_code_country_format_regex AS PostalCodeCountryFormatRegex,
postal_code_city_regex_json AS PostalCodeCityRegexesJson
FROM cities; FROM cities;
"""; """;
await using SqliteCommand command = new(sql, connection); CommandDefinition command = new(sql, cancellationToken: cancellationToken);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken); IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
Dictionary<long, City> cities = []; return rows.ToDictionary(
row => (int)row.Id,
const int idIndex = 0; row => new City
const int cityNameIndex = 1;
const int stateProvinceIndex = 2;
const int iso31662Index = 3;
const int countryIndex = 4;
const int iso31661Index = 5;
const int localLanguagesIndex = 6;
const int countryFormatRegexIndex = 7;
const int cityRegexesIndex = 8;
while (await reader.ReadAsync(cancellationToken))
cities[reader.GetInt64(idIndex)] = new City
{ {
CityName = reader.GetString(cityNameIndex), Id = (int)row.Id,
StateProvince = reader.GetString(stateProvinceIndex), CityName = row.CityName,
Iso31662 = reader.GetString(iso31662Index), StateProvinceName = row.StateProvince,
Country = reader.GetString(countryIndex), ISO_3166_2 = row.Iso31662,
Iso31661 = reader.GetString(iso31661Index), CountryName = row.Country,
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)), ISO_3166_1 = row.Iso31661,
PostalCode = new PostalCodeSpec Longitude = row.Longitude,
{ Latitude = row.Latitude,
CountryFormatRegex = reader.GetString(countryFormatRegexIndex), LocalLanguages = DeserializeStringArray(row.LocalLanguagesJson),
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)), PostalCode = new PostalCodeSpec(
}, CountryFormatRegex: row.PostalCodeCountryFormatRegex,
}; CityRegexes: DeserializeStringArray(row.PostalCodeCityRegexesJson)
),
return cities; }
);
} }
private static IReadOnlyList<string> DeserializeStringArray(string json) private static IReadOnlyList<string> DeserializeStringArray(string json)
{ {
return JsonSerializer.Deserialize<List<string>>(json) ?? []; return JsonSerializer.Deserialize<List<string>>(json) ?? [];
} }
} }

View File

@@ -5,11 +5,11 @@ COPY . .
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is # Runs every Features.*.Tests and Shared.*.Tests project (unit + DbMocker-backed repository
# excluded here since it's a separate BDD/integration suite, run by its own compose service # tests). API.Specs is excluded here since it's a separate BDD/integration suite, run by its own
# against the live, seeded database. # compose service against the live, seeded database.
FROM build AS final FROM build AS final
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
RUN mkdir -p /app/test-results RUN mkdir -p /app/test-results
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"] ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests Shared/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Domain.Entities; namespace Domain.Entities;
public sealed class UserAccount public sealed class UserAccount
@@ -19,4 +21,11 @@ public sealed class UserAccount
// Navigation properties // Navigation properties
public UserCredential? UserCredential { get; set; } public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
// storage key and uploader of the underlying Photo. Clients read the avatar from
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
[JsonIgnore]
public UserAvatar? UserAvatar { get; set; }
} }

View File

@@ -17,10 +17,8 @@ public class CreateBreweryHandlerTests
_handler = new CreateBreweryHandler(_repoMock.Object); _handler = new CreateBreweryHandler(_repoMock.Object);
} }
private static CreateBreweryLocation ValidLocation() private static CreateBreweryLocation ValidLocation() =>
{ new(Guid.NewGuid(), "123 Main St", null, "12345", null);
return new CreateBreweryLocation(Guid.NewGuid(), "123 Main St", null, "12345", null);
}
[Fact] [Fact]
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt() public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()

View File

@@ -0,0 +1,171 @@
using Features.Breweries.Commands.CreateBrewery;
using FluentValidation.TestHelper;
namespace Features.Breweries.Tests.Commands;
public class CreateBreweryValidatorTests
{
private readonly CreateBreweryValidator _validator = new();
private static CreateBreweryLocation ValidLocation() =>
new(Guid.NewGuid(), "123 Main St", null, "12345", null);
private static CreateBreweryCommand ValidCommand() =>
new(Guid.NewGuid(), "Valid Brewery", "A valid description.", ValidLocation());
[Fact]
public void Validate_ValidCommand_HasNoValidationErrors()
{
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(ValidCommand());
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_EmptyPostedById_HasErrorForPostedById()
{
CreateBreweryCommand command = ValidCommand() with { PostedById = Guid.Empty };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.PostedById)
.WithErrorMessage("PostedById is required.");
}
[Fact]
public void Validate_EmptyBreweryName_HasErrorForBreweryName()
{
CreateBreweryCommand command = ValidCommand() with { BreweryName = "" };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.BreweryName)
.WithErrorMessage("Brewery name is required.");
}
[Fact]
public void Validate_BreweryNameTooLong_HasErrorForBreweryName()
{
CreateBreweryCommand command = ValidCommand() with { BreweryName = new string('a', 257) };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.BreweryName)
.WithErrorMessage("Brewery name cannot exceed 256 characters.");
}
[Fact]
public void Validate_EmptyDescription_HasErrorForDescription()
{
CreateBreweryCommand command = ValidCommand() with { Description = "" };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Description)
.WithErrorMessage("Description is required.");
}
[Fact]
public void Validate_DescriptionTooLong_HasErrorForDescription()
{
CreateBreweryCommand command = ValidCommand() with { Description = new string('a', 513) };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Description)
.WithErrorMessage("Description cannot exceed 512 characters.");
}
[Fact]
public void Validate_NullLocation_HasErrorForLocationOnly()
{
CreateBreweryCommand command = ValidCommand() with { Location = null! };
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Location).WithErrorMessage("Location is required.");
result.ShouldNotHaveValidationErrorFor("Location.CityId");
result.ShouldNotHaveValidationErrorFor("Location.AddressLine1");
result.ShouldNotHaveValidationErrorFor("Location.PostalCode");
}
[Fact]
public void Validate_EmptyCityId_HasErrorForCityId()
{
CreateBreweryCommand command = ValidCommand() with
{
Location = ValidLocation() with { CityId = Guid.Empty }
};
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Location.CityId)
.WithErrorMessage("CityId is required.");
}
[Fact]
public void Validate_EmptyAddressLine1_HasErrorForAddressLine1()
{
CreateBreweryCommand command = ValidCommand() with
{
Location = ValidLocation() with { AddressLine1 = "" }
};
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Location.AddressLine1)
.WithErrorMessage("Address line 1 is required.");
}
[Fact]
public void Validate_AddressLine1TooLong_HasErrorForAddressLine1()
{
CreateBreweryCommand command = ValidCommand() with
{
Location = ValidLocation() with { AddressLine1 = new string('a', 257) }
};
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Location.AddressLine1)
.WithErrorMessage("Address line 1 cannot exceed 256 characters.");
}
[Fact]
public void Validate_EmptyPostalCode_HasErrorForPostalCode()
{
CreateBreweryCommand command = ValidCommand() with
{
Location = ValidLocation() with { PostalCode = "" }
};
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Location.PostalCode)
.WithErrorMessage("Postal code is required.");
}
[Fact]
public void Validate_PostalCodeTooLong_HasErrorForPostalCode()
{
CreateBreweryCommand command = ValidCommand() with
{
Location = ValidLocation() with { PostalCode = new string('1', 21) }
};
TestValidationResult<CreateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.Location.PostalCode)
.WithErrorMessage("Postal code cannot exceed 20 characters.");
}
}

View File

@@ -0,0 +1,32 @@
using Features.Breweries.Commands.UpdateBrewery;
using FluentValidation.TestHelper;
namespace Features.Breweries.Tests.Commands;
public class UpdateBreweryValidatorTests
{
private readonly UpdateBreweryValidator _validator = new();
private static UpdateBreweryCommand ValidCommand() =>
new(Guid.NewGuid(), Guid.NewGuid(), [0x01, 0x02], "Name", "Description", null);
[Fact]
public void Validate_ValidCommand_HasNoValidationErrors()
{
TestValidationResult<UpdateBreweryCommand> result = _validator.TestValidate(ValidCommand());
result.ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_EmptyRowVersion_HasErrorForRowVersion()
{
UpdateBreweryCommand command = ValidCommand() with { RowVersion = [] };
TestValidationResult<UpdateBreweryCommand> result = _validator.TestValidate(command);
result
.ShouldHaveValidationErrorFor(x => x.RowVersion)
.WithErrorMessage("RowVersion is required to detect conflicting concurrent updates.");
}
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="Moq" Version="4.20.72"/> <PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="FluentAssertions" Version="7.2.0"/> <PackageReference Include="FluentAssertions" Version="7.2.0"/>
<PackageReference Include="FluentValidation" Version="11.3.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -36,8 +36,10 @@ public class GetAllBreweryLocationsHandlerTests
{ {
BreweryPost[] breweries = BreweryPost[] breweries =
[ [
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, new()
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
new()
{ BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
]; ];
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries); _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);

View File

@@ -50,8 +50,10 @@ public class GetBreweryLocationsWithinRangeHandlerTests
{ {
BreweryPost[] breweries = BreweryPost[] breweries =
[ [
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" }, new()
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" }, { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" },
new()
{ BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" },
]; ];
_repoMock _repoMock
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>())) .Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))

View File

@@ -0,0 +1,109 @@
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
using FluentValidation.TestHelper;
namespace Features.Breweries.Tests.Queries;
public class GetBreweryLocationsWithinRangeValidatorTests
{
private readonly GetBreweryLocationsWithinRangeValidator _validator = new();
private static GetBreweryLocationsWithinRangeQuery ValidQuery() => new(45, -73, 1000);
[Fact]
public void Validate_ValidQuery_HasNoValidationErrors()
{
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
ValidQuery()
);
result.ShouldNotHaveAnyValidationErrors();
}
[Theory]
[InlineData(-90)]
[InlineData(90)]
public void Validate_LatitudeAtBoundary_HasNoValidationErrorForLatitude(double latitude)
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Latitude = latitude };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result.ShouldNotHaveValidationErrorFor(x => x.Latitude);
}
[Theory]
[InlineData(90.0001)]
[InlineData(-90.0001)]
public void Validate_LatitudeOutOfRange_HasErrorForLatitude(double latitude)
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Latitude = latitude };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result
.ShouldHaveValidationErrorFor(x => x.Latitude)
.WithErrorMessage("Latitude must be between -90 and 90 degrees.");
}
[Theory]
[InlineData(-180)]
[InlineData(180)]
public void Validate_LongitudeAtBoundary_HasNoValidationErrorForLongitude(double longitude)
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Longitude = longitude };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result.ShouldNotHaveValidationErrorFor(x => x.Longitude);
}
[Theory]
[InlineData(180.0001)]
[InlineData(-180.0001)]
public void Validate_LongitudeOutOfRange_HasErrorForLongitude(double longitude)
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { Longitude = longitude };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result
.ShouldHaveValidationErrorFor(x => x.Longitude)
.WithErrorMessage("Longitude must be between -180 and 180 degrees.");
}
[Fact]
public void Validate_RangeInMetresIsZero_HasErrorForRangeInMetres()
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { RangeInMetres = 0 };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result
.ShouldHaveValidationErrorFor(x => x.RangeInMetres)
.WithErrorMessage("RangeInMetres must be greater than zero.");
}
[Fact]
public void Validate_RangeInMetresIsNegative_HasErrorForRangeInMetres()
{
GetBreweryLocationsWithinRangeQuery query = ValidQuery() with { RangeInMetres = -1 };
TestValidationResult<GetBreweryLocationsWithinRangeQuery> result = _validator.TestValidate(
query
);
result
.ShouldHaveValidationErrorFor(x => x.RangeInMetres)
.WithErrorMessage("RangeInMetres must be greater than zero.");
}
}

View File

@@ -4,8 +4,12 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Location details for a brewery being created.</summary> /// <summary>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param> /// Describes the location supplied when creating a brewery.
/// </summary>
/// <param name="Coordinates">
/// Serialized geographic coordinates for the brewery.
/// </param>
public record CreateBreweryLocation( public record CreateBreweryLocation(
Guid CityId, Guid CityId,
string AddressLine1, string AddressLine1,
@@ -15,7 +19,7 @@ public record CreateBreweryLocation(
); );
/// <summary> /// <summary>
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>. /// Represents the application request used to create a brewery post.
/// </summary> /// </summary>
public record CreateBreweryCommand( public record CreateBreweryCommand(
Guid PostedById, Guid PostedById,
@@ -24,7 +28,9 @@ public record CreateBreweryCommand(
CreateBreweryLocation Location CreateBreweryLocation Location
) : IRequest<BreweryDto>; ) : IRequest<BreweryDto>;
/// <summary>Request body for <c>POST /api/brewery</c>. <c>PostedById</c> comes from the caller's JWT.</summary> /// <summary>
/// Defines the client-supplied body for creating a brewery post.
/// </summary>
public record CreateBreweryRequest( public record CreateBreweryRequest(
string BreweryName, string BreweryName,
string Description, string Description,

View File

@@ -5,13 +5,17 @@ using MediatR;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</summary> /// <summary>
/// Persists brewery posts created through <see cref="CreateBreweryCommand" />.
/// </summary>
public class CreateBreweryHandler(IBreweryRepository repository) public class CreateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<CreateBreweryCommand, BreweryDto> : IRequestHandler<CreateBreweryCommand, BreweryDto>
{ {
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary> /// <summary>
/// Creates the post and its associated location with new identifiers.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="request" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist. /// Thrown if the posting user or the selected city cannot be found.
/// </exception> /// </exception>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
CreateBreweryCommand request, CreateBreweryCommand request,
@@ -38,10 +42,6 @@ public class CreateBreweryHandler(IBreweryRepository repository)
await repository.CreateAsync(entity); await repository.CreateAsync(entity);
// RowVersion is a DB-computed column, unset on the in-memory entity; re-fetch so callers
// get back the value needed for a subsequent optimistic-concurrency update. Uses
// CancellationToken.None since the write already committed: cancelling the caller's request
// at this point must not turn a successful creation into a thrown exception.
BreweryPost created = BreweryPost created =
await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None) await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Commands.CreateBrewery; namespace Features.Breweries.Commands.CreateBrewery;
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary> /// <summary>
/// Applies creation rules to <see cref="CreateBreweryCommand" />.
/// </summary>
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand> public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
{ {
public CreateBreweryValidator() public CreateBreweryValidator()
@@ -23,19 +25,23 @@ public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
RuleFor(x => x.Location).NotNull().WithMessage("Location is required."); RuleFor(x => x.Location).NotNull().WithMessage("Location is required.");
RuleFor(x => x.Location.CityId).NotEmpty().WithMessage("CityId is required."); RuleFor(x => x.Location.CityId)
.NotEmpty()
.WithMessage("CityId is required.")
.When(x => x.Location != null);
RuleFor(x => x.Location.AddressLine1) RuleFor(x => x.Location.AddressLine1)
.NotEmpty() .NotEmpty()
.When(x => x.Location is not null)
.WithMessage("Address line 1 is required.") .WithMessage("Address line 1 is required.")
.MaximumLength(256) .MaximumLength(256)
.WithMessage("Address line 1 cannot exceed 256 characters."); .WithMessage("Address line 1 cannot exceed 256 characters.")
.When(x => x.Location != null);
RuleFor(x => x.Location.PostalCode) RuleFor(x => x.Location.PostalCode)
.NotEmpty() .NotEmpty()
.WithMessage("Postal code is required.") .WithMessage("Postal code is required.")
.MaximumLength(20) .MaximumLength(20)
.WithMessage("Postal code cannot exceed 20 characters."); .WithMessage("Postal code cannot exceed 20 characters.")
.When(x => x.Location != null);
} }
} }

View File

@@ -2,5 +2,7 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Deletes a brewery post. <see cref="BreweryPostId" /> is bound from the route of <c>DELETE /api/brewery/{id}</c>.</summary> /// <summary>
/// Represents a request to delete a brewery post on behalf of a caller.
/// </summary>
public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;

View File

@@ -3,15 +3,20 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary> /// <summary>
/// Processes <see cref="DeleteBreweryCommand" /> requests.
/// </summary>
public class DeleteBreweryHandler(IBreweryRepository repository) public class DeleteBreweryHandler(IBreweryRepository repository)
: IRequestHandler<DeleteBreweryCommand> : IRequestHandler<DeleteBreweryCommand>
{ {
/// <summary>
/// Confirms that the caller owns the post before deleting it.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>. /// Thrown if the brewery post does not exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ForbiddenException"> /// <exception cref="Domain.Exceptions.ForbiddenException">
/// Thrown when <paramref name="request" />'s <c>RequestingUserId</c> did not post the brewery. /// Thrown if the caller did not create the brewery post.
/// </exception> /// </exception>
public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{ {

View File

@@ -4,11 +4,15 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Location details for a brewery being updated.</summary> /// <summary>
/// Describes the location supplied when updating a brewery.
/// </summary>
/// <param name="BreweryPostLocationId"> /// <param name="BreweryPostLocationId">
/// Identifier of the existing location row, if any; a new row is created if none exists yet. /// Identifies the existing location; a new location is created when needed.
/// </param>
/// <param name="Coordinates">
/// Serialized geographic coordinates for the brewery.
/// </param> /// </param>
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
public record UpdateBreweryLocation( public record UpdateBreweryLocation(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,
@@ -19,9 +23,8 @@ public record UpdateBreweryLocation(
); );
/// <summary> /// <summary>
/// Updates an existing brewery post. A <c>null</c> <see cref="Location" /> clears the brewery's location. /// Represents a concurrency-protected change to an existing brewery post.
/// <see cref="RowVersion" /> must be the row-version last read for this brewery (e.g. from a prior /// A <see langword="null" /> <see cref="Location" /> removes the existing location.
/// <c>GET</c>); the update is rejected with a <c>409 Conflict</c> if the brewery was modified since then.
/// </summary> /// </summary>
public record UpdateBreweryCommand( public record UpdateBreweryCommand(
Guid BreweryPostId, Guid BreweryPostId,
@@ -32,7 +35,9 @@ public record UpdateBreweryCommand(
UpdateBreweryLocation? Location UpdateBreweryLocation? Location
) : IRequest<BreweryDto>; ) : IRequest<BreweryDto>;
/// <summary>Request body for <c>PUT /api/brewery/{id}</c>.</summary> /// <summary>
/// Defines the client-supplied body for updating a brewery post.
/// </summary>
public record UpdateBreweryRequest( public record UpdateBreweryRequest(
Guid BreweryPostId, Guid BreweryPostId,
byte[] RowVersion, byte[] RowVersion,
@@ -40,4 +45,3 @@ public record UpdateBreweryRequest(
string Description, string Description,
UpdateBreweryLocation? Location UpdateBreweryLocation? Location
); );

View File

@@ -5,21 +5,20 @@ using MediatR;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.</summary> /// <summary>
/// Persists changes requested through <see cref="UpdateBreweryCommand" />.
/// </summary>
public class UpdateBreweryHandler(IBreweryRepository repository) public class UpdateBreweryHandler(IBreweryRepository repository)
: IRequestHandler<UpdateBreweryCommand, BreweryDto> : IRequestHandler<UpdateBreweryCommand, BreweryDto>
{ {
/// <summary> /// <summary>
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>, /// Updates the post after confirming that the caller owns it.
/// the brewery's location is cleared.
/// </summary> /// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>, or its /// Thrown if the brewery post or its requested city cannot be found.
/// location's <c>CityId</c> does not exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ConflictException"> /// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when the brewery was modified by another request since <paramref name="request" />. /// Thrown if the post has changed since its supplied row version was read.
/// <c>RowVersion</c> was read.
/// </exception> /// </exception>
public async Task<BreweryDto> Handle( public async Task<BreweryDto> Handle(
UpdateBreweryCommand request, UpdateBreweryCommand request,

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Commands.UpdateBrewery; namespace Features.Breweries.Commands.UpdateBrewery;
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary> /// <summary>
/// Applies update rules to <see cref="UpdateBreweryCommand" />.
/// </summary>
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand> public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
{ {
public UpdateBreweryValidator() public UpdateBreweryValidator()

View File

@@ -13,19 +13,25 @@ using Shared.Contracts;
namespace Features.Breweries.Controllers; namespace Features.Breweries.Controllers;
/// <summary>Exposes CRUD operations for brewery posts.</summary> /// <summary>
/// Provides HTTP endpoints for reading and managing brewery posts.
/// </summary>
/// <remarks> /// <remarks>
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via /// Requests require JWT authentication unless the endpoint allows anonymous access.
/// <c>[AllowAnonymous]</c>.
/// </remarks> /// </remarks>
///
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IMediator mediator) : ControllerBase public class BreweryController(IMediator mediator) : ControllerBase
{ {
/// <summary>Retrieves a single brewery post by ID.</summary> /// <summary>
/// <remarks>Allows anonymous access.</remarks> /// Returns the brewery post identified by <paramref name="id" />.
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns> /// </summary>
/// <remarks>This endpoint permits anonymous access.</remarks>
/// <returns>
/// A successful response containing the post, or <c>404 Not Found</c> when it is absent.
/// </returns>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id) public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
@@ -43,10 +49,16 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves brewery posts, newest first.</summary> /// <summary>
/// <param name="limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param> /// Returns brewery posts in reverse chronological order.
/// <param name="offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param> /// </summary>
/// <remarks>Allows anonymous access.</remarks> /// <param name="limit">
/// Limits the number of returned posts; <see langword="null" /> leaves it unlimited.
/// </param>
/// <param name="offset">
/// Skips this many posts; <see langword="null" /> means none.
/// </param>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet] [HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
@@ -66,11 +78,15 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves all brewery posts that have a set location.</summary> /// <summary>
/// <remarks>Allows anonymous access.</remarks> /// Returns all brewery posts with an associated location.
/// </summary>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations")] [HttpGet("locations")]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetAllLocations() public async Task<
ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>
> GetAllLocations()
{ {
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send( IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
new GetAllBreweryLocationsQuery() new GetAllBreweryLocationsQuery()
@@ -84,14 +100,24 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Retrieves brewery posts with a set location within range of the given coordinates, nearest first.</summary> /// <summary>
/// <param name="latitude">The origin latitude, in decimal degrees.</param> /// Returns located brewery posts within the requested radius, ordered by proximity.
/// <param name="longitude">The origin longitude, in decimal degrees.</param> /// </summary>
/// <param name="rangeInMetres">The maximum distance, in metres, from the origin coordinates.</param> /// <param name="latitude">
/// <remarks>Allows anonymous access.</remarks> /// Identifies the origin latitude in decimal degrees.
/// </param>
/// <param name="longitude">
/// Identifies the origin longitude in decimal degrees.
/// </param>
/// <param name="rangeInMetres">
/// Sets the maximum distance from the origin in metres.
/// </param>
/// <remarks>This endpoint permits anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations/nearby")] [HttpGet("locations/nearby")]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetLocationsWithinRange( public async Task<
ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>
> GetLocationsWithinRange(
[FromQuery] double latitude, [FromQuery] double latitude,
[FromQuery] double longitude, [FromQuery] double longitude,
[FromQuery] double rangeInMetres [FromQuery] double rangeInMetres
@@ -109,8 +135,10 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Creates a new brewery post.</summary> /// <summary>
/// <returns><c>201 Created</c> with the newly created brewery.</returns> /// Creates a brewery post for the authenticated user.
/// </summary>
/// <returns>A <c>201 Created</c> response containing the new post.</returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create( public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryRequest request [FromBody] CreateBreweryRequest request
@@ -134,12 +162,15 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Updates an existing brewery post.</summary> /// <summary>
/// <param name="id">Must match <paramref name="request" />'s <c>BreweryPostId</c>.</param> /// Updates a brewery post owned by the authenticated user.
/// </summary>
/// <param name="id">
/// Must equal the brewery post identifier in <paramref name="request" />.
/// </param>
/// <returns> /// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the /// A successful response containing the updated post, or an error for an invalid ID,
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or /// missing resource, or stale row version.
/// <c>409 Conflict</c> if the brewery was modified since <c>request.RowVersion</c> was read.
/// </returns> /// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update( public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
@@ -170,8 +201,12 @@ public class BreweryController(IMediator mediator) : ControllerBase
); );
} }
/// <summary>Deletes a brewery post.</summary> /// <summary>
/// <returns><c>200 OK</c> confirming the deletion; <c>404 Not Found</c> if no brewery exists with <paramref name="id" />.</returns> /// Deletes a brewery post owned by the authenticated user.
/// </summary>
/// <returns>
/// A confirmation response, or <c>404 Not Found</c> if the post is absent.
/// </returns>
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id) public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{ {

View File

@@ -3,11 +3,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Breweries.DependencyInjection; namespace Features.Breweries.DependencyInjection;
/// <summary>Registers the services required by the Breweries feature.</summary> /// <summary>
/// Adds Breweries feature services to the dependency-injection container.
/// </summary>
public static class FeaturesBreweriesServiceCollectionExtensions public static class FeaturesBreweriesServiceCollectionExtensions
{ {
/// <summary>Registers <see cref="IBreweryRepository" /> and its implementation.</summary> /// <summary>
/// <returns><paramref name="services" />, for chaining.</returns> /// Registers the repository services used by the Breweries feature.
/// </summary>
/// <returns>The supplied service collection.</returns>
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services) public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
{ {
services.AddScoped<IBreweryRepository, BreweryRepository>(); services.AddScoped<IBreweryRepository, BreweryRepository>();

View File

@@ -2,8 +2,12 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>Location details of a brewery, as returned by the API.</summary> /// <summary>
/// <param name="Coordinates">The brewery's geographic coordinates, in a raw binary representation.</param> /// Represents location data returned for a brewery post.
/// </summary>
/// <param name="Coordinates">
/// Contains the serialized geographic coordinates, when available.
/// </param>
public record BreweryLocationDto( public record BreweryLocationDto(
Guid BreweryPostLocationId, Guid BreweryPostLocationId,
Guid CityId, Guid CityId,
@@ -18,10 +22,18 @@ public record BreweryLocationDto(
CoordinateData? Coordinates CoordinateData? Coordinates
); );
/// <summary>A brewery post, as returned by the API.</summary> /// <summary>
/// <param name="UpdatedAt">The date and time of the last edit, or <see langword="null"/> if never edited.</param> /// Represents a brewery post returned by the API.
/// <param name="RowVersion">The row-version/concurrency token used to detect conflicting concurrent updates.</param> /// </summary>
/// <param name="Location">The brewery's location, or <see langword="null"/> if none has been set.</param> /// <param name="UpdatedAt">
/// Records the most recent update time, if the post has been updated.
/// </param>
/// <param name="RowVersion">
/// Provides the concurrency token required for updates.
/// </param>
/// <param name="Location">
/// Contains the post's location, when one is available.
/// </param>
public record BreweryDto( public record BreweryDto(
Guid BreweryPostId, Guid BreweryPostId,
Guid PostedById, Guid PostedById,
@@ -32,9 +44,12 @@ public record BreweryDto(
byte[]? RowVersion, byte[]? RowVersion,
BreweryLocationDto? Location BreweryLocationDto? Location
); );
/// <summary>
/// Represents the location-focused result returned by brewery searches.
/// </summary>
/// <param name="DistanceMetres"> /// <param name="DistanceMetres">
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no /// Contains the distance from the search origin in metres, when applicable.
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
/// </param> /// </param>
public record BreweryWithLocationDto( public record BreweryWithLocationDto(
Guid BreweryPostId, Guid BreweryPostId,

View File

@@ -2,74 +2,62 @@ using Domain.Entities;
namespace Features.Breweries.Dtos; namespace Features.Breweries.Dtos;
/// <summary>Maps <see cref="BreweryPost" /> domain entities to <see cref="BreweryDto" />.</summary> /// <summary>
/// Converts brewery domain entities into API response models.
/// </summary>
public static class BreweryDtoMapper public static class BreweryDtoMapper
{ {
/// <summary> /// <summary>
/// Converts a <see cref="BreweryPost" /> to its DTO representation, including its /// Converts a brewery post into its complete API representation.
/// <see cref="BreweryDto.Location" /> if the entity has one.
/// </summary> /// </summary>
public static BreweryDto ToDto(this BreweryPost brewery) public static BreweryDto ToDto(this BreweryPost brewery) =>
{ new(
return new BreweryDto( brewery.BreweryPostId,
BreweryPostId: brewery.BreweryPostId, brewery.PostedById,
PostedById: brewery.PostedById, brewery.BreweryName,
BreweryName: brewery.BreweryName, brewery.Description,
Description: brewery.Description, brewery.CreatedAt,
CreatedAt: brewery.CreatedAt, brewery.UpdatedAt,
UpdatedAt: brewery.UpdatedAt, brewery.RowVersion,
RowVersion: brewery.RowVersion, brewery.Location is null
Location: brewery.Location is null
? null ? null
: new BreweryLocationDto( : new BreweryLocationDto(
BreweryPostLocationId: brewery.Location.BreweryPostLocationId, brewery.Location.BreweryPostLocationId,
CityId: brewery.Location.CityId, brewery.Location.CityId,
CityName: brewery.Location.City?.CityName ?? string.Empty, brewery.Location.City?.CityName ?? string.Empty,
StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty,
StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty,
CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName brewery.Location.AddressLine1,
?? string.Empty, brewery.Location.AddressLine2,
CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 brewery.Location.PostalCode,
?? string.Empty, brewery.Location.Coordinates
AddressLine1: brewery.Location.AddressLine1,
AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates
) )
); );
}
/// <summary> /// <summary>
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for /// Converts a brewery post into its location-search representation.
/// location-based queries that only need identifying and location details.
/// </summary> /// </summary>
public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery) =>
{ new(
return new BreweryWithLocationDto( brewery.BreweryPostId,
BreweryPostId: brewery.BreweryPostId, brewery.BreweryName,
BreweryName: brewery.BreweryName, brewery.Location is null
Location: brewery.Location is null
? null ? null
: new BreweryLocationDto( : new BreweryLocationDto(
BreweryPostLocationId: brewery.Location.BreweryPostLocationId, brewery.Location.BreweryPostLocationId,
CityId: brewery.Location.CityId, brewery.Location.CityId,
CityName: brewery.Location.City?.CityName ?? string.Empty, brewery.Location.City?.CityName ?? string.Empty,
StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName brewery.Location.City?.StateProvince?.StateProvinceName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Iso31662 ?? string.Empty,
StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 brewery.Location.City?.StateProvince?.Country?.CountryName ?? string.Empty,
?? string.Empty, brewery.Location.City?.StateProvince?.Country?.Iso31661 ?? string.Empty,
CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName brewery.Location.AddressLine1,
?? string.Empty, brewery.Location.AddressLine2,
CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 brewery.Location.PostalCode,
?? string.Empty, brewery.Location.Coordinates
AddressLine1: brewery.Location.AddressLine1,
AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates
), ),
DistanceMetres: brewery.Distance?.DistanceMetres brewery.Distance?.DistanceMetres
); );
} }
}

View File

@@ -5,11 +5,15 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Handles <see cref="GetAllBreweriesQuery" /> by retrieving brewery posts, newest first.</summary> /// <summary>
/// Handles ordered, paged brewery-post queries.
/// </summary>
public class GetAllBreweriesHandler(IBreweryRepository repository) public class GetAllBreweriesHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>> : IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
{ {
/// <summary>Retrieves brewery posts, newest first, applying <see cref="GetAllBreweriesQuery" />'s paging.</summary> /// <summary>
/// Retrieves and maps the requested page of brewery posts.
/// </summary>
public async Task<IEnumerable<BreweryDto>> Handle( public async Task<IEnumerable<BreweryDto>> Handle(
GetAllBreweriesQuery request, GetAllBreweriesQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -3,7 +3,13 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweries; namespace Features.Breweries.Queries.GetAllBreweries;
/// <summary>Retrieves brewery posts, newest first. Bound directly from the query string of <c>GET /api/brewery</c>.</summary> /// <summary>
/// <param name="Limit">Maximum number of breweries to return. Unbounded if <see langword="null"/>.</param> /// Requests brewery posts ordered from newest to oldest.
/// <param name="Offset">Number of breweries to skip. Treated as zero if <see langword="null"/>.</param> /// </summary>
/// <param name="Limit">
/// Caps the result count; <see langword="null" /> disables the cap.
/// </param>
/// <param name="Offset">
/// Specifies the number of results to skip; <see langword="null" /> means zero.
/// </param>
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>; public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;

View File

@@ -5,11 +5,15 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweryLocations; namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary> /// <summary>
/// Handles queries for brewery posts with locations.
/// </summary>
public class GetAllBreweryLocationsHandler(IBreweryRepository repository) public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>> : IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>>
{ {
/// <summary>Retrieves every brewery post that has a set location.</summary> /// <summary>
/// Retrieves and maps every brewery post that has a location.
/// </summary>
public async Task<IEnumerable<BreweryWithLocationDto>> Handle( public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetAllBreweryLocationsQuery request, GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -4,7 +4,6 @@ using MediatR;
namespace Features.Breweries.Queries.GetAllBreweryLocations; namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary> /// <summary>
/// Retrieves all brewery posts that have a set location. Bound directly from /// Requests brewery posts that include a location.
/// <c>GET /api/brewery/locations</c>.
/// </summary> /// </summary>
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>; public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -5,17 +5,24 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Handles <see cref="GetBreweryByIdQuery" /> by retrieving the matching brewery post.</summary> /// <summary>
/// Handles lookup requests for individual brewery posts.
/// </summary>
public class GetBreweryByIdHandler(IBreweryRepository repository) public class GetBreweryByIdHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?> : IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
{ {
/// <summary>Retrieves the brewery post, or <see langword="null"/> if none exists with the given ID.</summary> /// <summary>
/// Retrieves and maps the post, if it exists.
/// </summary>
public async Task<BreweryDto?> Handle( public async Task<BreweryDto?> Handle(
GetBreweryByIdQuery request, GetBreweryByIdQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken); BreweryPost? brewery = await repository.GetByIdAsync(
request.BreweryPostId,
cancellationToken
);
return brewery?.ToDto(); return brewery?.ToDto();
} }
} }

View File

@@ -3,6 +3,10 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryById; namespace Features.Breweries.Queries.GetBreweryById;
/// <summary>Retrieves a brewery post by ID. Bound directly from the route of <c>GET /api/brewery/{id}</c>.</summary> /// <summary>
/// <remarks>Yields <see langword="null"/> if no brewery exists with the given <c>BreweryPostId</c>.</remarks> /// Requests a brewery post by its identifier.
/// </summary>
/// <remarks>
/// The handler returns <see langword="null" /> when the post is absent.
/// </remarks>
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>; public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;

View File

@@ -6,12 +6,14 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary> /// <summary>
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts. /// Handles proximity searches for brewery locations.
/// </summary> /// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository) public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>> : IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>>
{ {
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary> /// <summary>
/// Retrieves and maps nearby brewery locations.
/// </summary>
public async Task<IEnumerable<BreweryWithLocationDto>> Handle( public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetBreweryLocationsWithinRangeQuery request, GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken

View File

@@ -4,12 +4,19 @@ using MediatR;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary> /// <summary>
/// Retrieves brewery posts with a set location within <paramref name="RangeInMetres" /> of the given /// Requests located brewery posts near a geographic point, nearest first.
/// coordinates, nearest first. Bound directly from the query string of
/// <c>GET /api/brewery/locations/nearby</c>.
/// </summary> /// </summary>
/// <param name="Latitude">The origin latitude, in decimal degrees.</param> /// <param name="Latitude">
/// <param name="Longitude">The origin longitude, in decimal degrees.</param> /// Sets the origin latitude in decimal degrees.
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param> /// </param>
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres) /// <param name="Longitude">
: IRequest<IEnumerable<BreweryWithLocationDto>>; /// Sets the origin longitude in decimal degrees.
/// </param>
/// <param name="RangeInMetres">
/// Sets the inclusive search radius in metres.
/// </param>
public record GetBreweryLocationsWithinRangeQuery(
double Latitude,
double Longitude,
double RangeInMetres
) : IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -2,7 +2,9 @@ using FluentValidation;
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <summary>Validates <see cref="GetBreweryLocationsWithinRangeQuery" />.</summary> /// <summary>
/// Validates coordinates and radius values for proximity searches.
/// </summary>
public class GetBreweryLocationsWithinRangeValidator public class GetBreweryLocationsWithinRangeValidator
: AbstractValidator<GetBreweryLocationsWithinRangeQuery> : AbstractValidator<GetBreweryLocationsWithinRangeQuery>
{ {

View File

@@ -2,11 +2,20 @@ using Domain.Exceptions;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary>Shared ownership check for commands that mutate a specific brewery post.</summary> /// <summary>
/// Provides authorization checks for brewery-post mutations.
/// </summary>
public static class BreweryOwnershipExtensions public static class BreweryOwnershipExtensions
{ {
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given ID.</exception> /// <summary>
/// <exception cref="Domain.Exceptions.ForbiddenException">Thrown when <paramref name="requestingUserId" /> did not post the brewery.</exception> /// Verifies that the specified user created the brewery post.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown if the brewery post cannot be found.
/// </exception>
/// <exception cref="Domain.Exceptions.ForbiddenException">
/// Thrown if the specified user does not own the brewery post.
/// </exception>
public static async Task EnsureCallerOwnsBreweryAsync( public static async Task EnsureCallerOwnsBreweryAsync(
this IBreweryRepository repository, this IBreweryRepository repository,
Guid breweryPostId, Guid breweryPostId,

View File

@@ -7,14 +7,11 @@ using Domain.Exceptions;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Dapper-based implementation of <see cref="IBreweryRepository" />. /// Implements brewery persistence with Dapper.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <c>BreweryPostLocation.Coordinates</c> is a SQL Server <c>GEOGRAPHY</c> column. Dapper's generic /// Coordinate queries convert SQL Server <c>GEOGRAPHY</c> values to <c>varbinary(max)</c>
/// <c>Query&lt;T&gt;</c> deserializer reads columns via <c>IDataRecord.GetValue</c>, which for a UDT /// so Dapper can deserialize them as binary coordinate data.
/// column returns a CLR UDT instance rather than raw bytes and cannot be coerced to <c>byte[]</c>.
/// The read queries below therefore select <c>CONVERT(varbinary(max), bpl.Coordinates)</c>, which
/// yields the UDT's serialized bytes as a plain <c>varbinary</c> value that Dapper can bind directly.
/// </remarks> /// </remarks>
public class BreweryRepository(ISqlConnectionFactory connectionFactory) public class BreweryRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory), : DapperRepository(connectionFactory),
@@ -27,8 +24,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler()); SqlMapper.AddTypeHandler(new CoordinateDataTypeHandler());
} }
/// <inheritdoc/> /// <summary>Gets a brewery post with its location data.</summary>
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) public async Task<BreweryPost?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> results = await connection.QueryAsync< IEnumerable<BreweryPost> results = await connection.QueryAsync<
@@ -73,14 +73,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
cancellationToken: cancellationToken cancellationToken: cancellationToken
), ),
MapBreweryRow, MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
); );
return results.SingleOrDefault(); return results.SingleOrDefault();
} }
/// <inheritdoc/> /// <summary>Gets the identifier of the user who created a brewery post.</summary>
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default) public async Task<Guid?> GetPostedByIdAsync(
Guid id,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>( return await connection.ExecuteScalarAsync<Guid?>(
@@ -92,7 +95,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Gets brewery posts in reverse chronological order.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -140,8 +143,11 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Gets brewery posts within the requested distance from an origin.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres) public async Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(
CoordinateData coords,
double rangeInMetres
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync< return await connection.QueryAsync<
@@ -183,18 +189,26 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
ORDER BY bpl.Coordinates.STDistance(@Origin) ASC ORDER BY bpl.Coordinates.STDistance(@Origin) ASC
""", """,
(post, location, city, stateProvince, country, distance) => (post, location, city, stateProvince, country, distance) =>
MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords), MapBreweryRowWithDistance(
param: new post,
location,
city,
stateProvince,
country,
distance,
coords
),
new
{ {
coords.Latitude, coords.Latitude,
coords.Longitude, coords.Longitude,
RangeInMetres = rangeInMetres RangeInMetres = rangeInMetres,
}, },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
); );
} }
/// <inheritdoc/> /// <summary>Gets brewery posts that have location data.</summary>
public async Task<IEnumerable<BreweryPost>> GetAllLocations() public async Task<IEnumerable<BreweryPost>> GetAllLocations()
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -236,11 +250,16 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Updates a brewery post and its location in a transaction.</summary>
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default) public async Task<BreweryPost> UpdateAsync(
BreweryPost brewery,
CancellationToken cancellationToken = default
)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); await using DbTransaction transaction = await connection.BeginTransactionAsync(
cancellationToken
);
try try
{ {
@@ -363,8 +382,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction, transaction,
@@ -403,8 +422,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction, transaction,
@@ -427,7 +446,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
); );
} }
/// <inheritdoc/> /// <summary>Deletes the brewery post identified by <paramref name="id" />.</summary>
public async Task DeleteAsync(Guid id) public async Task DeleteAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
@@ -445,7 +464,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
throw new NotFoundException("Brewery not found."); throw new NotFoundException("Brewery not found.");
} }
/// <inheritdoc/> /// <summary>Creates a brewery post and its location in a transaction.</summary>
public async Task CreateAsync(BreweryPost brewery) public async Task CreateAsync(BreweryPost brewery)
{ {
if (brewery.Location is null) if (brewery.Location is null)
@@ -543,8 +562,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Location.AddressLine1, brewery.Location.AddressLine1,
brewery.Location.AddressLine2, brewery.Location.AddressLine2,
brewery.Location.PostalCode, brewery.Location.PostalCode,
Latitude = brewery.Location.Coordinates?.Latitude, brewery.Location.Coordinates?.Latitude,
Longitude = brewery.Location.Coordinates?.Longitude, brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction transaction
@@ -560,15 +579,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
} }
/// <summary> /// <summary>
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />, /// Reconstructs a brewery post and its optional location hierarchy from a joined row.
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), Dapper passes <see langword="null" /> for
/// <paramref name="location" /> rather than a blank instance, since its identity column
/// (<c>BreweryPostLocationID</c>) is <c>NULL</c>; the schema guarantees that whenever a location
/// row was joined in, its City/StateProvince/Country chain was too.
/// </summary> /// </summary>
private static BreweryPost MapBreweryRow( private static BreweryPost MapBreweryRow(
BreweryPost post, BreweryPost post,
@@ -596,15 +608,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
return post; return post;
} }
/// <summary>Dapper multi-mapping row for the <c>DistanceMetres</c> column computed by a range query.</summary>
private sealed class DistanceRow
{
public double DistanceMetres { get; set; }
}
/// <summary> /// <summary>
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance /// Reconstructs a brewery row and attaches its database-calculated distance.
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
/// </summary> /// </summary>
private static BreweryPost MapBreweryRowWithDistance( private static BreweryPost MapBreweryRowWithDistance(
BreweryPost post, BreweryPost post,
@@ -620,4 +625,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres); mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres);
return mapped; return mapped;
} }
/// <summary>
/// Holds the distance calculated by a proximity query.
/// </summary>
private sealed class DistanceRow
{
public double DistanceMetres { get; set; }
}
} }

View File

@@ -7,9 +7,7 @@ using NetTopologySuite.IO;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Dapper <see cref="SqlMapper.TypeHandler{T}" /> that deserializes a SQL Server <c>GEOGRAPHY</c> /// Converts SQL Server geography values read as binary data into <see cref="CoordinateData" />.
/// column (read as <c>varbinary(max)</c>, see <see cref="BreweryRepository" />) into a
/// <see cref="CoordinateData" />.
/// </summary> /// </summary>
public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData> public sealed class CoordinateDataTypeHandler : SqlMapper.TypeHandler<CoordinateData>
{ {

View File

@@ -3,69 +3,80 @@ using Domain.Entities;
namespace Features.Breweries.Repository; namespace Features.Breweries.Repository;
/// <summary> /// <summary>
/// Repository for CRUD operations on brewery post records. /// Defines persistence operations for brewery posts.
/// </summary> /// </summary>
public interface IBreweryRepository public interface IBreweryRepository
{ {
/// <summary> /// <summary>
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post /// Gets a brewery post and its location data by identifier.
/// exists with the given ID, or if it has no associated location.
/// </summary> /// </summary>
Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves the <c>PostedById</c> of the brewery post with the given ID, without joining its /// Gets the identifier of the user who created a brewery post.
/// location. Returns <c>null</c> if no brewery post exists with the given ID.
/// </summary> /// </summary>
Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts /// Gets brewery posts ordered by creation time, with optional paging.
/// without a location are included, with <see cref="BreweryPost.Location" /> left <c>null</c>.
/// </summary> /// </summary>
/// <param name="limit">Maximum number of rows to return. Unbounded if <see langword="null" />.</param> /// <param name="limit">
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param> /// Limits returned posts; <see langword="null" /> applies no limit.
/// </param>
/// <param name="offset">
/// Skips returned posts; <see langword="null" /> skips none.
/// </param>
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset); Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
/// <summary> /// <summary>
/// Retrieves brewery posts with a set location within <paramref name="rangeInMetres" /> of /// Gets located brewery posts within a radius, ordered by distance.
/// <paramref name="coords" />, nearest first. Posts without coordinates are excluded.
/// </summary> /// </summary>
/// <param name="coords">The origin point to measure distance from.</param> /// <param name="coords">
/// <param name="rangeInMetres">The maximum distance, in metres, from <paramref name="coords" />.</param> /// Sets the point from which distance is measured.
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres); /// </param>
/// <param name="rangeInMetres">
/// Sets the maximum search distance in metres.
/// </param>
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(
CoordinateData coords,
double rangeInMetres
);
/// <summary> /// <summary>
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts /// Gets brewery posts with location data.
/// without coordinates are excluded.
/// </summary> /// </summary>
Task<IEnumerable<BreweryPost>> GetAllLocations(); Task<IEnumerable<BreweryPost>> GetAllLocations();
/// <summary> /// <summary>
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing /// Saves a brewery post and its location using optimistic concurrency.
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
/// if the row was modified since <c>RowVersion</c> was read. When <paramref name="brewery" />'s
/// <c>Location</c> is <c>null</c>, any existing location for the brewery is removed.
/// Returns the freshly persisted brewery, including its new <c>RowVersion</c>.
/// </summary> /// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with the given ID, or the given location's <c>CityId</c> does not /// Thrown if the brewery post or its requested city cannot be found.
/// exist.
/// </exception> /// </exception>
/// <exception cref="Domain.Exceptions.ConflictException"> /// <exception cref="Domain.Exceptions.ConflictException">
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was /// Thrown if the supplied row version is no longer current.
/// modified by another request since it was last read).
/// </exception> /// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default); Task<BreweryPost> UpdateAsync(
BreweryPost brewery,
CancellationToken cancellationToken = default
);
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary> /// <summary>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception> /// Deletes a brewery post and its dependent records.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown if the brewery post cannot be found.
/// </exception>
Task DeleteAsync(Guid id); Task DeleteAsync(Guid id);
/// <summary>Creates a new brewery post, including its location details.</summary> /// <summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" /> has no <c>Location</c>.</exception> /// Creates a brewery post and its required location.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="brewery" /> has no location.
/// </exception>
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when <paramref name="brewery" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist. /// Thrown if the posting user or location city cannot be found.
/// </exception> /// </exception>
Task CreateAsync(BreweryPost brewery); Task CreateAsync(BreweryPost brewery);
} }

View File

@@ -1,4 +1,4 @@
@using Infrastructure.Email.Templates.Components @using Features.Emails.Templates.Components
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"> xmlns:o="urn:schemas-microsoft-com:office:office">

View File

@@ -1,4 +1,4 @@
@using Infrastructure.Email.Templates.Components @using Features.Emails.Templates.Components
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"> xmlns:o="urn:schemas-microsoft-com:office:office">

View File

@@ -1,10 +1,10 @@
using Infrastructure.Email.Templates.Mail; using Features.Emails.Templates.Mail;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering; using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Infrastructure.Email.Templates.Rendering; namespace Features.Emails.Templates.Rendering;
/// <summary> /// <summary>
/// Service for rendering Razor email templates to HTML using HtmlRenderer. /// Service for rendering Razor email templates to HTML using HtmlRenderer.

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Email.Templates.Rendering; namespace Features.Emails.Templates.Rendering;
/// <summary> /// <summary>
/// Service for rendering Razor email templates to HTML. /// Service for rendering Razor email templates to HTML.

View File

@@ -1,24 +0,0 @@
using Features.Emails.Commands.SendRegistrationEmail;
using Features.Emails.Services;
using Moq;
using Shared.Application.Emails;
namespace Features.Emails.Tests.Commands;
public class SendRegistrationEmailHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
SendRegistrationEmailHandler handler = new(dispatcherMock.Object);
SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123");
await handler.Handle(command, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"),
Times.Once
);
}
}

View File

@@ -1,24 +0,0 @@
using Features.Emails.Commands.SendResendConfirmationEmail;
using Features.Emails.Services;
using Moq;
using Shared.Application.Emails;
namespace Features.Emails.Tests.Commands;
public class SendResendConfirmationEmailHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object);
SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456");
await handler.Handle(command, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
Times.Once
);
}
}

View File

@@ -0,0 +1,63 @@
using Features.Emails.Notifications;
using Features.Emails.Services;
using Features.Users.Notifications;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
namespace Features.Emails.Tests.Notifications;
public class ConfirmationEmailResendRequestedNotificationHandlerTests
{
[Fact]
public async Task Handle_DelegatesToEmailDispatcher()
{
Mock<IEmailDispatcher> dispatcherMock = new();
ConfirmationEmailResendRequestedNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<ConfirmationEmailResendRequestedNotificationHandler>>()
);
ConfirmationEmailResendRequestedNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-456"
);
await handler.Handle(notification, CancellationToken.None);
dispatcherMock.Verify(
d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
Times.Once
);
}
[Fact]
public async Task Handle_SwallowsDispatcherFailure()
{
Mock<IEmailDispatcher> dispatcherMock = new();
dispatcherMock
.Setup(d =>
d.SendResendConfirmationEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()
)
)
.ThrowsAsync(new Exception("smtp down"));
ConfirmationEmailResendRequestedNotificationHandler handler = new(
dispatcherMock.Object,
Mock.Of<ILogger<ConfirmationEmailResendRequestedNotificationHandler>>()
);
ConfirmationEmailResendRequestedNotification notification = new(
Guid.NewGuid(),
"Aaron",
"aaron@example.com",
"token-456"
);
Func<Task> act = async () => await handler.Handle(notification, CancellationToken.None);
await act.Should().NotThrowAsync();
}
}

Some files were not shown because too many files have changed in this diff Show More