mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
37 Commits
d6e176bcd9
...
feat/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028a43f4b | ||
|
|
fffbe68dc2 | ||
|
|
e6240bc889 | ||
|
|
4a5abd4639 | ||
|
|
d02f31b55c | ||
|
|
e961ce4042 | ||
|
|
dfb67bcdfa | ||
|
|
0ecc9a830e | ||
|
|
5ac55b8be0 | ||
|
|
19791df6a5 | ||
|
|
0780349a79 | ||
|
|
c0cb013f54 | ||
|
|
dfe248ea5f | ||
|
|
ff775e3b8a | ||
|
|
0e51630439 | ||
|
|
bd0e9e4a20 | ||
|
|
c5972b9f04 | ||
|
|
e1b6cba1d4 | ||
|
|
0ff42c3c70 | ||
|
|
a6a9046457 | ||
|
|
40ae75808c | ||
|
|
fbede956e9 | ||
|
|
3793fca65e | ||
|
|
78d0eaa739 | ||
|
|
180ed41e68 | ||
|
|
ce2c90070e | ||
|
|
de63e9f216 | ||
|
|
7187631ebc | ||
|
|
5fa78fbdf6 | ||
|
|
f85ff79a2c | ||
|
|
a3ec7d63f2 | ||
|
|
9d5a463d93 | ||
|
|
d958933250 | ||
|
|
4aa7ba5d9b | ||
|
|
272a25fb05 | ||
|
|
d9411eb00e | ||
|
|
78ad32e93a |
101
.github/pull_request_template.md
vendored
Normal file
101
.github/pull_request_template.md
vendored
Normal 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.
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -123,7 +121,8 @@ own
|
|||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
|
| `Program.cs` | Thin composition root: builds the host and calls the `ServiceCollectionExtensions` methods below. |
|
||||||
|
| `ServiceCollectionExtensions.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
|
||||||
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
|
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
|
||||||
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
|
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
|
||||||
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
|
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
|
||||||
@@ -132,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**:
|
||||||
|
|
||||||
@@ -166,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`)
|
||||||
|
|
||||||
@@ -189,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**:
|
||||||
|
|
||||||
@@ -207,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**:
|
||||||
|
|
||||||
@@ -269,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
|
||||||
@@ -384,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
|
||||||
@@ -400,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
|
||||||
@@ -564,11 +565,12 @@ 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +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/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
|
(host-level auth middleware) also depends on `ITokenInfrastructure` directly,
|
||||||
middleware) also depends on it directly.
|
resolving it from the same DI container.
|
||||||
|
|
||||||
### Integration points
|
### Integration points
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,24 +7,27 @@
|
|||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="API.Specs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -25,17 +25,13 @@ public class JwtAuthenticationHandler(
|
|||||||
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable.
|
||||||
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
/// Fails if the secret is not configured, the <c>Authorization</c> header is
|
||||||
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
|
|
||||||
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
|
||||||
string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
|
string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
|
||||||
secret = configuration[ConfigurationKeys.JwtSecretKeyFallback];
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
|
||||||
/// <c>Program.cs</c>.
|
|
||||||
/// </remarks>
|
|
||||||
[ApiController]
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
|
||||||
[Route("error")]
|
|
||||||
public class NotFoundController : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a 404 response for any request that did not match a route.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("404")]
|
|
||||||
public IActionResult Handle404()
|
|
||||||
{
|
|
||||||
return NotFound(new { message = "Route not found." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Shared.Contracts;
|
|
||||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
|
||||||
public class ProtectedController : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the authenticated caller's user ID and username extracted from the JWT claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
|
||||||
{
|
|
||||||
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
|
||||||
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
|
||||||
|
|
||||||
return Ok(
|
|
||||||
new ResponseBody<object>
|
|
||||||
{
|
|
||||||
Message = "Protected endpoint accessed successfully",
|
|
||||||
Payload = new { userId, username },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 . .
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ namespace API.Core;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||||
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||||
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||||
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,86 @@
|
|||||||
using API.Core;
|
|
||||||
using API.Core.Authentication;
|
|
||||||
using Database.Connection.DependencyInjection;
|
|
||||||
using Features.Auth.Controllers;
|
|
||||||
using Features.Users.DependencyInjection;
|
|
||||||
using Features.Breweries.Controllers;
|
|
||||||
using Features.Breweries.DependencyInjection;
|
|
||||||
using Features.Emails.DependencyInjection;
|
|
||||||
using Features.Emails.Services;
|
|
||||||
using Features.ImageUploads.Commands.UploadPhoto;
|
|
||||||
using Features.ImageUploads.DependencyInjection;
|
|
||||||
using FluentValidation;
|
|
||||||
using FluentValidation.AspNetCore;
|
|
||||||
using Infrastructure.FileUpload;
|
|
||||||
using Infrastructure.Jwt;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using Shared.Application.Behaviors;
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Shared.Contracts;
|
||||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
namespace API.Core;
|
||||||
|
|
||||||
// Add current controllers to the exception filter
|
internal class Program
|
||||||
builder
|
|
||||||
.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
|
|
||||||
.AddApplicationPart(typeof(BreweryController).Assembly)
|
|
||||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
{
|
||||||
options.AddSecurityDefinition(
|
public static void Main(string[] args)
|
||||||
"Bearer",
|
{
|
||||||
new OpenApiSecurityScheme
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
{
|
|
||||||
Name = "Authorization",
|
builder.Logging.ClearProviders().AddConsole();
|
||||||
Type = SecuritySchemeType.Http,
|
|
||||||
Scheme = "Bearer",
|
if (!builder.Environment.IsProduction())
|
||||||
BearerFormat = "JWT",
|
builder.Logging.AddDebug();
|
||||||
In = ParameterLocation.Header,
|
|
||||||
Description = "Enter a JWT access token.",
|
builder
|
||||||
}
|
.Services.AddApiControllers()
|
||||||
);
|
.AddValidationAndMediatR()
|
||||||
options.AddSecurityRequirement(
|
.AddApplicationServices()
|
||||||
new OpenApiSecurityRequirement
|
.AddFeatureModules()
|
||||||
{
|
.AddCoreInfrastructure()
|
||||||
{
|
.AddJwtAuthentication();
|
||||||
new OpenApiSecurityScheme
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
app.UseSwagger()
|
||||||
|
.UseSwaggerUI()
|
||||||
|
.UseHttpsRedirection()
|
||||||
|
.UseAuthentication()
|
||||||
|
.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
if (app.Environment.IsEnvironment("Testing"))
|
||||||
|
app.MapGet(
|
||||||
|
"/api/protected",
|
||||||
|
(ClaimsPrincipal user) =>
|
||||||
{
|
{
|
||||||
Reference = new OpenApiReference
|
string? userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
{
|
string? username = user.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
Type = ReferenceType.SecurityScheme,
|
|
||||||
Id = "Bearer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
return Results.Ok(
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
new ResponseBody<object>
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
{
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
Message = "Protected endpoint accessed successfully",
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
Payload = new { userId, username },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser());
|
||||||
|
|
||||||
// Add MediatR.
|
// Easter egg, as per tradition with the previous versions of the Biergarten App.
|
||||||
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
app.Map(
|
||||||
builder.Services.AddMediatR(cfg =>
|
"/teapot",
|
||||||
{
|
() =>
|
||||||
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
Results.Json(
|
||||||
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
new ResponseBody
|
||||||
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
{
|
||||||
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
Message =
|
||||||
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
"I'm a little teapot, short and stout. Here is my handle, here is my spout!",
|
||||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
},
|
||||||
});
|
statusCode: 418
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
app.MapFallback(() =>
|
||||||
|
Results.NotFound(
|
||||||
|
new ResponseBody { Message = "Are you lost? That route does not exist." }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
// Configure logging for container output
|
app.Services.GetRequiredService<IHostApplicationLifetime>()
|
||||||
builder.Logging.ClearProviders();
|
.ApplicationStopping.Register(() =>
|
||||||
builder.Logging.AddConsole();
|
{
|
||||||
if (!builder.Environment.IsProduction())
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
builder.Logging.AddDebug();
|
});
|
||||||
|
|
||||||
builder.Services.AddDatabaseConnection();
|
app.Run();
|
||||||
|
}
|
||||||
builder.Services.AddFeaturesBreweries();
|
}
|
||||||
builder.Services.AddFeaturesUsers();
|
|
||||||
builder.Services.AddFeaturesEmails();
|
|
||||||
builder.Services.AddFeaturesPhotoUpload();
|
|
||||||
|
|
||||||
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
|
||||||
|
|
||||||
builder
|
|
||||||
.Services.AddAuthentication("JWT")
|
|
||||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
|
||||||
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
app.MapOpenApi();
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.MapFallbackToController("Handle404", "NotFound");
|
|
||||||
|
|
||||||
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
|
||||||
lifetime.ApplicationStopping.Register(() =>
|
|
||||||
{
|
|
||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|||||||
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using API.Core.Authentication;
|
||||||
|
using Database.Connection.DependencyInjection;
|
||||||
|
using Features.Breweries.Controllers;
|
||||||
|
using Features.Breweries.DependencyInjection;
|
||||||
|
using Features.Emails.DependencyInjection;
|
||||||
|
using Features.Emails.Services;
|
||||||
|
using Features.ImageUploads.Commands.UploadPhoto;
|
||||||
|
using Features.ImageUploads.DependencyInjection;
|
||||||
|
using Features.Locations.Controllers;
|
||||||
|
using Features.Locations.DependencyInjection;
|
||||||
|
using Features.Users.Controllers;
|
||||||
|
using Features.Users.DependencyInjection;
|
||||||
|
using FluentValidation;
|
||||||
|
using FluentValidation.AspNetCore;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
|
namespace API.Core;
|
||||||
|
|
||||||
|
internal static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
extension(IServiceCollection services)
|
||||||
|
{
|
||||||
|
public IServiceCollection AddApiControllers() =>
|
||||||
|
services
|
||||||
|
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
|
||||||
|
.AddApplicationPart(typeof(BreweryController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(AuthController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(CityController).Assembly)
|
||||||
|
.Services;
|
||||||
|
|
||||||
|
public IServiceCollection AddValidationAndMediatR()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddOpenApi()
|
||||||
|
.AddValidatorsFromAssemblyContaining<Program>()
|
||||||
|
.AddValidatorsFromAssembly(typeof(BreweryController).Assembly)
|
||||||
|
.AddValidatorsFromAssembly(typeof(AuthController).Assembly)
|
||||||
|
.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly)
|
||||||
|
.AddFluentValidationAutoValidation()
|
||||||
|
.AddMediatR(cfg =>
|
||||||
|
{
|
||||||
|
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
|
||||||
|
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
|
||||||
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IServiceCollection AddApplicationServices()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddEndpointsApiExplorer()
|
||||||
|
.AddSwaggerGen(options =>
|
||||||
|
{
|
||||||
|
options.AddSecurityDefinition(
|
||||||
|
"Bearer",
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Name = "Authorization",
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "Bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Description = "Enter a JWT access token.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
options.AddSecurityRequirement(
|
||||||
|
new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = "Bearer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHealthChecks();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IServiceCollection AddFeatureModules() =>
|
||||||
|
services
|
||||||
|
.AddDatabaseConnection()
|
||||||
|
.AddFeaturesBreweries()
|
||||||
|
.AddFeaturesUsers()
|
||||||
|
.AddFeaturesEmails()
|
||||||
|
.AddFeaturesPhotoUpload()
|
||||||
|
.AddFeaturesLocations();
|
||||||
|
|
||||||
|
public IServiceCollection AddCoreInfrastructure() =>
|
||||||
|
services.AddScoped<GlobalExceptionFilter>();
|
||||||
|
|
||||||
|
public IServiceCollection AddJwtAuthentication()
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddAuthentication("JWT")
|
||||||
|
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||||
|
"JWT",
|
||||||
|
options => { }
|
||||||
|
);
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 . .
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ Scenario: GET request to an invalid route returns 404
|
|||||||
Given the API is running
|
Given the API is running
|
||||||
When I send an HTTP request "GET" to "/invalid-route"
|
When I send an HTTP request "GET" to "/invalid-route"
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
And the response JSON should have "message" equal "Route not found."
|
And the response JSON should have "message" equal "Are you lost? That route does not exist."
|
||||||
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Infrastructure.Email;
|
using Features.Emails.Services;
|
||||||
|
|
||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
|
|||||||
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public abstract class ApiStepsBase(ScenarioContext scenario)
|
|||||||
protected const string ResponseKey = "response";
|
protected const string ResponseKey = "response";
|
||||||
protected const string ResponseBodyKey = "responseBody";
|
protected const string ResponseBodyKey = "responseBody";
|
||||||
|
|
||||||
protected TestApiFactory GetFactory()
|
internal TestApiFactory GetFactory()
|
||||||
{
|
{
|
||||||
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
|
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
|
||||||
FactoryKey,
|
FactoryKey,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
FixtureStateProvinceName,
|
?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode);
|
||||||
FixtureStateProvinceIsoCode,
|
|
||||||
FixtureCountryName,
|
|
||||||
FixtureCountryIsoCode
|
|
||||||
);
|
|
||||||
|
|
||||||
await CityProvisioningLock.WaitAsync();
|
Guid stateProvinceId =
|
||||||
Guid cityId;
|
await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode)
|
||||||
try
|
?? await repository.CreateStateProvinceAsync(
|
||||||
{
|
FixtureStateProvinceName,
|
||||||
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
|
FixtureStateProvinceIsoCode,
|
||||||
}
|
countryId
|
||||||
finally
|
);
|
||||||
{
|
|
||||||
CityProvisioningLock.Release();
|
Guid cityId =
|
||||||
}
|
await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode)
|
||||||
|
?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId);
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
|||||||
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
namespace API.Specs;
|
namespace API.Specs;
|
||||||
|
|
||||||
public class TestApiFactory : WebApplicationFactory<Program>
|
internal class TestApiFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,15 @@ public 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>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
371
web/backend/Database/Database.Seed/BiergartenDataSeeder.cs
Normal file
371
web/backend/Database/Database.Seed/BiergartenDataSeeder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Features.Auth.Services;
|
using Features.Users.Services;
|
||||||
|
|
||||||
namespace Database.Seed.DatabaseHelpers;
|
namespace Database.Seed.DatabaseHelpers;
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Database.Seed.PipelineData;
|
|
||||||
|
|
||||||
public sealed record BreweryRecord
|
|
||||||
{
|
|
||||||
public required BreweryAddress Address { get; init; }
|
|
||||||
|
|
||||||
public required BreweryResult Brewery { get; init; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; } = [];
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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,264 +30,47 @@ 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();
|
||||||
IMediator mediator = provider.GetRequiredService<IMediator>();
|
|
||||||
|
|
||||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
|
|
||||||
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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
|
||||||
AnsiConsole.WriteException(ex);
|
|
||||||
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;
|
public static async Task<int> Main()
|
||||||
}
|
|
||||||
|
|
||||||
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...");
|
using CancellationTokenSource cts = new();
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
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(),
|
e.Cancel = true;
|
||||||
ContentType = "image/png",
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
cts.Cancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
await mediator.Send(new UploadAvatarCommand(userId, file));
|
try
|
||||||
|
{
|
||||||
|
await using ServiceProvider provider = BuildServiceProvider();
|
||||||
|
IMediator mediator = provider.GetRequiredService<IMediator>();
|
||||||
|
SeedRepository reader = new SeedRepository(
|
||||||
|
connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
|
||||||
|
);
|
||||||
|
await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
||||||
|
AnsiConsole.WriteException(ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
public PipelineSeedDataReader(string connectionString)
|
|
||||||
{
|
{
|
||||||
_connectionString = connectionString;
|
connection = new(connectionString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
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;
|
Id = (int)row.Id,
|
||||||
const int descriptionLocalIndex = 3;
|
NameEn = row.NameEn,
|
||||||
const int cityIdIndex = 4;
|
DescriptionEn = row.DescriptionEn,
|
||||||
const int longitudeIndex = 5;
|
NameLocal = row.NameLocal,
|
||||||
const int latitudeIndex = 6;
|
DescriptionLocal = row.DescriptionLocal,
|
||||||
const int addressLine1Index = 7;
|
Address = new BreweryAddress
|
||||||
const int postalCodeIndex = 8;
|
|
||||||
|
|
||||||
List<BreweryRecord> records = [];
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
records.Add(
|
|
||||||
new BreweryRecord
|
|
||||||
{
|
{
|
||||||
Brewery = new BreweryResult
|
Id = (int)row.AddressId,
|
||||||
{
|
CityId = (int)row.CityId,
|
||||||
NameEn = reader.GetString(nameEnIndex),
|
BreweryId = (int)row.Id,
|
||||||
DescriptionEn = reader.GetString(descriptionEnIndex),
|
Longitude = row.Longitude,
|
||||||
NameLocal = reader.GetString(nameLocalIndex),
|
Latitude = row.Latitude,
|
||||||
DescriptionLocal = reader.GetString(descriptionLocalIndex),
|
AddressLine1 = row.AddressLine1,
|
||||||
},
|
PostalCode = row.PostalCode,
|
||||||
Address = new BreweryAddress
|
City = cities[(int)row.CityId],
|
||||||
{
|
},
|
||||||
City = cities[reader.GetInt64(cityIdIndex)],
|
})];
|
||||||
Longitude = reader.GetDouble(longitudeIndex),
|
|
||||||
Latitude = reader.GetDouble(latitudeIndex),
|
|
||||||
AddressLine1 = reader.GetString(addressLine1Index),
|
|
||||||
PostalCode = reader.GetString(postalCodeIndex),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
Email = row.Email,
|
||||||
const int usernameIndex = 3;
|
DateOfBirth = row.DateOfBirth,
|
||||||
const int bioIndex = 4;
|
Address = new UserAddress
|
||||||
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
|
Id = (int)row.AddressId,
|
||||||
{
|
CityId = (int)row.CityId,
|
||||||
FirstName = reader.GetString(firstNameIndex),
|
UserId = (int)row.Id,
|
||||||
LastName = reader.GetString(lastNameIndex),
|
Longitude = row.Longitude,
|
||||||
Gender = reader.GetString(genderIndex),
|
Latitude = row.Latitude,
|
||||||
Username = reader.GetString(usernameIndex),
|
City = cities[(int)row.CityId],
|
||||||
Bio = reader.GetString(bioIndex),
|
},
|
||||||
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
|
User = new UserResult
|
||||||
},
|
{
|
||||||
Email = reader.GetString(emailIndex),
|
Id = (int)row.Id,
|
||||||
DateOfBirth = reader.GetString(dateOfBirthIndex),
|
FirstName = row.FirstName,
|
||||||
Address = new UserAddress
|
LastName = row.LastName,
|
||||||
{
|
Gender = row.Gender,
|
||||||
City = cities[reader.GetInt64(cityIdIndex)],
|
Username = row.Username,
|
||||||
Longitude = reader.GetDouble(longitudeIndex),
|
Bio = row.Bio,
|
||||||
Latitude = reader.GetDouble(latitudeIndex),
|
ActivityWeight = (float)row.ActivityWeight,
|
||||||
},
|
},
|
||||||
}
|
})];
|
||||||
);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyDictionary<long, City>> ReadCitiesAsync(
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyDictionary<int, 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) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,18 +8,19 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
<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>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj" />
|
<ProjectReference Include="..\Features.Breweries\Features.Breweries.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>()))
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<T></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,125 +143,141 @@ 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<
|
||||||
BreweryPost,
|
BreweryPost,
|
||||||
BreweryPostLocation,
|
BreweryPostLocation,
|
||||||
City,
|
City,
|
||||||
StateProvince,
|
StateProvince,
|
||||||
Country,
|
Country,
|
||||||
DistanceRow,
|
DistanceRow,
|
||||||
BreweryPost
|
BreweryPost
|
||||||
> (
|
>(
|
||||||
"""
|
"""
|
||||||
DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326);
|
DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326);
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
bp.BreweryPostID,
|
bp.BreweryPostID,
|
||||||
bp.BreweryName,
|
bp.BreweryName,
|
||||||
bpl.BreweryPostLocationID,
|
bpl.BreweryPostLocationID,
|
||||||
bpl.AddressLine1,
|
bpl.AddressLine1,
|
||||||
bpl.AddressLine2,
|
bpl.AddressLine2,
|
||||||
bpl.PostalCode,
|
bpl.PostalCode,
|
||||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||||
c.CityID,
|
c.CityID,
|
||||||
c.CityName,
|
c.CityName,
|
||||||
sp.StateProvinceID,
|
sp.StateProvinceID,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceName,
|
||||||
sp.ISO3166_2 AS Iso31662,
|
sp.ISO3166_2 AS Iso31662,
|
||||||
co.CountryID,
|
co.CountryID,
|
||||||
co.CountryName,
|
co.CountryName,
|
||||||
co.ISO3166_1 AS Iso31661,
|
co.ISO3166_1 AS Iso31661,
|
||||||
bpl.Coordinates.STDistance(@Origin) AS DistanceMetres
|
bpl.Coordinates.STDistance(@Origin) AS DistanceMetres
|
||||||
FROM Brewery.BreweryPost bp
|
FROM Brewery.BreweryPost bp
|
||||||
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
|
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
|
||||||
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
||||||
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
|
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
|
||||||
WHERE bpl.Coordinates IS NOT NULL
|
WHERE bpl.Coordinates IS NOT NULL
|
||||||
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
|
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
|
||||||
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,
|
||||||
coords.Latitude,
|
city,
|
||||||
coords.Longitude,
|
stateProvince,
|
||||||
RangeInMetres = rangeInMetres
|
country,
|
||||||
},
|
distance,
|
||||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
|
coords
|
||||||
);
|
),
|
||||||
|
new
|
||||||
|
{
|
||||||
|
coords.Latitude,
|
||||||
|
coords.Longitude,
|
||||||
|
RangeInMetres = rangeInMetres,
|
||||||
|
},
|
||||||
|
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();
|
||||||
return await connection.QueryAsync<
|
return await connection.QueryAsync<
|
||||||
BreweryPost,
|
BreweryPost,
|
||||||
BreweryPostLocation,
|
BreweryPostLocation,
|
||||||
City,
|
City,
|
||||||
StateProvince,
|
StateProvince,
|
||||||
Country,
|
Country,
|
||||||
BreweryPost
|
BreweryPost
|
||||||
> (
|
>(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
bp.BreweryPostID,
|
bp.BreweryPostID,
|
||||||
bp.BreweryName,
|
bp.BreweryName,
|
||||||
bpl.BreweryPostLocationID,
|
bpl.BreweryPostLocationID,
|
||||||
bpl.AddressLine1,
|
bpl.AddressLine1,
|
||||||
bpl.AddressLine2,
|
bpl.AddressLine2,
|
||||||
bpl.PostalCode,
|
bpl.PostalCode,
|
||||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||||
c.CityID,
|
c.CityID,
|
||||||
c.CityName,
|
c.CityName,
|
||||||
sp.StateProvinceID,
|
sp.StateProvinceID,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceName,
|
||||||
sp.ISO3166_2 AS Iso31662,
|
sp.ISO3166_2 AS Iso31662,
|
||||||
co.CountryID,
|
co.CountryID,
|
||||||
co.CountryName,
|
co.CountryName,
|
||||||
co.ISO3166_1 AS Iso31661
|
co.ISO3166_1 AS Iso31661
|
||||||
FROM Brewery.BreweryPost bp
|
FROM Brewery.BreweryPost bp
|
||||||
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
INNER JOIN Brewery.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
|
LEFT JOIN Geolocation.City c ON bpl.CityID = c.CityID
|
||||||
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
LEFT JOIN Geolocation.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
||||||
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
|
LEFT JOIN Geolocation.Country co ON sp.CountryID = co.CountryID
|
||||||
WHERE bpl.Coordinates IS NOT NULL
|
WHERE bpl.Coordinates IS NOT NULL
|
||||||
ORDER BY bp.BreweryPostID ASC
|
ORDER BY bp.BreweryPostID ASC
|
||||||
""",
|
""",
|
||||||
MapBreweryRow,
|
MapBreweryRow,
|
||||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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
|
||||||
{
|
{
|
||||||
bool breweryExists =
|
bool breweryExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"""
|
"""
|
||||||
SELECT 1 FROM
|
SELECT 1 FROM
|
||||||
Brewery.BreweryPost
|
Brewery.BreweryPost
|
||||||
WHERE
|
WHERE
|
||||||
BreweryPostID = @BreweryPostId
|
BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction,
|
transaction,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not null;
|
)
|
||||||
|
is not null;
|
||||||
|
|
||||||
if (!breweryExists)
|
if (!breweryExists)
|
||||||
throw new NotFoundException("Brewery not found.");
|
throw new NotFoundException("Brewery not found.");
|
||||||
@@ -267,14 +286,14 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
{
|
{
|
||||||
bool cityExists =
|
bool cityExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"SELECT 1 FROM Geolocation.City WHERE CityID = @CityId",
|
"SELECT 1 FROM Geolocation.City WHERE CityID = @CityId",
|
||||||
new { brewery.Location.CityId },
|
new { brewery.Location.CityId },
|
||||||
transaction,
|
transaction,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not null;
|
)
|
||||||
|
is not null;
|
||||||
|
|
||||||
if (!cityExists)
|
if (!cityExists)
|
||||||
throw new NotFoundException("City not found.");
|
throw new NotFoundException("City not found.");
|
||||||
@@ -328,18 +347,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
{
|
{
|
||||||
bool locationExists =
|
bool locationExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM Brewery.BreweryPostLocation
|
FROM Brewery.BreweryPostLocation
|
||||||
WHERE BreweryPostID = @BreweryPostId
|
WHERE BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction,
|
transaction,
|
||||||
cancellationToken: cancellationToken
|
cancellationToken: cancellationToken
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not null;
|
)
|
||||||
|
is not null;
|
||||||
|
|
||||||
if (locationExists)
|
if (locationExists)
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@@ -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,
|
||||||
@@ -422,12 +441,12 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
|
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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)
|
||||||
@@ -458,38 +477,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
{
|
{
|
||||||
bool userExists =
|
bool userExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM
|
FROM
|
||||||
Auth.UserAccount
|
Auth.UserAccount
|
||||||
WHERE
|
WHERE
|
||||||
UserAccountID = @PostedById
|
UserAccountID = @PostedById
|
||||||
""",
|
""",
|
||||||
new { brewery.PostedById },
|
new { brewery.PostedById },
|
||||||
transaction
|
transaction
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not null;
|
)
|
||||||
|
is not null;
|
||||||
|
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
throw new NotFoundException("User not found.");
|
throw new NotFoundException("User not found.");
|
||||||
|
|
||||||
bool cityExists =
|
bool cityExists =
|
||||||
await connection.ExecuteScalarAsync<int?>(
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM
|
FROM
|
||||||
Geolocation.City
|
Geolocation.City
|
||||||
WHERE
|
WHERE
|
||||||
CityID = @CityId
|
CityID = @CityId
|
||||||
""",
|
""",
|
||||||
new { brewery.Location.CityId },
|
new { brewery.Location.CityId },
|
||||||
transaction
|
transaction
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not null;
|
)
|
||||||
|
is not null;
|
||||||
|
|
||||||
if (!cityExists)
|
if (!cityExists)
|
||||||
throw new NotFoundException("City not found.");
|
throw new NotFoundException("City not found.");
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user