mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Migrate Auth/Brewery/Location repositories to pure Dapper; rename Timer to RowVersion (#237)
* Integrate Spectre.Console into seed application, use async data loading * Replace stored-procedure repositories with Dapper-based repositories Removes the USP_* stored procedures for Auth, Location, and Brewery data access in favour of services built with Dapper. Adds a new Features.Locations slice and optimistic concurrency support for the Brewery slice. * Add new documentation for Dapper migration/database schema * Update FluentAssertions to most recent open source ver * Update XML doc comment in backend projects * Modify brewery location dto to include denormalized data * - Updated column names from 'Timer' to 'RowVersion' in schema.sql for - Introduced new BeerPost entity and updated BreweryPost and BreweryPostLocation entities to use 'RowVersion' for concurrency. - Modified UserAccount, UserCredential, UserVerification, City, StateProvince, and Country entities to use 'RowVersion' instead of 'Timer'. - Updated tests and command handlers to reflect changes in concurrency token from 'Timer' to 'RowVersion'. - Adjusted BreweryDto and BreweryDtoMapper to include 'RowVersion' and updated mapping logic accordingly. - Ensured all repository methods and interfaces are aligned with the new concurrency handling approach. * updates * Add BeerStyle entity and related properties; refactor BreweryDto to remove BeerPosts * Fix Api.Specs test failures: stale Timer column refs and missing test.user fixture * Remove repository tests, usages of registration dto used in repo * archive old stored procs/functions * update stale docs * Add instructions for generating cryptographically secure base64 secrets on macOS/Linux and Windows
This commit is contained in:
@@ -4,7 +4,7 @@ The Biergarten App is a full-stack directory and discovery platform for
|
|||||||
breweries. It provides:
|
breweries. It provides:
|
||||||
|
|
||||||
- JWT-based user authentication, with email confirmation and refresh-token
|
- JWT-based user authentication, with email confirmation and refresh-token
|
||||||
rotation
|
reissue
|
||||||
- A searchable database of brewery locations
|
- A searchable database of brewery locations
|
||||||
- An offline data-generation pipeline that uses large language models (LLMs)
|
- An offline data-generation pipeline that uses large language models (LLMs)
|
||||||
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
||||||
@@ -24,6 +24,7 @@ Website + backend (active stack):
|
|||||||
|
|
||||||
- [Getting Started](docs/website/getting-started.md)
|
- [Getting Started](docs/website/getting-started.md)
|
||||||
- [Architecture](docs/architecture.md)
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Database](docs/website/database.md)
|
||||||
- [Docker Guide](docs/website/docker.md)
|
- [Docker Guide](docs/website/docker.md)
|
||||||
- [Testing](docs/website/testing.md)
|
- [Testing](docs/website/testing.md)
|
||||||
- [Environment Variables](docs/website/environment-variables.md)
|
- [Environment Variables](docs/website/environment-variables.md)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ active website:
|
|||||||
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
||||||
frontend
|
frontend
|
||||||
|
|
||||||
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
|
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
|
||||||
for reference only.
|
reference only.
|
||||||
|
|
||||||
## Diagrams
|
## Diagrams
|
||||||
|
|
||||||
@@ -35,9 +35,11 @@ For visual representations, see:
|
|||||||
|
|
||||||
The backend organizes business capabilities as feature slices instead of
|
The backend organizes business capabilities as feature slices instead of
|
||||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns
|
`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single
|
||||||
its own controller, MediatR commands/queries/handlers, validators, and
|
project that owns its own MediatR commands/queries/handlers, validators, and
|
||||||
repository, end to end:
|
repository end to end. `Features.Emails` and `Features.Locations` have no
|
||||||
|
`Controllers/` folder — both are invoked internally by other slices via
|
||||||
|
MediatR/DI, never over HTTP:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌───────────────────────────────────────────────────────────────────────┐
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
@@ -46,15 +48,15 @@ repository, end to end:
|
|||||||
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
|
||||||
└───────────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
↓ discovers controllers via AddApplicationPart
|
↓ discovers controllers via AddApplicationPart
|
||||||
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
|
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐
|
||||||
│ Features.Auth │Features. │ Features. │ Features.Emails │
|
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │
|
||||||
│ │Breweries │ UserManagement │ (no controller, │
|
│ │Breweries │ UserManagement │ (no controller, │ Locations │
|
||||||
│ Controller │ Controller │ Controller │ internal only) │
|
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │
|
||||||
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │
|
||||||
│ Queries + │ Queries + │ Queries + │ Handlers │
|
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │
|
||||||
│ Handlers │ Handlers │ Handlers │ │
|
│ Handlers │ Handlers │ Handlers │ │ │
|
||||||
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
│ Repository │ Repository │ Repository │ EmailDispatcher │ │
|
||||||
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘
|
||||||
↓ each slice depends only on shared/domain/infra, never on another slice
|
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||||
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||||
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||||
@@ -69,23 +71,22 @@ repository, end to end:
|
|||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ Database (SQL Server) │
|
│ Database (SQL Server) │
|
||||||
│ - Stored procedures │
|
|
||||||
│ - Tables & constraints │
|
│ - Tables & constraints │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Slices never reference each other's project. The one cross-slice
|
Slices never reference each other's project. The one cross-slice interaction
|
||||||
interaction (`Features.Auth` triggering a confirmation email handled by
|
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
|
||||||
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
|
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
|
||||||
whose contract lives in `Shared.Application`, so neither slice takes a
|
lives in `Shared.Application`, so neither slice takes a project reference on the
|
||||||
project reference on the other.
|
other.
|
||||||
|
|
||||||
### Layer responsibilities
|
### Layer responsibilities
|
||||||
|
|
||||||
#### API layer (`API.Core`)
|
#### API layer (`API.Core`)
|
||||||
|
|
||||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
|
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
|
||||||
its own
|
own
|
||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
@@ -108,32 +109,36 @@ its own
|
|||||||
- No controllers, no business logic, no feature-specific contracts
|
- No controllers, no business logic, no feature-specific contracts
|
||||||
- Exists purely to host and wire up the feature slices
|
- Exists purely to host and wire up the feature slices
|
||||||
|
|
||||||
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`)
|
||||||
|
|
||||||
**Purpose**: Each slice is the complete vertical for one business capability
|
**Purpose**: Each slice is the complete vertical for one business capability
|
||||||
|
|
||||||
**Components** (per slice):
|
**Components** (per slice):
|
||||||
|
|
||||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
|
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
|
||||||
as the request contract
|
request contract
|
||||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
|
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
||||||
operation, each containing the Command/Query record, its
|
each containing the Command/Query record, its `IRequestHandler`, and (for
|
||||||
`IRequestHandler`, and (for commands) a FluentValidation validator
|
commands) a FluentValidation validator
|
||||||
- `Repository/`: the slice's own stored-procedure repository
|
- `Repository/`: the slice's own Dapper repository implementation
|
||||||
implementation
|
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
||||||
- `Dtos/`: response shapes returned by query handlers (never the raw
|
entity)
|
||||||
domain entity)
|
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
||||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
|
slice's repository/services
|
||||||
the slice's repository/services
|
|
||||||
|
|
||||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via
|
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||||
MediatR commands sent from other slices, never over HTTP.
|
commands sent from other slices, never over HTTP. `Features.Locations` also has
|
||||||
|
no `Controllers/` folder and no MediatR handlers — it exposes
|
||||||
|
`ILocationRepository` directly as a plain service, currently consumed only by
|
||||||
|
`Database.Seed` to look up cities while seeding brewery locations. Other slices
|
||||||
|
(for example, `Features.Breweries`) do not yet depend on it; they run their own
|
||||||
|
`CityID` existence checks inline.
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
- `Domain.Entities`, `Domain.Exceptions`
|
- `Domain.Entities`, `Domain.Exceptions`
|
||||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
|
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
|
||||||
infrastructure project the slice needs (`Infrastructure.Jwt`/
|
project the slice needs (`Infrastructure.Jwt`/
|
||||||
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
|
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
|
||||||
`Infrastructure.Email.Templates` for Emails)
|
`Infrastructure.Email.Templates` for Emails)
|
||||||
- `Shared.Contracts`, `Shared.Application`
|
- `Shared.Contracts`, `Shared.Application`
|
||||||
@@ -142,29 +147,27 @@ MediatR commands sent from other slices, never over HTTP.
|
|||||||
**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
|
- No direct controller-to-repository calls; everything flows through MediatR
|
||||||
MediatR
|
|
||||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||||
|
|
||||||
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||||
|
|
||||||
**Purpose**: The minimum cross-slice surface area required because every
|
**Purpose**: The minimum cross-slice surface area required because every slice
|
||||||
slice needs it, or because duplicating it four times would be worse than
|
needs it, or because duplicating it four times would be worse than sharing it
|
||||||
sharing it
|
|
||||||
|
|
||||||
**Components**:
|
**Components**:
|
||||||
|
|
||||||
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
||||||
envelope every controller returns
|
envelope every controller returns
|
||||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
|
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
|
||||||
MediatR pipeline behavior that runs FluentValidation before a handler
|
pipeline behavior that runs FluentValidation before a handler executes) and
|
||||||
executes) and the cross-slice email commands
|
the cross-slice email commands (`SendRegistrationEmailCommand`,
|
||||||
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
|
`SendResendConfirmationEmailCommand`)
|
||||||
|
|
||||||
**Rules**:
|
**Rules**:
|
||||||
|
|
||||||
- Kept deliberately small: this is the exception to "no slice depends on
|
- Kept deliberately small: this is the exception to "no slice depends on another
|
||||||
another slice," not a general-purpose dumping ground
|
slice," not a general-purpose dumping ground
|
||||||
|
|
||||||
#### Infrastructure layer
|
#### Infrastructure layer
|
||||||
|
|
||||||
@@ -201,6 +204,14 @@ whichever slices need them
|
|||||||
- `UserAccount` - User profile data
|
- `UserAccount` - User profile data
|
||||||
- `UserCredential` - Authentication credentials
|
- `UserCredential` - Authentication credentials
|
||||||
- `UserVerification` - Account verification state
|
- `UserVerification` - Account verification state
|
||||||
|
- `BreweryPost` - A user-submitted brewery listing
|
||||||
|
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||||
|
- `City` - A city referenced by a brewery location
|
||||||
|
- `StateProvince` - A state/province referenced by a city
|
||||||
|
- `Country` - A country referenced by a state/province
|
||||||
|
- `BeerStyle` - A beer style/category (schema table exists; no repository yet)
|
||||||
|
- `BeerPost` - A beer listing tied to a brewery and style (schema table
|
||||||
|
exists; no repository yet)
|
||||||
|
|
||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
@@ -218,16 +229,15 @@ whichever slices need them
|
|||||||
#### Vertical slice + MediatR
|
#### Vertical slice + MediatR
|
||||||
|
|
||||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||||
everything needed to understand or change one capability lives in one
|
everything needed to understand or change one capability lives in one project
|
||||||
project
|
|
||||||
|
|
||||||
**Implementation**:
|
**Implementation**:
|
||||||
|
|
||||||
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||||
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||||
`Query` (for example, `GetBreweryByIdQuery`)
|
`Query` (for example, `GetBreweryByIdQuery`)
|
||||||
- Controllers bind directly to the Command/Query as the request body;
|
- Controllers bind directly to the Command/Query as the request body; there is
|
||||||
there is no separate request DTO + mapping step for writes
|
no separate request DTO + mapping step for writes
|
||||||
- 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
|
||||||
@@ -250,19 +260,18 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
|
|||||||
|
|
||||||
**Purpose**: Abstract database access behind interfaces
|
**Purpose**: Abstract database access behind interfaces
|
||||||
|
|
||||||
**Implementation**: each slice owns its own repository, scoped to that
|
**Implementation**: each slice owns its own repository, scoped to that feature
|
||||||
feature only:
|
only:
|
||||||
|
|
||||||
- `Features.Auth/Repository/IAuthRepository.cs`
|
- `Features.Auth/Repository/IAuthRepository.cs`
|
||||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
||||||
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
|
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
|
||||||
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
|
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
|
||||||
connection factory every slice's repository builds on
|
factory every slice's repository builds on
|
||||||
|
|
||||||
**Benefits**:
|
**Benefits**:
|
||||||
|
|
||||||
- Testable (easy to mock)
|
- Testable (easy to mock)
|
||||||
- SQL-first approach (stored procedures)
|
|
||||||
- Each slice's data access logic is self-contained
|
- Each slice's data access logic is self-contained
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
@@ -279,9 +288,9 @@ public interface IAuthRepository
|
|||||||
|
|
||||||
**Purpose**: Loose coupling and testability
|
**Purpose**: Loose coupling and testability
|
||||||
|
|
||||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
|
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
|
||||||
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
|
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
|
||||||
extension method that registers its repository and slice-internal services
|
method that registers its repository and slice-internal services
|
||||||
|
|
||||||
**Lifetimes**:
|
**Lifetimes**:
|
||||||
|
|
||||||
@@ -289,22 +298,32 @@ extension method that registers its repository and slice-internal services
|
|||||||
- Singleton: `ISqlConnectionFactory`
|
- Singleton: `ISqlConnectionFactory`
|
||||||
- Transient: Utilities, helpers
|
- Transient: Utilities, helpers
|
||||||
|
|
||||||
#### SQL-first approach
|
#### Direct SQL via Dapper/ADO.NET
|
||||||
|
|
||||||
**Purpose**: Push complex logic into the database
|
**Purpose**: Keep data access explicit and colocated with the slice that owns
|
||||||
|
it, without an ORM's change-tracking or mapping magic
|
||||||
|
|
||||||
**Strategy**:
|
**Strategy**:
|
||||||
|
|
||||||
- All queries via stored procedures
|
- No ORM (Entity Framework not used); each repository issues inline SQL
|
||||||
- No ORM (Entity Framework not used)
|
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
||||||
- Database handles complex logic
|
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
||||||
- Application focuses on orchestration
|
can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
|
||||||
|
Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind
|
||||||
|
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||||
|
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||||
|
repository method, run inside the same transaction as the write
|
||||||
|
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
|
||||||
|
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
|
||||||
|
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||||
|
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||||
|
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||||
|
exception types to HTTP status codes
|
||||||
|
- Application focuses on orchestration; the database enforces integrity via
|
||||||
|
keys, `CHECK` constraints, and cascades
|
||||||
|
|
||||||
**Stored Procedure Examples**:
|
See [Database](website/database.md) for the schema and the app's current SQL
|
||||||
|
error-handling approach in more detail.
|
||||||
- `USP_RegisterUser` - User registration
|
|
||||||
- `USP_GetUserAccountByUsername` - User lookup
|
|
||||||
- `USP_RotateUserCredential` - Password update
|
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -351,8 +370,8 @@ All component styling should prefer semantic tokens such as `primary`,
|
|||||||
### Legacy frontend
|
### Legacy frontend
|
||||||
|
|
||||||
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
||||||
for reference only. Active product and engineering documentation should point
|
for reference only. Active product and engineering documentation should point to
|
||||||
to `web/frontend`.
|
`web/frontend`.
|
||||||
|
|
||||||
## Security architecture
|
## Security architecture
|
||||||
|
|
||||||
@@ -361,18 +380,29 @@ to `web/frontend`.
|
|||||||
1. **Registration**:
|
1. **Registration**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password hashed with Argon2id
|
- Password hashed with Argon2id
|
||||||
- User account created
|
- User account created (unverified) and a confirmation email is dispatched
|
||||||
- JWT token issued
|
via `Features.Emails`
|
||||||
|
|
||||||
2. **Login**:
|
2. **Email confirmation**:
|
||||||
|
- User follows the confirmation link/token from the email
|
||||||
|
- `Features.Auth`'s `ConfirmUser` command marks the account verified
|
||||||
|
|
||||||
|
3. **Login**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
- Password verified against hash
|
- Password verified against hash
|
||||||
- JWT token issued
|
- Access and refresh JWTs are issued; the refresh token is stored server-side
|
||||||
- Token stored client-side
|
against the user's credential row
|
||||||
|
|
||||||
3. **API Requests**:
|
4. **Token refresh**:
|
||||||
- Client sends JWT in Authorization header
|
- Client exchanges a valid, unexpired refresh token for a new access/refresh
|
||||||
- Middleware validates token
|
pair via `Features.Auth`'s `RefreshToken` command
|
||||||
|
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
|
||||||
|
refresh token is not invalidated and remains usable until its own
|
||||||
|
expiration
|
||||||
|
|
||||||
|
5. **API Requests**:
|
||||||
|
- Client sends the access JWT in the `Authorization` header
|
||||||
|
- `JwtAuthenticationHandler` validates the token
|
||||||
- Request proceeds if valid
|
- Request proceeds if valid
|
||||||
|
|
||||||
### Password security
|
### Password security
|
||||||
@@ -411,19 +441,22 @@ to `web/frontend`.
|
|||||||
|
|
||||||
## Database architecture
|
## Database architecture
|
||||||
|
|
||||||
|
For the table-by-table schema and the custom SQL error code scheme, see
|
||||||
|
[Database](website/database.md).
|
||||||
|
|
||||||
### SQL-first philosophy
|
### SQL-first philosophy
|
||||||
|
|
||||||
**Principles**:
|
**Principles**:
|
||||||
|
|
||||||
1. Database is source of truth
|
1. Database is source of truth
|
||||||
2. Complex queries in stored procedures
|
2. Queries live in the owning repository, not scattered across the app
|
||||||
3. Database handles referential integrity
|
3. Database handles referential integrity
|
||||||
4. Application orchestrates, database executes
|
4. Application orchestrates, database executes
|
||||||
|
|
||||||
**Benefits**:
|
**Benefits**:
|
||||||
|
|
||||||
- Performance optimization via execution plans
|
- Performance optimization via execution plans
|
||||||
- Centralized query logic
|
- Query logic scoped to the repository that owns it, not spread across layers
|
||||||
- Version-controlled schema (migrations)
|
- Version-controlled schema (migrations)
|
||||||
- Easier query profiling and tuning
|
- Easier query profiling and tuning
|
||||||
|
|
||||||
@@ -433,32 +466,41 @@ to `web/frontend`.
|
|||||||
|
|
||||||
**Process**:
|
**Process**:
|
||||||
|
|
||||||
1. Write SQL migration script
|
1. Write a SQL script under `Database.Migrations/scripts/`
|
||||||
2. Embed in `Database.Migrations` project
|
2. Embed it in the `Database.Migrations` project
|
||||||
3. Run migrations on startup
|
3. `Database.Migrations` runs each script against the target database on
|
||||||
4. Idempotent and versioned
|
startup, tracked so a script never re-runs once applied
|
||||||
|
|
||||||
**Migration Files**:
|
**Migration Files**:
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/
|
scripts/
|
||||||
├── 001-CreateUserTables.sql
|
└── 01-schema/
|
||||||
├── 002-CreateLocationTables.sql
|
└── schema.sql # full table/index/constraint definitions
|
||||||
├── 003-CreateBreweryTables.sql
|
|
||||||
└── ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Currently the whole schema is a single versioned script. As the schema evolves,
|
||||||
|
new numbered scripts get added alongside it rather than editing `schema.sql` in
|
||||||
|
place, so DbUp's already-applied tracking stays valid.
|
||||||
|
|
||||||
### Data seeding
|
### Data seeding
|
||||||
|
|
||||||
**Purpose**: Populate development/test databases
|
**Purpose**: Populate development/test databases with realistic data
|
||||||
|
|
||||||
**Implementation**: `Database.Seed` project
|
**Implementation**: `Database.Seed` project, using data produced by the C++
|
||||||
|
pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
||||||
|
|
||||||
**Seed Data**:
|
**Seed Data**:
|
||||||
|
|
||||||
- Countries, states/provinces, cities
|
- Countries, state/provinces, and cities (via `Features.Locations`'
|
||||||
- Test user accounts
|
`ILocationRepository`, which creates `Country`/`StateProvince` rows on
|
||||||
- Sample breweries (future)
|
demand while resolving a city)
|
||||||
|
- User accounts (via `Features.Auth`'s repository)
|
||||||
|
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||||
|
|
||||||
|
Tables that exist in the schema but have no repository yet (`Photo`,
|
||||||
|
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
|
||||||
|
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
|
||||||
|
|
||||||
## Deployment architecture
|
## Deployment architecture
|
||||||
|
|
||||||
@@ -470,6 +512,7 @@ scripts/
|
|||||||
- `database.migrations` - Schema migration runner
|
- `database.migrations` - Schema migration runner
|
||||||
- `database.seed` - Data seeder
|
- `database.seed` - Data seeder
|
||||||
- `api.core` - ASP.NET Core Web API
|
- `api.core` - ASP.NET Core Web API
|
||||||
|
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
|
||||||
|
|
||||||
**Environments**:
|
**Environments**:
|
||||||
|
|
||||||
@@ -505,8 +548,8 @@ healthcheck:
|
|||||||
├──────────────┤
|
├──────────────┤
|
||||||
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
|
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
|
||||||
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
|
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests
|
||||||
│ handlers + │ (commands/queries/handlers + that slice's own
|
│ handler │ (commands/queries/handlers, with repository/service
|
||||||
│ repository) │ repository, mocked with Moq/DbMocker)
|
│ tests) │ dependencies mocked via Moq)
|
||||||
└──────────────┘
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ consumers are informed of the AI-generated origin before engagement.
|
|||||||
|
|
||||||
When forced by the system prompt to generate a "highly specialized technical
|
When forced by the system prompt to generate a "highly specialized technical
|
||||||
brewing detail," the model frequently hallucinates fermentation science and
|
brewing detail," the model frequently hallucinates fermentation science and
|
||||||
brewing chemistry. While the resulting text sounds confident, it is
|
brewing chemistry. While the resulting text sounds confident, it is nonsensical
|
||||||
nonsensical to a reader with brewing or scientific expertise.
|
to a reader with brewing or scientific expertise.
|
||||||
|
|
||||||
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
||||||
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
||||||
@@ -262,8 +262,8 @@ idiomatic phrasing.
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
When fed into the pipeline, this dataset often causes the model to reason that
|
When fed into the pipeline, this dataset often causes the model to reason that a
|
||||||
a local variant of French is needed, but it often defaults to a standardized
|
local variant of French is needed, but it often defaults to a standardized
|
||||||
dialect of French, without cultural or linguistic nuance.
|
dialect of French, without cultural or linguistic nuance.
|
||||||
|
|
||||||
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
||||||
@@ -312,10 +312,10 @@ not a source of truth.
|
|||||||
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
||||||
|
|
||||||
[^llm-bias]:
|
[^llm-bias]:
|
||||||
For example, Blasi et al. (2022), "Systematic Inequalities in Language Technology
|
For example, Blasi et al. (2022), "Systematic Inequalities in Language
|
||||||
Performance across the World's Languages," _ACL Anthology_. The pattern is
|
Technology Performance across the World's Languages," _ACL Anthology_. The
|
||||||
consistent with models trained predominantly on English-language web
|
pattern is consistent with models trained predominantly on English-language
|
||||||
corpora.
|
web corpora.
|
||||||
|
|
||||||
[^wp-license]:
|
[^wp-license]:
|
||||||
Source:
|
Source:
|
||||||
|
|||||||
@@ -101,21 +101,21 @@ Each run writes a fresh dated SQLite file such as
|
|||||||
|
|
||||||
#### CLI flags
|
#### CLI flags
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `--mocked` | Deterministic mock generator, no model required. |
|
| `--mocked` | Deterministic mock generator, no model required. |
|
||||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||||
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
||||||
| `--help, -h` | Print usage and exit. |
|
| `--help, -h` | Print usage and exit. |
|
||||||
|
|
||||||
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
|
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
|
||||||
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
||||||
|
|||||||
79
docs/website/database.md
Normal file
79
docs/website/database.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Database
|
||||||
|
|
||||||
|
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
|
||||||
|
convention. For the vertical-slice code that talks to this schema, see
|
||||||
|
[Architecture](../architecture.md).
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
The full schema lives in one script:
|
||||||
|
`web/backend/Database/Database.Migrations/scripts/01-schema/schema.sql`, run by
|
||||||
|
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
|
||||||
|
owning feature slice's repository (see
|
||||||
|
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||||
|
|
||||||
|
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||||
|
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
|
||||||
|
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||||
|
|
||||||
|
### Tables backed by the API today
|
||||||
|
|
||||||
|
| Table | Backed by | Notes |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
|
||||||
|
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||||
|
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||||
|
| `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 |
|
||||||
|
| `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` |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### Tables defined in the schema but not yet wired to a feature slice
|
||||||
|
|
||||||
|
These exist so the schema can support planned functionality, but have no
|
||||||
|
repository or seed data yet:
|
||||||
|
|
||||||
|
| Table | Purpose (from schema) |
|
||||||
|
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Photo` | A photo uploaded by a user account |
|
||||||
|
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
||||||
|
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||||
|
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
|
||||||
|
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
|
||||||
|
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||||
|
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||||
|
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
**Tool**: DbUp, run by the `Database.Migrations` project/container.
|
||||||
|
|
||||||
|
Scripts live under `scripts/`, applied in order and tracked so a script never
|
||||||
|
re-runs once it has succeeded. Today that's a single `01-schema/schema.sql`.
|
||||||
|
When the schema needs to change, add a new numbered script rather than editing
|
||||||
|
`schema.sql` in place — DbUp's tracking is per-script, so editing an
|
||||||
|
already-applied script means it won't re-run against existing databases.
|
||||||
|
|
||||||
|
## Data seeding
|
||||||
|
|
||||||
|
`Database.Seed` populates a target database from data produced by the C++
|
||||||
|
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
|
||||||
|
state/provinces, and cities (created on demand while resolving each brewery's
|
||||||
|
location), user accounts, and brewery posts with locations. It only seeds
|
||||||
|
tables that have a feature slice to write through (see the table above).
|
||||||
|
|
||||||
|
## Database error handling
|
||||||
|
|
||||||
|
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||||
|
rows against the `RowVersion` column.
|
||||||
|
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||||
|
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||||
|
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||||
|
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
|
||||||
|
constraint-violation failure) maps to 503.
|
||||||
|
|
||||||
|
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
|
||||||
|
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
|
||||||
|
error 2601 or 2627) when two requests try to verify the same account
|
||||||
|
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,18 +23,26 @@ package "Feature Slices" #F3E5F5 {
|
|||||||
[Features.Breweries] as BrewerySlice
|
[Features.Breweries] as BrewerySlice
|
||||||
[Features.UserManagement] as UserSlice
|
[Features.UserManagement] as UserSlice
|
||||||
[Features.Emails] as EmailsSlice
|
[Features.Emails] as EmailsSlice
|
||||||
|
[Features.Locations] as LocationsSlice
|
||||||
note right of AuthSlice
|
note right of AuthSlice
|
||||||
Each slice owns its own:
|
Each slice owns its own:
|
||||||
- Controller (HTTP endpoints)
|
- Controller (HTTP endpoints)
|
||||||
- Commands/Queries + Handlers
|
- Commands/Queries + Handlers
|
||||||
- Validators
|
- Validators
|
||||||
- Repository
|
- Repository (Dapper)
|
||||||
end note
|
end note
|
||||||
note right of EmailsSlice
|
note right of EmailsSlice
|
||||||
No controller - invoked only
|
No controller - invoked only
|
||||||
via MediatR commands sent
|
via MediatR commands sent
|
||||||
from other slices
|
from other slices
|
||||||
end note
|
end note
|
||||||
|
note right of LocationsSlice
|
||||||
|
No controller and no MediatR
|
||||||
|
handlers - exposes ILocationRepository
|
||||||
|
directly as a plain DI service.
|
||||||
|
Not registered by API.Core at all;
|
||||||
|
only Database.Seed depends on it today
|
||||||
|
end note
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Shared" #FCE4EC {
|
package "Shared" #FCE4EC {
|
||||||
@@ -61,24 +69,30 @@ package "Domain Layer" #E8F5E9 {
|
|||||||
[Domain.Entities] as Domain
|
[Domain.Entities] as Domain
|
||||||
[Domain.Exceptions] as DomainExceptions
|
[Domain.Exceptions] as DomainExceptions
|
||||||
note right of Domain
|
note right of Domain
|
||||||
- UserAccount
|
- UserAccount, UserCredential, UserVerification
|
||||||
- UserCredential
|
- BreweryPost, BreweryPostLocation
|
||||||
- UserVerification
|
- City, StateProvince, Country
|
||||||
- BreweryPost
|
- BeerPost, BeerStyle
|
||||||
|
(schema tables exist; no repository yet)
|
||||||
end note
|
end note
|
||||||
}
|
}
|
||||||
|
|
||||||
database "SQL Server" {
|
database "SQL Server" {
|
||||||
[Stored Procedures] as SP
|
|
||||||
[Tables] as Tables
|
[Tables] as Tables
|
||||||
}
|
}
|
||||||
|
|
||||||
|
node "Database.Seed\n(separate console app)" as Seed
|
||||||
|
|
||||||
' Relationships
|
' Relationships
|
||||||
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
|
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
|
||||||
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
|
||||||
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
|
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
|
||||||
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
API ..> EmailsSlice : MediatR scan only\n(no controller)
|
||||||
|
|
||||||
|
Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR)
|
||||||
|
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||||
|
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||||
|
|
||||||
AuthSlice --> Sql
|
AuthSlice --> Sql
|
||||||
AuthSlice --> JWT
|
AuthSlice --> JWT
|
||||||
AuthSlice --> PwdHash
|
AuthSlice --> PwdHash
|
||||||
@@ -93,6 +107,8 @@ UserSlice --> Sql
|
|||||||
UserSlice --> SharedContracts
|
UserSlice --> SharedContracts
|
||||||
UserSlice --> SharedApp
|
UserSlice --> SharedApp
|
||||||
|
|
||||||
|
LocationsSlice --> Sql
|
||||||
|
|
||||||
EmailsSlice --> Email
|
EmailsSlice --> Email
|
||||||
EmailsSlice --> EmailTemplates
|
EmailsSlice --> EmailTemplates
|
||||||
EmailsSlice --> SharedApp
|
EmailsSlice --> SharedApp
|
||||||
@@ -101,20 +117,25 @@ EmailsSlice --> SharedApp
|
|||||||
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
||||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||||
|
|
||||||
Sql --> SP
|
|
||||||
Sql --> Domain
|
Sql --> Domain
|
||||||
SP --> Tables
|
|
||||||
|
AuthSlice --> Tables : Dapper SQL
|
||||||
|
BrewerySlice --> Tables : Dapper SQL
|
||||||
|
UserSlice --> Tables : Dapper SQL
|
||||||
|
LocationsSlice --> Tables : Dapper SQL
|
||||||
|
|
||||||
AuthSlice --> Domain
|
AuthSlice --> Domain
|
||||||
BrewerySlice --> Domain
|
BrewerySlice --> Domain
|
||||||
UserSlice --> Domain
|
UserSlice --> Domain
|
||||||
|
LocationsSlice --> Domain
|
||||||
AuthSlice --> DomainExceptions
|
AuthSlice --> DomainExceptions
|
||||||
|
|
||||||
' Notes
|
' Notes
|
||||||
note left of Sql
|
note left of Sql
|
||||||
SQL-first approach
|
SQL-first approach
|
||||||
All queries via
|
Each slice's own repository issues
|
||||||
stored procedures
|
inline SQL via Dapper - no ORM,
|
||||||
|
no stored procedures
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of AuthSlice
|
note bottom of AuthSlice
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ title User Authentication Flow - Expanded
|
|||||||
|
|
||||||
actor User
|
actor User
|
||||||
participant "API\nController" as API
|
participant "API\nController" as API
|
||||||
box "Service Layer" #LightBlue
|
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||||
participant "RegisterService" as RegSvc
|
box "Handler Layer" #LightBlue
|
||||||
participant "LoginService" as LoginSvc
|
participant "RegisterUserHandler" as RegHandler
|
||||||
|
participant "LoginHandler" as LoginHandler
|
||||||
participant "TokenService" as TokenSvc
|
participant "TokenService" as TokenSvc
|
||||||
participant "EmailService" as EmailSvc
|
|
||||||
end box
|
end box
|
||||||
box "Infrastructure Layer" #LightGreen
|
box "Infrastructure Layer" #LightGreen
|
||||||
participant "Argon2\nInfrastructure" as Argon2
|
participant "Argon2\nInfrastructure" as Argon2
|
||||||
@@ -21,55 +21,59 @@ participant "JWT\nInfrastructure" as JWT
|
|||||||
participant "Email\nProvider" as SMTP
|
participant "Email\nProvider" as SMTP
|
||||||
participant "Template\nProvider" as Template
|
participant "Template\nProvider" as Template
|
||||||
end box
|
end box
|
||||||
box "Repository Layer" #LightYellow
|
box "Repository / Cross-slice" #LightYellow
|
||||||
participant "AuthRepository" as AuthRepo
|
participant "AuthRepository" as AuthRepo
|
||||||
participant "UserAccount\nRepository" as UserRepo
|
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||||
end box
|
end box
|
||||||
database "SQL Server\nStored Procedures" as DB
|
database "SQL Server" as DB
|
||||||
|
|
||||||
== Registration Flow ==
|
== Registration Flow ==
|
||||||
|
|
||||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||||
activate API
|
activate API
|
||||||
|
|
||||||
note right of API
|
API -> Mediator: Send(RegisterUserCommand)
|
||||||
FluentValidation runs:
|
activate Mediator
|
||||||
|
|
||||||
|
note right of Mediator
|
||||||
|
ValidationBehavior runs RegisterUserValidator
|
||||||
|
(FluentValidation) before the handler:
|
||||||
- Username: 3-64 chars, alphanumeric + [._-]
|
- Username: 3-64 chars, alphanumeric + [._-]
|
||||||
- Email: valid format, max 128 chars
|
- Email: valid format, max 128 chars
|
||||||
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
||||||
- DateOfBirth: must be 19+ years old
|
- DateOfBirth: must be 19+ years old
|
||||||
end note
|
end note
|
||||||
|
|
||||||
API -> API: Validate request\n(FluentValidation)
|
|
||||||
|
|
||||||
alt Validation fails
|
alt Validation fails
|
||||||
API -> User: 400 Bad Request\n{errors: {...}}
|
Mediator -> API: throws FluentValidation.ValidationException
|
||||||
|
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||||
else Validation succeeds
|
else Validation succeeds
|
||||||
API -> RegSvc: RegisterAsync(userAccount, password)
|
Mediator -> RegHandler: Handle(command)
|
||||||
activate RegSvc
|
activate RegHandler
|
||||||
|
|
||||||
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
||||||
activate AuthRepo
|
activate AuthRepo
|
||||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||||
activate DB
|
activate DB
|
||||||
DB --> AuthRepo: null (user doesn't exist)
|
DB --> AuthRepo: null (user doesn't exist)
|
||||||
deactivate DB
|
deactivate DB
|
||||||
deactivate AuthRepo
|
deactivate AuthRepo
|
||||||
|
|
||||||
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
|
RegHandler -> AuthRepo: GetUserByEmailAsync(email)
|
||||||
activate AuthRepo
|
activate AuthRepo
|
||||||
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
|
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
|
||||||
activate DB
|
activate DB
|
||||||
DB --> AuthRepo: null (email doesn't exist)
|
DB --> AuthRepo: null (email doesn't exist)
|
||||||
deactivate DB
|
deactivate DB
|
||||||
deactivate AuthRepo
|
deactivate AuthRepo
|
||||||
|
|
||||||
alt User/Email already exists
|
alt Username or email already exists
|
||||||
RegSvc -> API: throw ConflictException
|
RegHandler -> Mediator: throw ConflictException
|
||||||
|
Mediator -> API: propagate
|
||||||
API -> User: 409 Conflict\n"Username or email already exists"
|
API -> User: 409 Conflict\n"Username or email already exists"
|
||||||
else User doesn't exist
|
else Username and email are free
|
||||||
|
|
||||||
RegSvc -> Argon2: Hash(password)
|
RegHandler -> Argon2: Hash(password)
|
||||||
activate Argon2
|
activate Argon2
|
||||||
note right of Argon2
|
note right of Argon2
|
||||||
Argon2id parameters:
|
Argon2id parameters:
|
||||||
@@ -81,33 +85,39 @@ else Validation succeeds
|
|||||||
end note
|
end note
|
||||||
Argon2 -> Argon2: Generate random salt\n(16 bytes)
|
Argon2 -> Argon2: Generate random salt\n(16 bytes)
|
||||||
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
|
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
|
||||||
Argon2 --> RegSvc: "base64(salt):base64(hash)"
|
Argon2 --> RegHandler: "base64(salt):base64(hash)"
|
||||||
deactivate Argon2
|
deactivate Argon2
|
||||||
|
|
||||||
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
|
RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
|
||||||
activate AuthRepo
|
activate AuthRepo
|
||||||
|
|
||||||
AuthRepo -> DB: EXEC USP_RegisterUser
|
AuthRepo -> DB: BEGIN TRANSACTION
|
||||||
|
activate DB
|
||||||
|
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||||
|
DB --> AuthRepo: UserAccountId (GUID)
|
||||||
|
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||||
|
AuthRepo -> DB: COMMIT TRANSACTION
|
||||||
|
deactivate DB
|
||||||
|
note right of AuthRepo
|
||||||
|
All three statements run via Dapper's
|
||||||
|
ExecuteScalarAsync/ExecuteAsync against
|
||||||
|
a single DbTransaction, not a stored
|
||||||
|
procedure - no manual parameter binding
|
||||||
|
end note
|
||||||
|
|
||||||
|
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
|
||||||
activate DB
|
activate DB
|
||||||
note right of DB
|
note right of DB
|
||||||
Transaction begins:
|
Round trip to load the row the
|
||||||
1. INSERT UserAccount
|
INSERT just created
|
||||||
2. INSERT UserCredential
|
|
||||||
(with hashed password)
|
|
||||||
Transaction commits
|
|
||||||
end note
|
end note
|
||||||
DB -> DB: BEGIN TRANSACTION
|
DB --> AuthRepo: UserAccount row
|
||||||
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
|
|
||||||
DB -> DB: OUTPUT INSERTED.UserAccountID
|
|
||||||
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
|
|
||||||
DB -> DB: COMMIT TRANSACTION
|
|
||||||
DB --> AuthRepo: UserAccountId (GUID)
|
|
||||||
deactivate DB
|
deactivate DB
|
||||||
|
|
||||||
AuthRepo --> RegSvc: UserAccount entity
|
AuthRepo --> RegHandler: UserAccount entity
|
||||||
deactivate AuthRepo
|
deactivate AuthRepo
|
||||||
|
|
||||||
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
|
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
||||||
activate TokenSvc
|
activate TokenSvc
|
||||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||||
activate JWT
|
activate JWT
|
||||||
@@ -118,33 +128,55 @@ else Validation succeeds
|
|||||||
- Claims:
|
- Claims:
|
||||||
* sub: userId
|
* sub: userId
|
||||||
* unique_name: username
|
* unique_name: username
|
||||||
* jti: unique token ID
|
* iat / jti
|
||||||
end note
|
end note
|
||||||
JWT -> JWT: Create JWT with claims
|
JWT -> JWT: Create JWT with claims
|
||||||
JWT -> JWT: Sign with secret key
|
JWT -> JWT: Sign with secret key
|
||||||
JWT --> TokenSvc: Access Token
|
JWT --> TokenSvc: Access Token
|
||||||
deactivate JWT
|
deactivate JWT
|
||||||
TokenSvc --> RegSvc: Access Token
|
TokenSvc --> RegHandler: Access Token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
|
RegHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||||
activate TokenSvc
|
activate TokenSvc
|
||||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||||
activate JWT
|
activate JWT
|
||||||
note right of JWT
|
note right of JWT
|
||||||
Refresh Token:
|
Refresh Token:
|
||||||
- Expires: 21 days
|
- Expires: 21 days (504 hours)
|
||||||
- Same structure as access token
|
- Same structure as access token,\n signed with a separate secret
|
||||||
end note
|
end note
|
||||||
JWT --> TokenSvc: Refresh Token
|
JWT --> TokenSvc: Refresh Token
|
||||||
deactivate JWT
|
deactivate JWT
|
||||||
TokenSvc --> RegSvc: Refresh Token
|
TokenSvc --> RegHandler: Refresh Token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
|
RegHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||||
activate EmailSvc
|
activate TokenSvc
|
||||||
|
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
note right of JWT
|
||||||
|
Confirmation Token:
|
||||||
|
- Expires: 30 minutes (0.5 hours)
|
||||||
|
- Signed with a third, separate secret
|
||||||
|
end note
|
||||||
|
JWT --> TokenSvc: Confirmation Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc --> RegHandler: Confirmation Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
EmailSvc -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
|
||||||
|
activate Mediator
|
||||||
|
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
|
||||||
|
note right of Mediator
|
||||||
|
Routed to Features.Emails'
|
||||||
|
SendRegistrationEmailHandler,
|
||||||
|
the only cross-slice call in
|
||||||
|
the backend
|
||||||
|
end note
|
||||||
|
activate EmailDispatcher
|
||||||
|
|
||||||
|
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||||
activate Template
|
activate Template
|
||||||
note right of Template
|
note right of Template
|
||||||
Razor Component:
|
Razor Component:
|
||||||
@@ -154,38 +186,37 @@ else Validation succeeds
|
|||||||
- Footer
|
- Footer
|
||||||
end note
|
end note
|
||||||
Template -> Template: Render Razor component\nto HTML
|
Template -> Template: Render Razor component\nto HTML
|
||||||
Template --> EmailSvc: HTML email content
|
Template --> EmailDispatcher: HTML email content
|
||||||
deactivate Template
|
deactivate Template
|
||||||
|
|
||||||
EmailSvc -> SMTP: SendAsync(email, subject, body)
|
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||||
activate SMTP
|
activate SMTP
|
||||||
note right of SMTP
|
note right of SMTP
|
||||||
SMTP Configuration:
|
SMTP Configuration (MailKit):
|
||||||
- Host: from env (SMTP_HOST)
|
- Host/port: from env (SMTP_HOST/SMTP_PORT)
|
||||||
- Port: from env (SMTP_PORT)
|
- TLS: StartTLS when SMTP_USE_SSL=true
|
||||||
- TLS: StartTLS
|
- Auth: username/password, if configured
|
||||||
- Auth: username/password
|
|
||||||
end note
|
end note
|
||||||
SMTP -> SMTP: Create MIME message
|
SMTP -> SMTP: Build MIME message
|
||||||
SMTP -> SMTP: Connect to SMTP server
|
SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
|
||||||
SMTP -> SMTP: Authenticate
|
SMTP --> EmailDispatcher: Success / throws
|
||||||
SMTP -> SMTP: Send email
|
|
||||||
SMTP -> SMTP: Disconnect
|
|
||||||
SMTP --> EmailSvc: Success / Failure
|
|
||||||
deactivate SMTP
|
deactivate SMTP
|
||||||
|
|
||||||
|
deactivate EmailDispatcher
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
alt Email sent successfully
|
alt Email sent successfully
|
||||||
EmailSvc --> RegSvc: emailSent = true
|
RegHandler -> RegHandler: emailSent = true
|
||||||
else Email failed
|
else mediator.Send throws
|
||||||
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
|
RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
|
||||||
end
|
end
|
||||||
deactivate EmailSvc
|
|
||||||
|
|
||||||
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
|
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
|
||||||
deactivate RegSvc
|
deactivate RegHandler
|
||||||
|
Mediator --> API: RegistrationPayload
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
API -> API: Create response body
|
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
|
||||||
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
deactivate API
|
deactivate API
|
||||||
@@ -195,88 +226,94 @@ deactivate API
|
|||||||
User -> API: POST /api/auth/login\n{username, password}
|
User -> API: POST /api/auth/login\n{username, password}
|
||||||
activate API
|
activate API
|
||||||
|
|
||||||
API -> API: Validate request\n(FluentValidation)
|
API -> Mediator: Send(LoginCommand)
|
||||||
|
activate Mediator
|
||||||
|
|
||||||
|
note right of Mediator
|
||||||
|
ValidationBehavior runs LoginValidator:
|
||||||
|
username and password must both be non-empty
|
||||||
|
end note
|
||||||
|
|
||||||
alt Validation fails
|
alt Validation fails
|
||||||
API -> User: 400 Bad Request\n{errors: {...}}
|
Mediator -> API: throws FluentValidation.ValidationException
|
||||||
|
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||||
else Validation succeeds
|
else Validation succeeds
|
||||||
|
|
||||||
API -> LoginSvc: LoginAsync(username, password)
|
Mediator -> LoginHandler: Handle(command)
|
||||||
activate LoginSvc
|
activate LoginHandler
|
||||||
|
|
||||||
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
||||||
activate AuthRepo
|
activate AuthRepo
|
||||||
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||||
activate DB
|
activate DB
|
||||||
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
|
DB --> AuthRepo: UserAccount row or null
|
||||||
DB --> AuthRepo: UserAccount entity
|
|
||||||
deactivate DB
|
deactivate DB
|
||||||
deactivate AuthRepo
|
deactivate AuthRepo
|
||||||
|
|
||||||
alt User not found
|
alt User not found
|
||||||
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
|
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
||||||
|
Mediator -> API: propagate
|
||||||
API -> User: 401 Unauthorized
|
API -> User: 401 Unauthorized
|
||||||
else User found
|
else User found
|
||||||
|
|
||||||
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
||||||
activate AuthRepo
|
activate AuthRepo
|
||||||
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
|
AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
|
||||||
activate DB
|
activate DB
|
||||||
note right of DB
|
DB --> AuthRepo: UserCredential row or null
|
||||||
SELECT FROM UserCredential
|
|
||||||
WHERE UserAccountId = @UserAccountId
|
|
||||||
AND IsRevoked = 0
|
|
||||||
end note
|
|
||||||
DB --> AuthRepo: UserCredential entity
|
|
||||||
deactivate DB
|
deactivate DB
|
||||||
deactivate AuthRepo
|
deactivate AuthRepo
|
||||||
|
|
||||||
alt No active credential
|
alt No active credential
|
||||||
LoginSvc -> API: throw UnauthorizedException
|
LoginHandler -> Mediator: throw UnauthorizedException
|
||||||
|
Mediator -> API: propagate
|
||||||
API -> User: 401 Unauthorized
|
API -> User: 401 Unauthorized
|
||||||
else Active credential found
|
else Active credential found
|
||||||
|
|
||||||
LoginSvc -> Argon2: Verify(password, storedHash)
|
LoginHandler -> Argon2: Verify(password, storedHash)
|
||||||
activate Argon2
|
activate Argon2
|
||||||
note right of Argon2
|
note right of Argon2
|
||||||
1. Split stored hash: "salt:hash"
|
1. Split stored hash: "salt:hash"
|
||||||
2. Extract salt
|
2. Extract salt
|
||||||
3. Hash provided password\n with same salt
|
3. Hash provided password\n with same salt
|
||||||
4. Constant-time comparison
|
4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||||
end note
|
end note
|
||||||
Argon2 -> Argon2: Parse salt from stored hash
|
Argon2 -> Argon2: Parse salt from stored hash
|
||||||
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
||||||
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
||||||
Argon2 --> LoginSvc: true/false
|
Argon2 --> LoginHandler: true/false
|
||||||
deactivate Argon2
|
deactivate Argon2
|
||||||
|
|
||||||
alt Password invalid
|
alt Password invalid
|
||||||
LoginSvc -> API: throw UnauthorizedException
|
LoginHandler -> Mediator: throw UnauthorizedException
|
||||||
|
Mediator -> API: propagate
|
||||||
API -> User: 401 Unauthorized
|
API -> User: 401 Unauthorized
|
||||||
else Password valid
|
else Password valid
|
||||||
|
|
||||||
LoginSvc -> TokenSvc: GenerateAccessToken(user)
|
LoginHandler -> TokenSvc: GenerateAccessToken(user)
|
||||||
activate TokenSvc
|
activate TokenSvc
|
||||||
TokenSvc -> JWT: GenerateJwt(...)
|
TokenSvc -> JWT: GenerateJwt(...)
|
||||||
activate JWT
|
activate JWT
|
||||||
JWT --> TokenSvc: Access Token
|
JWT --> TokenSvc: Access Token
|
||||||
deactivate JWT
|
deactivate JWT
|
||||||
TokenSvc --> LoginSvc: Access Token
|
TokenSvc --> LoginHandler: Access Token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
|
LoginHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||||
activate TokenSvc
|
activate TokenSvc
|
||||||
TokenSvc -> JWT: GenerateJwt(...)
|
TokenSvc -> JWT: GenerateJwt(...)
|
||||||
activate JWT
|
activate JWT
|
||||||
JWT --> TokenSvc: Refresh Token
|
JWT --> TokenSvc: Refresh Token
|
||||||
deactivate JWT
|
deactivate JWT
|
||||||
TokenSvc --> LoginSvc: Refresh Token
|
TokenSvc --> LoginHandler: Refresh Token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
|
LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
|
||||||
deactivate LoginSvc
|
deactivate LoginHandler
|
||||||
|
Mediator --> API: LoginPayload
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
|
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -286,13 +323,15 @@ deactivate API
|
|||||||
== Error Handling (Global Exception Filter) ==
|
== Error Handling (Global Exception Filter) ==
|
||||||
|
|
||||||
note over API
|
note over API
|
||||||
GlobalExceptionFilter catches:
|
API.Core's GlobalExceptionFilter catches:
|
||||||
- ValidationException → 400 Bad Request
|
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
|
||||||
- ConflictException → 409 Conflict
|
- ConflictException -> 409 Conflict
|
||||||
- NotFoundException → 404 Not Found
|
- NotFoundException -> 404 Not Found
|
||||||
- UnauthorizedException → 401 Unauthorized
|
- UnauthorizedException -> 401 Unauthorized
|
||||||
- ForbiddenException → 403 Forbidden
|
- ForbiddenException -> 403 Forbidden
|
||||||
- All others → 500 Internal Server Error
|
- Domain.Exceptions.ValidationException -> 400 Bad Request
|
||||||
|
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
|
||||||
|
- All others -> 500 Internal Server Error
|
||||||
end note
|
end note
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -3,102 +3,205 @@
|
|||||||
skinparam backgroundColor #FFFFFF
|
skinparam backgroundColor #FFFFFF
|
||||||
skinparam defaultFontName Arial
|
skinparam defaultFontName Arial
|
||||||
skinparam linetype ortho
|
skinparam linetype ortho
|
||||||
|
skinparam packageStyle rectangle
|
||||||
|
|
||||||
title Key Database Schema - User & Authentication
|
title Database Schema
|
||||||
|
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
|
||||||
|
|
||||||
entity "UserAccount" as User {
|
package "User & Auth" {
|
||||||
* UserAccountId: INT <<PK>>
|
entity "UserAccount" as User {
|
||||||
--
|
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
|
||||||
* Username: NVARCHAR(30) <<UNIQUE>>
|
--
|
||||||
* Email: NVARCHAR(255) <<UNIQUE>>
|
* Username: VARCHAR(64) <<UNIQUE>>
|
||||||
* FirstName: NVARCHAR(50)
|
* FirstName: NVARCHAR(128)
|
||||||
* LastName: NVARCHAR(50)
|
* LastName: NVARCHAR(128)
|
||||||
Bio: NVARCHAR(500)
|
* Email: VARCHAR(128) <<UNIQUE>>
|
||||||
CreatedAt: DATETIME2
|
* CreatedAt: DATETIME
|
||||||
UpdatedAt: DATETIME2
|
UpdatedAt: DATETIME
|
||||||
LastLoginAt: DATETIME2
|
* DateOfBirth: DATE
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserCredential" as Cred {
|
||||||
|
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* CreatedAt: DATETIME
|
||||||
|
* Expiry: DATETIME
|
||||||
|
* Hash: NVARCHAR(256)
|
||||||
|
* IsRevoked: BIT
|
||||||
|
RevokedAt: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserVerification" as Verify {
|
||||||
|
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||||
|
* VerificationDateTime: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserAvatar (*)" as Avatar {
|
||||||
|
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||||
|
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserFollow (*)" as Follow {
|
||||||
|
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* FollowingID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* CreatedAt: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "Photo (*)" as Photo {
|
||||||
|
* PhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
Hyperlink: NVARCHAR(256)
|
||||||
|
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* UploadedAt: DATETIME
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "UserCredential" as Cred {
|
package "Location" {
|
||||||
* UserCredentialId: INT <<PK>>
|
entity "Country" as Country {
|
||||||
--
|
* CountryID: UNIQUEIDENTIFIER <<PK>>
|
||||||
* UserAccountId: INT <<FK>>
|
--
|
||||||
* PasswordHash: VARBINARY(32)
|
* CountryName: NVARCHAR(100)
|
||||||
* PasswordSalt: VARBINARY(16)
|
* ISO3166_1: CHAR(2) <<UNIQUE>>
|
||||||
CredentialRotatedAt: DATETIME2
|
}
|
||||||
CredentialExpiresAt: DATETIME2
|
|
||||||
CredentialRevokedAt: DATETIME2
|
entity "StateProvince" as StateProvince {
|
||||||
* IsActive: BIT
|
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
|
||||||
CreatedAt: DATETIME2
|
--
|
||||||
|
* StateProvinceName: NVARCHAR(100)
|
||||||
|
* ISO3166_2: CHAR(6) <<UNIQUE>>
|
||||||
|
* CountryID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "City" as City {
|
||||||
|
* CityID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* CityName: NVARCHAR(100)
|
||||||
|
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "UserVerification" as Verify {
|
package "Brewery" {
|
||||||
* UserVerificationId: INT <<PK>>
|
entity "BreweryPost" as Brewery {
|
||||||
--
|
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
|
||||||
* UserAccountId: INT <<FK>>
|
--
|
||||||
* IsVerified: BIT
|
* BreweryName: NVARCHAR(256)
|
||||||
VerifiedAt: DATETIME2
|
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||||
VerificationToken: NVARCHAR(255)
|
* Description: NVARCHAR(512)
|
||||||
TokenExpiresAt: DATETIME2
|
* CreatedAt: DATETIME
|
||||||
|
UpdatedAt: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "BreweryPostLocation" as BreweryLoc {
|
||||||
|
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||||
|
* AddressLine1: NVARCHAR(256)
|
||||||
|
AddressLine2: NVARCHAR(256)
|
||||||
|
* PostalCode: NVARCHAR(20)
|
||||||
|
* CityID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
Coordinates: GEOGRAPHY
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "BreweryPostPhoto (*)" as BreweryPhoto {
|
||||||
|
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* LinkedAt: DATETIME
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "UserAvatar" as Avatar {
|
package "Beer (*)" {
|
||||||
* UserAvatarId: INT <<PK>>
|
entity "BeerStyle (*)" as BeerStyle {
|
||||||
--
|
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
|
||||||
* UserAccountId: INT <<FK>>
|
--
|
||||||
PhotoId: INT <<FK>>
|
* StyleName: NVARCHAR(100) <<UNIQUE>>
|
||||||
* IsActive: BIT
|
Description: NVARCHAR(MAX)
|
||||||
CreatedAt: DATETIME2
|
}
|
||||||
|
|
||||||
|
entity "BeerPost (*)" as Beer {
|
||||||
|
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* Name: NVARCHAR(100)
|
||||||
|
* Description: NVARCHAR(MAX)
|
||||||
|
* ABV: DECIMAL(4,2)
|
||||||
|
* IBU: INT
|
||||||
|
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* CreatedAt: DATETIME
|
||||||
|
UpdatedAt: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "BeerPostPhoto (*)" as BeerPhoto {
|
||||||
|
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* LinkedAt: DATETIME
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "BeerPostComment (*)" as BeerComment {
|
||||||
|
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
|
||||||
|
--
|
||||||
|
* Comment: NVARCHAR(250)
|
||||||
|
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
|
||||||
|
* Rating: INT
|
||||||
|
* CreatedAt: DATETIME
|
||||||
|
UpdatedAt: DATETIME
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entity "UserFollow" as Follow {
|
' Relationships: User & Auth
|
||||||
* UserFollowId: INT <<PK>>
|
|
||||||
--
|
|
||||||
* FollowerUserId: INT <<FK>>
|
|
||||||
* FollowedUserId: INT <<FK>>
|
|
||||||
CreatedAt: DATETIME2
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "Photo" as Photo {
|
|
||||||
* PhotoId: INT <<PK>>
|
|
||||||
--
|
|
||||||
* Url: NVARCHAR(500)
|
|
||||||
* CloudinaryPublicId: NVARCHAR(255)
|
|
||||||
Width: INT
|
|
||||||
Height: INT
|
|
||||||
Format: NVARCHAR(10)
|
|
||||||
CreatedAt: DATETIME2
|
|
||||||
}
|
|
||||||
|
|
||||||
' Relationships
|
|
||||||
User ||--o{ Cred : "has"
|
User ||--o{ Cred : "has"
|
||||||
User ||--o| Verify : "has"
|
User ||--o| Verify : "has"
|
||||||
User ||--o{ Avatar : "has"
|
User ||--o| Avatar : "has"
|
||||||
User ||--o{ Follow : "follows"
|
|
||||||
User ||--o{ Follow : "followed by"
|
|
||||||
Avatar }o--|| Photo : "refers to"
|
Avatar }o--|| Photo : "refers to"
|
||||||
|
User ||--o{ Follow : "follows (UserAccountID)"
|
||||||
|
User ||--o{ Follow : "followed by (FollowingID)"
|
||||||
|
User ||--o{ Photo : "uploaded"
|
||||||
|
|
||||||
note right of Cred
|
' Relationships: Location
|
||||||
Password hashing:
|
Country ||--o{ StateProvince : "has"
|
||||||
- Algorithm: Argon2id
|
StateProvince ||--o{ City : "has"
|
||||||
- Memory: 64MB
|
|
||||||
- Iterations: 4
|
|
||||||
- Salt: 128-bit
|
|
||||||
- Hash: 256-bit
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of Verify
|
' Relationships: Brewery
|
||||||
Account verification
|
User ||--o{ Brewery : "posted"
|
||||||
via email token
|
Brewery ||--o| BreweryLoc : "has"
|
||||||
with expiry
|
City ||--o{ BreweryLoc : "located in"
|
||||||
end note
|
Brewery ||--o{ BreweryPhoto : "has"
|
||||||
|
Photo ||--o{ BreweryPhoto : "linked to"
|
||||||
|
|
||||||
|
' Relationships: Beer
|
||||||
|
User ||--o{ Beer : "posted"
|
||||||
|
BeerStyle ||--o{ Beer : "categorizes"
|
||||||
|
Brewery ||--o{ Beer : "brewed"
|
||||||
|
Beer ||--o{ BeerPhoto : "has"
|
||||||
|
Photo ||--o{ BeerPhoto : "linked to"
|
||||||
|
Beer ||--o{ BeerComment : "has"
|
||||||
|
User ||--o{ BeerComment : "commented"
|
||||||
|
|
||||||
note bottom of User
|
note bottom of User
|
||||||
Core stored procedures:
|
Backed by Features.Auth/Features.UserManagement's
|
||||||
- USP_RegisterUser
|
Dapper repositories (AuthRepository,
|
||||||
- USP_GetUserAccountByUsername
|
UserAccountRepository) - registration, credential
|
||||||
- USP_RotateUserCredential
|
rotation, and verification each issue their own
|
||||||
- USP_UpdateUserAccount
|
inline SQL, no stored procedures
|
||||||
|
end note
|
||||||
|
|
||||||
|
note bottom of Brewery
|
||||||
|
Backed by Features.Breweries' BreweryRepository
|
||||||
|
(Dapper); create/update/delete run inline SQL
|
||||||
|
inside a transaction, with optimistic concurrency
|
||||||
|
via RowVersion
|
||||||
end note
|
end note
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
||||||
component "xUnit + Moq + DbMocker" as UnitComp
|
component "xUnit + Moq" as UnitComp
|
||||||
note right
|
note right
|
||||||
Builds Dockerfile.tests, which
|
Builds Dockerfile.tests, which
|
||||||
restores/builds against Core.slnx
|
restores/builds against Core.slnx
|
||||||
@@ -124,8 +124,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
|||||||
- Features.UserManagement.Tests
|
- Features.UserManagement.Tests
|
||||||
- Features.Emails.Tests
|
- Features.Emails.Tests
|
||||||
|
|
||||||
Uses: Moq for handlers,
|
Uses: Moq to mock each handler's
|
||||||
DbMocker for repositories
|
repository/service dependencies
|
||||||
No real database needed
|
No real database needed
|
||||||
end note
|
end note
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ DevAPI .up.> DevSeed : depends_on
|
|||||||
TestMig --> TestDB : 1. Migrate
|
TestMig --> TestDB : 1. Migrate
|
||||||
TestSeed --> TestDB : 2. Seed
|
TestSeed --> TestDB : 2. Seed
|
||||||
Specs --> TestDB : 3. Integration test
|
Specs --> TestDB : 3. Integration test
|
||||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection)
|
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
|
||||||
|
|
||||||
TestMig .up.> TestDB : depends_on
|
TestMig .up.> TestDB : depends_on
|
||||||
TestSeed .up.> TestMig : depends_on
|
TestSeed .up.> TestMig : depends_on
|
||||||
|
|||||||
@@ -88,12 +88,26 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
|||||||
|
|
||||||
**Generate Secrets**:
|
**Generate Secrets**:
|
||||||
|
|
||||||
```bash
|
**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)**
|
||||||
# macOS/Linux - Generate 127-character base64 secret
|
|
||||||
openssl rand -base64 127
|
|
||||||
|
|
||||||
# Windows PowerShell
|
```bash
|
||||||
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
openssl rand -base64 127
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw bytes)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$bytes = New-Object byte[] 127
|
||||||
|
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||||
|
$rng.GetBytes($bytes)
|
||||||
|
[Convert]::ToBase64String($bytes)
|
||||||
|
$rng.Dispose()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows - PowerShell 7+ compatible (cryptographically secure, 127 raw bytes)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
[Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(127))
|
||||||
```
|
```
|
||||||
|
|
||||||
**Token Expiration**:
|
**Token Expiration**:
|
||||||
@@ -166,8 +180,8 @@ NODE_ENV=development # Standard Node runtime mode
|
|||||||
|
|
||||||
### SMTP configuration (backend)
|
### SMTP configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
||||||
and account emails.
|
account emails.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SMTP_HOST=mailpit # Required, no default
|
SMTP_HOST=mailpit # Required, no default
|
||||||
@@ -183,8 +197,8 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
|
|||||||
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)
|
||||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
|
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
|
||||||
and so on)
|
so on)
|
||||||
|
|
||||||
## Docker-specific variables
|
## Docker-specific variables
|
||||||
|
|
||||||
@@ -234,35 +248,35 @@ reference only and is not run as part of the active stack.
|
|||||||
|
|
||||||
## Variable reference table
|
## Variable reference table
|
||||||
|
|
||||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
| ----------------------------- | :-----: | :------: | :----: | :------: | ---------------------------- |
|
||||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||||
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
|
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
|
||||||
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
|
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
|
||||||
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
|
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
|
||||||
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
|
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
|
||||||
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
||||||
| `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` |
|
||||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||||
|
|
||||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
||||||
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
||||||
@@ -276,9 +290,8 @@ Variables are validated at startup:
|
|||||||
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
||||||
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
|
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
|
||||||
values) cause the application to fail with an `InvalidOperationException`
|
values) cause the application to fail with an `InvalidOperationException`
|
||||||
- No minimum length is enforced on the token secrets in code; the
|
- No minimum length is enforced on the token secrets in code; the "minimum 32
|
||||||
"minimum 32 characters" guidance above is a recommendation, not an
|
characters" guidance above is a recommendation, not an enforced check
|
||||||
enforced check
|
|
||||||
|
|
||||||
### Frontend validation
|
### Frontend validation
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ See [Environment Variables](environment-variables.md) for the full list.
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP).
|
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
|
||||||
|
SMTP).
|
||||||
|
|
||||||
Available endpoints:
|
Available endpoints:
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ Biergarten App.
|
|||||||
|
|
||||||
The project uses a multi-layered testing approach across backend and frontend:
|
The project uses a multi-layered testing approach across backend and frontend:
|
||||||
|
|
||||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
|
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
|
||||||
a live, seeded database
|
live, seeded database
|
||||||
- **Features.\*.Tests** - One unit test project per backend feature slice
|
- **Features.\*.Tests** - One unit test project per backend feature slice
|
||||||
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
|
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
|
||||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
|
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
|
||||||
slice's command/query handlers and its own repository (mocked with Moq /
|
slice's command/query handlers, with dependencies mocked via Moq (no real
|
||||||
DbMocker, no real database required)
|
database required)
|
||||||
- **Storybook Vitest project** - Browser-based interaction tests for shared
|
- **Storybook Vitest project** - Browser-based interaction tests for shared
|
||||||
website stories
|
website stories
|
||||||
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
||||||
@@ -78,7 +78,7 @@ dotnet test API/API.Specs/API.Specs.csproj
|
|||||||
### Feature slice unit tests
|
### Feature slice 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 and repository:
|
handlers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/backend
|
cd web/backend
|
||||||
@@ -93,8 +93,8 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
|
|||||||
|
|
||||||
**Requirements**:
|
**Requirements**:
|
||||||
|
|
||||||
- No database required (handlers use Moq; repository tests use DbMocker to
|
- No database required (handlers are tested with their repository/service
|
||||||
simulate SQL Server responses)
|
dependencies mocked via Moq)
|
||||||
|
|
||||||
### Frontend Storybook tests
|
### Frontend Storybook tests
|
||||||
|
|
||||||
@@ -137,7 +137,6 @@ npm run test:storybook:playwright
|
|||||||
- Email confirmation and confirmation-email resend
|
- Email confirmation and confirmation-email resend
|
||||||
- Refresh token exchange
|
- Refresh token exchange
|
||||||
- JWT token generation, validation, and claims handling
|
- JWT token generation, validation, and claims handling
|
||||||
- `IAuthRepository` data access (DbMocker-backed)
|
|
||||||
- Invalid credentials and 404 error responses (via API.Specs)
|
- Invalid credentials and 404 error responses (via API.Specs)
|
||||||
|
|
||||||
**Features.Breweries.Tests**:
|
**Features.Breweries.Tests**:
|
||||||
@@ -173,7 +172,7 @@ npm run test:storybook:playwright
|
|||||||
### xUnit
|
### xUnit
|
||||||
|
|
||||||
- Primary unit testing framework
|
- Primary unit testing framework
|
||||||
- Used for Repository and Service layer tests
|
- Used for handler and service layer tests
|
||||||
- Supports parallel test execution
|
- Supports parallel test execution
|
||||||
|
|
||||||
### Reqnroll (Gherkin/BDD)
|
### Reqnroll (Gherkin/BDD)
|
||||||
@@ -194,12 +193,6 @@ npm run test:storybook:playwright
|
|||||||
- Used in Service layer tests
|
- Used in Service layer tests
|
||||||
- Enables isolated unit testing
|
- Enables isolated unit testing
|
||||||
|
|
||||||
### DbMocker
|
|
||||||
|
|
||||||
- Database mocking for repository tests
|
|
||||||
- Simulates SQL Server responses
|
|
||||||
- No real database required for unit tests
|
|
||||||
|
|
||||||
## Test structure
|
## Test structure
|
||||||
|
|
||||||
### API.Specs (integration tests)
|
### API.Specs (integration tests)
|
||||||
@@ -248,9 +241,7 @@ Features.Auth.Tests/
|
|||||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||||
│ └── RefreshTokenHandlerTests.cs
|
│ └── RefreshTokenHandlerTests.cs
|
||||||
├── Queries/
|
├── Queries/
|
||||||
│ └── LoginHandlerTests.cs
|
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
||||||
├── Repository/
|
|
||||||
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
|
|
||||||
└── Services/
|
└── Services/
|
||||||
├── TokenServiceRefreshTests.cs
|
├── TokenServiceRefreshTests.cs
|
||||||
└── TokenServiceValidationTests.cs
|
└── TokenServiceValidationTests.cs
|
||||||
@@ -278,7 +269,7 @@ public class LoginHandlerTests
|
|||||||
// ...set up mocks for a known user/credential...
|
// ...set up mocks for a known user/credential...
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
|
var result = await handler.Handle(new LoginCommand("testuser", "password123"), CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ Low-level JWT operations.
|
|||||||
|
|
||||||
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
||||||
|
|
||||||
Both token generation and validation live on the same slice-internal
|
Both token generation and validation live on the same slice-internal service
|
||||||
service (there is no separate validation service/class).
|
(there is no separate validation service/class).
|
||||||
|
|
||||||
**Generation methods:**
|
**Generation methods:**
|
||||||
|
|
||||||
@@ -56,18 +56,18 @@ service (there is no separate validation service/class).
|
|||||||
- `Username` (string)
|
- `Username` (string)
|
||||||
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||||
|
|
||||||
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
**Implementation:**
|
||||||
|
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
||||||
|
|
||||||
- Reads token secrets from environment variables
|
- Reads token secrets from environment variables
|
||||||
- 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.Auth`'s own
|
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
|
||||||
`AddFeaturesAuth()` extension method, except for the lower-level
|
extension method, except for the lower-level `ITokenInfrastructure` (JWT
|
||||||
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
|
signing/verification) it depends on, which is registered by the host
|
||||||
registered by the host (`API.Core/Program.cs`) since
|
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
|
||||||
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
|
middleware) also depends on it directly.
|
||||||
it directly.
|
|
||||||
|
|
||||||
### Integration points
|
### Integration points
|
||||||
|
|
||||||
@@ -97,9 +97,9 @@ it directly.
|
|||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
1. Receives a refresh token via `RefreshTokenCommand`
|
1. Receives a refresh token via `RefreshTokenCommand`
|
||||||
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
|
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
|
||||||
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
|
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
|
||||||
and issues a new access/refresh token pair
|
issues a new access/refresh token pair
|
||||||
3. Maps the result onto `LoginPayload`
|
3. Maps the result onto `LoginPayload`
|
||||||
|
|
||||||
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
|
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ namespace API.Core.Controllers;
|
|||||||
[Route("error")] // required
|
[Route("error")] // required
|
||||||
public class NotFoundController : ControllerBase
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a 404 response for any request that did not match a route.
|
||||||
|
/// </summary>
|
||||||
[HttpGet("404")] //required
|
[HttpGet("404")] //required
|
||||||
public IActionResult Handle404()
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature
|
|||||||
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.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.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
||||||
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
||||||
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ using Features.UserManagement.DependencyInjection;
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql.DependencyInjection;
|
||||||
using Shared.Application.Behaviors;
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Global Exception Filter
|
// Add current controllers to the exception filter
|
||||||
builder
|
builder
|
||||||
.Services.AddControllers(options =>
|
.Services.AddControllers(options =>
|
||||||
{
|
{
|
||||||
@@ -37,7 +37,7 @@ builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
|||||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
builder.Services.AddFluentValidationAutoValidation();
|
||||||
|
|
||||||
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
|
// Add MediatR.
|
||||||
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
||||||
builder.Services.AddMediatR(cfg =>
|
builder.Services.AddMediatR(cfg =>
|
||||||
{
|
{
|
||||||
@@ -60,15 +60,14 @@ if (!builder.Environment.IsProduction())
|
|||||||
|
|
||||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
builder.Services.AddInfrastructureSql();
|
||||||
|
|
||||||
builder.Services.AddFeaturesBreweries();
|
builder.Services.AddFeaturesBreweries();
|
||||||
builder.Services.AddFeaturesUserManagement();
|
builder.Services.AddFeaturesUserManagement();
|
||||||
builder.Services.AddFeaturesAuth();
|
builder.Services.AddFeaturesAuth();
|
||||||
builder.Services.AddFeaturesEmails();
|
builder.Services.AddFeaturesEmails();
|
||||||
|
|
||||||
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
|
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
||||||
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
|
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||||
|
|
||||||
// Register the exception filter
|
// Register the exception filter
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"Default": ""
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"SecretKey": "",
|
"SecretKey": "",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<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="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
|
|
||||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Feature
|
|||||||
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.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.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
|
||||||
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
|
||||||
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Scenario: Successful registration with valid details
|
|||||||
|
|
||||||
Scenario: Registration fails with existing username
|
Scenario: Registration fails with existing username
|
||||||
Given the API is running
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||||
@@ -21,6 +22,7 @@ Scenario: Registration fails with existing username
|
|||||||
|
|
||||||
Scenario: Registration fails with existing email
|
Scenario: Registration fails with existing email
|
||||||
Given the API is running
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||||
|
|||||||
@@ -83,10 +83,59 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string FixtureUsername = "test.user";
|
||||||
|
private const string FixturePassword = "Password1!";
|
||||||
|
private const string FixtureEmail = "test.user@thebiergarten.app";
|
||||||
|
|
||||||
|
// Scenarios run in parallel across feature classes, and this fixture is shared by name.
|
||||||
|
// Serializing provisioning avoids two scenarios racing to INSERT the same username/email,
|
||||||
|
// which would otherwise surface as a raw SQL unique-constraint error instead of a clean 409.
|
||||||
|
private static readonly SemaphoreSlim FixtureProvisioningLock = new(1, 1);
|
||||||
|
|
||||||
[Given("I have an existing account")]
|
[Given("I have an existing account")]
|
||||||
public void GivenIHaveAnExistingAccount()
|
public async Task GivenIHaveAnExistingAccount()
|
||||||
{
|
{
|
||||||
scenario[TestUserKey] = ("test.user", "password");
|
scenario[TestUserKey] = (FixtureUsername, FixturePassword);
|
||||||
|
|
||||||
|
HttpClient client = GetClient();
|
||||||
|
var registrationData = new
|
||||||
|
{
|
||||||
|
username = FixtureUsername,
|
||||||
|
firstName = "Test",
|
||||||
|
lastName = "User",
|
||||||
|
email = FixtureEmail,
|
||||||
|
dateOfBirth = "1990-01-01",
|
||||||
|
password = FixturePassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(registrationData),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registration is used (rather than relying on seeded data) so the fixture account is
|
||||||
|
// guaranteed to exist regardless of run order. A 409 means another scenario in this run
|
||||||
|
// already provisioned it, which is equally fine.
|
||||||
|
await FixtureProvisioningLock.WaitAsync();
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
FixtureProvisioningLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)response.StatusCode is not (201 or 409))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to provision fixture account '{FixtureUsername}': "
|
||||||
|
+ $"{(int)response.StatusCode} {response.StatusCode}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I do not have an existing account")]
|
[Given("I do not have an existing account")]
|
||||||
@@ -320,7 +369,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task GivenIAmLoggedIn()
|
public async Task GivenIAmLoggedIn()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
var loginData = new { username = "test.user", password = "password" };
|
var loginData = new { username = FixtureUsername, password = FixturePassword };
|
||||||
string body = JsonSerializer.Serialize(loginData);
|
string body = JsonSerializer.Serialize(loginData);
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/login")
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
|
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
|
||||||
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
|
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
|
||||||
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
|
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
|
||||||
|
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
|
||||||
|
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Shared/">
|
<Folder Name="/Shared/">
|
||||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
# Copy everything from the context (src/Core/Database)
|
# Copy everything from the context (src/Core), matching Database.Seed/Dockerfile's context so
|
||||||
|
# ProjectReference paths outside Database/ (e.g. Infrastructure.Sql) resolve.
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet restore "./Database.Migrations/Database.Migrations.csproj"
|
RUN dotnet restore "./Database/Database.Migrations/Database.Migrations.csproj"
|
||||||
RUN dotnet build "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "./Database/Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DbUp;
|
using DbUp;
|
||||||
using DbUp.Engine;
|
using DbUp.Engine;
|
||||||
|
using Infrastructure.Sql;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
@@ -96,12 +97,6 @@ public static class Program
|
|||||||
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
|
Console.WriteLine($"Database '{settings.DatabaseName}' is present.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens a connection, executes a non-query batch parameterised by database name, and
|
|
||||||
/// disposes both the command and the connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connectionString">The connection string to open.</param>
|
|
||||||
/// <param name="sql">The Transact-SQL batch to execute.</param>
|
|
||||||
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
|
/// <param name="databaseName">The value bound to the <c>@databaseName</c> parameter.</param>
|
||||||
private static async Task ExecuteNonQueryAsync(
|
private static async Task ExecuteNonQueryAsync(
|
||||||
string connectionString,
|
string connectionString,
|
||||||
@@ -171,16 +166,11 @@ public static class Program
|
|||||||
/// </exception>
|
/// </exception>
|
||||||
public static DatabaseSettings FromEnvironment()
|
public static DatabaseSettings FromEnvironment()
|
||||||
{
|
{
|
||||||
string server = Required("DB_SERVER");
|
string databaseName =
|
||||||
string databaseName = Required("DB_NAME");
|
Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
string user = Required("DB_USER");
|
?? throw new InvalidOperationException(
|
||||||
string password = Required("DB_PASSWORD");
|
"The DB_NAME environment variable is not set."
|
||||||
|
);
|
||||||
bool trustServerCertificate =
|
|
||||||
!bool.TryParse(
|
|
||||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
|
|
||||||
out bool parsedTrust
|
|
||||||
) || parsedTrust;
|
|
||||||
|
|
||||||
bool clearDatabase = string.Equals(
|
bool clearDatabase = string.Equals(
|
||||||
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
|
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
|
||||||
@@ -189,28 +179,11 @@ public static class Program
|
|||||||
);
|
);
|
||||||
|
|
||||||
return new DatabaseSettings(
|
return new DatabaseSettings(
|
||||||
Build(databaseName),
|
SqlConnectionStringHelper.BuildConnectionString(databaseName),
|
||||||
Build("master"),
|
SqlConnectionStringHelper.BuildMasterConnectionString(),
|
||||||
databaseName,
|
databaseName,
|
||||||
clearDatabase
|
clearDatabase
|
||||||
);
|
);
|
||||||
|
|
||||||
string Build(string initialCatalog) =>
|
|
||||||
new SqlConnectionStringBuilder
|
|
||||||
{
|
|
||||||
DataSource = server,
|
|
||||||
InitialCatalog = initialCatalog,
|
|
||||||
UserID = user,
|
|
||||||
Password = password,
|
|
||||||
Encrypt = true,
|
|
||||||
TrustServerCertificate = trustServerCertificate,
|
|
||||||
}.ConnectionString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Required(string variableName) =>
|
|
||||||
Environment.GetEnvironmentVariable(variableName)
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"The {variableName} environment variable is not set."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
|
|||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
FirstName NVARCHAR(128) NOT NULL,
|
FirstName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
LastName NVARCHAR(128) NOT NULL,
|
LastName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
Email VARCHAR(128) NOT NULL,
|
Email VARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATE NOT NULL,
|
DateOfBirth DATE NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAccount
|
CONSTRAINT PK_UserAccount
|
||||||
PRIMARY KEY (UserAccountID),
|
PRIMARY KEY (UserAccountID),
|
||||||
@@ -56,18 +56,18 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
||||||
(
|
(
|
||||||
PhotoID UNIQUEIDENTIFIER
|
PhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
Hyperlink NVARCHAR(256),
|
Hyperlink NVARCHAR(256),
|
||||||
-- storage is handled via filesystem or cloud service
|
-- storage is handled via filesystem or cloud service
|
||||||
|
|
||||||
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
UploadedAt DATETIME NOT NULL
|
UploadedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Photo
|
CONSTRAINT PK_Photo
|
||||||
PRIMARY KEY (PhotoID),
|
PRIMARY KEY (PhotoID),
|
||||||
@@ -87,14 +87,14 @@ NONCLUSTERED INDEX IX_Photo_UploadedByID
|
|||||||
|
|
||||||
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||||
(
|
(
|
||||||
UserAvatarID UNIQUEIDENTIFIER
|
UserAvatarID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||||
|
|
||||||
@@ -120,15 +120,15 @@ NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
||||||
(
|
(
|
||||||
UserVerificationID UNIQUEIDENTIFIER
|
UserVerificationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserVerification
|
CONSTRAINT PK_UserVerification
|
||||||
PRIMARY KEY (UserVerificationID),
|
PRIMARY KEY (UserVerificationID),
|
||||||
@@ -154,23 +154,23 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
UserCredentialID UNIQUEIDENTIFIER
|
UserCredentialID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Expiry DATETIME NOT NULL
|
Expiry DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||||
|
|
||||||
Hash NVARCHAR(256) NOT NULL,
|
Hash NVARCHAR(256) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
||||||
|
|
||||||
RevokedAt DATETIME NULL,
|
RevokedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserCredential
|
CONSTRAINT PK_UserCredential
|
||||||
PRIMARY KEY (UserCredentialID),
|
PRIMARY KEY (UserCredentialID),
|
||||||
@@ -195,17 +195,17 @@ NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
|||||||
|
|
||||||
CREATE TABLE UserFollow
|
CREATE TABLE UserFollow
|
||||||
(
|
(
|
||||||
UserFollowID UNIQUEIDENTIFIER
|
UserFollowID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserFollow
|
CONSTRAINT PK_UserFollow
|
||||||
PRIMARY KEY (UserFollowID),
|
PRIMARY KEY (UserFollowID),
|
||||||
@@ -223,7 +223,7 @@ CREATE TABLE UserFollow
|
|||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID
|
CHECK (UserAccountID != FollowingID
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||||
@@ -238,14 +238,14 @@ NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Country
|
CREATE TABLE Country
|
||||||
(
|
(
|
||||||
CountryID UNIQUEIDENTIFIER
|
CountryID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
||||||
|
|
||||||
CountryName NVARCHAR(100) NOT NULL,
|
CountryName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_1 CHAR(2) NOT NULL,
|
ISO3166_1 CHAR(2) NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Country
|
CONSTRAINT PK_Country
|
||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
@@ -259,17 +259,17 @@ CREATE TABLE Country
|
|||||||
|
|
||||||
CREATE TABLE StateProvince
|
CREATE TABLE StateProvince
|
||||||
(
|
(
|
||||||
StateProvinceID UNIQUEIDENTIFIER
|
StateProvinceID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
||||||
|
|
||||||
StateProvinceName NVARCHAR(100) NOT NULL,
|
StateProvinceName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_2 CHAR(6) NOT NULL,
|
ISO3166_2 CHAR(6) NOT NULL,
|
||||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||||
|
|
||||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_StateProvince
|
CONSTRAINT PK_StateProvince
|
||||||
PRIMARY KEY (StateProvinceID),
|
PRIMARY KEY (StateProvinceID),
|
||||||
@@ -291,14 +291,14 @@ NONCLUSTERED INDEX IX_StateProvince_Country
|
|||||||
|
|
||||||
CREATE TABLE City
|
CREATE TABLE City
|
||||||
(
|
(
|
||||||
CityID UNIQUEIDENTIFIER
|
CityID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
||||||
|
|
||||||
CityName NVARCHAR(100) NOT NULL,
|
CityName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_City
|
CONSTRAINT PK_City
|
||||||
PRIMARY KEY (CityID),
|
PRIMARY KEY (CityID),
|
||||||
@@ -320,18 +320,18 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryName NVARCHAR(256) NOT NULL,
|
BreweryName NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(MAX) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPost
|
CONSTRAINT PK_BreweryPost
|
||||||
PRIMARY KEY (BreweryPostID),
|
PRIMARY KEY (BreweryPostID),
|
||||||
@@ -354,19 +354,19 @@ CREATE TABLE BreweryPostLocation
|
|||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
AddressLine1 NVARCHAR(256) NOT NULL,
|
AddressLine1 NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
AddressLine2 NVARCHAR(256),
|
AddressLine2 NVARCHAR(256),
|
||||||
|
|
||||||
PostalCode NVARCHAR(20) NOT NULL,
|
PostalCode NVARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NULL,
|
Coordinates GEOGRAPHY NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostLocation
|
CONSTRAINT PK_BreweryPostLocation
|
||||||
PRIMARY KEY (BreweryPostLocationID),
|
PRIMARY KEY (BreweryPostLocationID),
|
||||||
@@ -412,14 +412,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
BreweryPostPhotoID UNIQUEIDENTIFIER
|
BreweryPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostPhoto
|
CONSTRAINT PK_BreweryPostPhoto
|
||||||
PRIMARY KEY (BreweryPostPhotoID),
|
PRIMARY KEY (BreweryPostPhotoID),
|
||||||
@@ -451,11 +451,11 @@ CREATE TABLE BeerStyle
|
|||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
||||||
|
|
||||||
StyleName NVARCHAR(100) NOT NULL,
|
StyleName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX),
|
Description NVARCHAR(MAX),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerStyle
|
CONSTRAINT PK_BeerStyle
|
||||||
PRIMARY KEY (BeerStyleID),
|
PRIMARY KEY (BeerStyleID),
|
||||||
@@ -469,31 +469,31 @@ CREATE TABLE BeerStyle
|
|||||||
|
|
||||||
CREATE TABLE BeerPost
|
CREATE TABLE BeerPost
|
||||||
(
|
(
|
||||||
BeerPostID UNIQUEIDENTIFIER
|
BeerPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
Name NVARCHAR(100) NOT NULL,
|
Name NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX) NOT NULL,
|
Description NVARCHAR(MAX) NOT NULL,
|
||||||
|
|
||||||
ABV DECIMAL(4, 2) NOT NULL,
|
ABV DECIMAL(4, 2) NOT NULL,
|
||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-120)
|
-- International Bitterness Units (typically 0-120)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPost
|
CONSTRAINT PK_BeerPost
|
||||||
PRIMARY KEY (BeerPostID),
|
PRIMARY KEY (BeerPostID),
|
||||||
@@ -538,14 +538,14 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
BeerPostPhotoID UNIQUEIDENTIFIER
|
BeerPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostPhoto
|
CONSTRAINT PK_BeerPostPhoto
|
||||||
PRIMARY KEY (BeerPostPhotoID),
|
PRIMARY KEY (BeerPostPhotoID),
|
||||||
@@ -577,20 +577,20 @@ CREATE TABLE BeerPostComment
|
|||||||
BeerPostCommentID UNIQUEIDENTIFIER
|
BeerPostCommentID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
||||||
|
|
||||||
Comment NVARCHAR(250) NOT NULL,
|
Comment NVARCHAR(250) NOT NULL,
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|||||||
@@ -10,13 +10,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
<PackageReference Include="idunno.Password.Generator" Version="2.0.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.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||||
|
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
||||||
<!--
|
|
||||||
remove this override once microsoft ships official fix to vulnerability
|
|
||||||
https:
|
|
||||||
-->
|
|
||||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -24,14 +19,9 @@
|
|||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
|
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
|
||||||
ConnectionStrings.SqliteConnectionString at runtime. -->
|
|
||||||
<None
|
|
||||||
CopyToOutputDirectory="PreserveNewest"
|
|
||||||
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"
|
|
||||||
/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#region
|
#region
|
||||||
|
|
||||||
using Microsoft.Data.SqlClient;
|
using Infrastructure.Sql;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -8,41 +8,7 @@ namespace Database.Seed.DatabaseHelpers;
|
|||||||
|
|
||||||
public class ConnectionStrings
|
public class ConnectionStrings
|
||||||
{
|
{
|
||||||
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
|
||||||
public static string SqliteConnectionString =>
|
public static string SqliteConnectionString =>
|
||||||
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||||
|
|
||||||
private static string GetSqlServerConnectionString()
|
|
||||||
{
|
|
||||||
string server =
|
|
||||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
|
||||||
|
|
||||||
string dbName =
|
|
||||||
Environment.GetEnvironmentVariable("DB_NAME")
|
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
|
||||||
|
|
||||||
string user =
|
|
||||||
Environment.GetEnvironmentVariable("DB_USER")
|
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
|
||||||
|
|
||||||
string password =
|
|
||||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
|
||||||
|
|
||||||
string trustServerCertificate =
|
|
||||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
|
||||||
|
|
||||||
SqlConnectionStringBuilder builder = new()
|
|
||||||
{
|
|
||||||
DataSource = server,
|
|
||||||
InitialCatalog = dbName,
|
|
||||||
UserID = user,
|
|
||||||
Password = password,
|
|
||||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
|
||||||
Encrypt = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return builder.ConnectionString;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,112 +7,257 @@ using Features.Auth.Dtos;
|
|||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Breweries.DependencyInjection;
|
using Features.Breweries.DependencyInjection;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using Features.Locations.DependencyInjection;
|
||||||
|
using Features.Locations.Dtos;
|
||||||
|
using Features.Locations.Repository;
|
||||||
using idunno.Password;
|
using idunno.Password;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql.DependencyInjection;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
static async Task<int> main()
|
return await RunAsync();
|
||||||
|
|
||||||
|
static async Task<int> RunAsync()
|
||||||
{
|
{
|
||||||
IReadOnlyList<BreweryRecord> breweries;
|
|
||||||
IReadOnlyList<UserRecord> users;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
|
ServiceCollection services = [];
|
||||||
breweries = reader.ReadBreweryRecords();
|
services.AddSingleton<IConfiguration>(
|
||||||
users = reader.ReadUserRecords();
|
new ConfigurationBuilder().Build());
|
||||||
|
services.AddInfrastructureSql();
|
||||||
|
services.AddFeaturesBreweries();
|
||||||
|
services.AddFeaturesLocations();
|
||||||
|
services.AddFeaturesAuth();
|
||||||
|
|
||||||
|
await using ServiceProvider provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
IBreweryRepository breweryRepository =
|
||||||
|
provider.GetRequiredService<IBreweryRepository>();
|
||||||
|
ILocationRepository locationRepository =
|
||||||
|
provider.GetRequiredService<ILocationRepository>();
|
||||||
|
IAuthRepository authRepository =
|
||||||
|
provider.GetRequiredService<IAuthRepository>();
|
||||||
|
IPasswordInfrastructure passwordInfrastructure =
|
||||||
|
provider.GetRequiredService<IPasswordInfrastructure>();
|
||||||
|
|
||||||
|
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> postedByIds = [];
|
||||||
|
|
||||||
|
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 =>
|
||||||
|
{
|
||||||
|
postedByIds = await LoadUsersIntoDatabaseAsync(
|
||||||
|
authRepository,
|
||||||
|
passwordInfrastructure,
|
||||||
|
seedData.Users
|
||||||
|
);
|
||||||
|
ctx.Status("User 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(
|
||||||
|
breweryRepository,
|
||||||
|
locationRepository,
|
||||||
|
seedData.Breweries,
|
||||||
|
postedByIds
|
||||||
|
);
|
||||||
|
ctx.Status("Brewery data loaded into target database.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
catch (SqliteException ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.ForegroundColor = ConsoleColor.Red;
|
AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
|
||||||
Console.WriteLine($"Error opening database connection: {ex.Message}");
|
AnsiConsole.WriteException(ex);
|
||||||
Console.ResetColor();
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ServiceCollection services = [];
|
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
IAuthRepository authRepository,
|
||||||
services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
services.AddFeaturesBreweries();
|
IReadOnlyList<UserRecord> users
|
||||||
services.AddFeaturesAuth();
|
)
|
||||||
|
{
|
||||||
|
List<Guid> userAccountIds = [];
|
||||||
|
|
||||||
using ServiceProvider provider = services.BuildServiceProvider();
|
foreach (UserRecord userRecord in users)
|
||||||
|
|
||||||
IBreweryRepository breweryRepository;
|
|
||||||
IAuthRepository authRepository;
|
|
||||||
IPasswordInfrastructure passwordInfrastructure;
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
breweryRepository = provider.GetRequiredService<IBreweryRepository>();
|
UserAccount userAccount = await authRepository.RegisterUserAsync(
|
||||||
authRepository = provider.GetRequiredService<IAuthRepository>();
|
new UserAccount()
|
||||||
passwordInfrastructure = provider.GetRequiredService<IPasswordInfrastructure>();
|
{
|
||||||
}
|
FirstName = userRecord.User.FirstName,
|
||||||
catch (InvalidOperationException ex)
|
LastName = userRecord.User.LastName,
|
||||||
{
|
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
||||||
Console.ForegroundColor = ConsoleColor.Red;
|
Username = userRecord.User.Username,
|
||||||
Console.WriteLine($"Error configuring database connection: {ex.Message}");
|
Email = userRecord.Email,
|
||||||
Console.ResetColor();
|
UserCredential = new UserCredential
|
||||||
return 1;
|
{
|
||||||
|
Hash = passwordInfrastructure.Hash(
|
||||||
|
password: PasswordGenerator.Generate(64, 12, 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
userAccountIds.Add(userAccount.UserAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.ForegroundColor = ConsoleColor.Green;
|
return userAccountIds;
|
||||||
Console.WriteLine($"Loaded {breweries.Count} breweries.");
|
}
|
||||||
Console.ResetColor();
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists each <paramref name="breweries" /> record, resolving (and creating, if needed) its
|
||||||
|
/// Country/StateProvince/City chain via <paramref name="locationRepository" />. The pipeline
|
||||||
|
/// data has no street-level address or a specific user tied to each brewery, so a placeholder
|
||||||
|
/// address is used and <c>PostedById</c> is assigned round-robin from <paramref name="posterUserIds" />
|
||||||
|
/// to keep seeding deterministic across runs.
|
||||||
|
/// </summary>
|
||||||
|
static async Task LoadBreweriesIntoDatabaseAsync(
|
||||||
|
IBreweryRepository breweryRepository,
|
||||||
|
ILocationRepository locationRepository,
|
||||||
|
IReadOnlyList<BreweryRecord> breweries,
|
||||||
|
IReadOnlyList<Guid> posterUserIds
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (posterUserIds.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot load breweries without any registered users.");
|
||||||
|
|
||||||
for (int i = 0; i < breweries.Count; i++)
|
for (int i = 0; i < breweries.Count; i++)
|
||||||
{
|
{
|
||||||
BreweryRecord brewery = breweries[i];
|
BreweryRecord breweryRecord = breweries[i];
|
||||||
Console.WriteLine(
|
|
||||||
$"{i + 1}:\t{brewery.Brewery.NameEn}\t({brewery.Address.City.CityName}, {brewery.Address.City.Country})"
|
Guid cityId = await locationRepository.GetOrCreateCityIdAsync(
|
||||||
|
new CityLocation(
|
||||||
|
breweryRecord.Address.City.CityName,
|
||||||
|
breweryRecord.Address.City.StateProvince,
|
||||||
|
breweryRecord.Address.City.Iso31662,
|
||||||
|
breweryRecord.Address.City.Country,
|
||||||
|
breweryRecord.Address.City.Iso31661
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await breweryRepository.CreateAsync(
|
||||||
|
new BreweryPost
|
||||||
|
{
|
||||||
|
BreweryPostId = Guid.NewGuid(),
|
||||||
|
BreweryName = breweryRecord.Brewery.NameEn,
|
||||||
|
Description = breweryRecord.Brewery.DescriptionEn,
|
||||||
|
PostedById = posterUserIds[i % posterUserIds.Count],
|
||||||
|
Location = new BreweryPostLocation
|
||||||
|
{
|
||||||
|
BreweryPostLocationId = Guid.NewGuid(),
|
||||||
|
CityId = cityId,
|
||||||
|
AddressLine1 = "Address unavailable",
|
||||||
|
PostalCode = breweryRecord.Address.PostalCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Postal Code");
|
||||||
|
|
||||||
|
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.PostalCode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.ForegroundColor = ConsoleColor.Green;
|
return table;
|
||||||
Console.WriteLine($"Loaded {users.Count} users.");
|
}
|
||||||
Console.ResetColor();
|
|
||||||
|
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)
|
foreach (UserRecord user in users)
|
||||||
{
|
{
|
||||||
string username = user.User.Username;
|
table.AddRow(
|
||||||
string firstName = user.User.FirstName;
|
user.User.Username,
|
||||||
string lastName = user.User.LastName;
|
user.User.FirstName,
|
||||||
string email = user.Email;
|
user.User.LastName,
|
||||||
DateTime dateOfBirth = DateTime.Parse(user.DateOfBirth);
|
user.Email,
|
||||||
|
user.DateOfBirth.ToString()
|
||||||
string generatedPassword = PasswordGenerator.Generate(
|
|
||||||
length: 64,
|
|
||||||
numberOfDigits: 10,
|
|
||||||
numberOfSymbols: 10
|
|
||||||
);
|
);
|
||||||
string hashedPassword = passwordInfrastructure.Hash(generatedPassword);
|
|
||||||
|
|
||||||
UserRegistrationDto registrationDto = new(
|
|
||||||
username,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email,
|
|
||||||
dateOfBirth,
|
|
||||||
hashedPassword
|
|
||||||
);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
UserAccount createdUser = await authRepository.RegisterUserAsync(registrationDto);
|
|
||||||
Console.WriteLine(
|
|
||||||
$"Created user: {createdUser.Username} ({createdUser.UserAccountId})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.ForegroundColor = ConsoleColor.Red;
|
|
||||||
Console.WriteLine($"Error creating user {username}: {ex.Message}");
|
|
||||||
Console.ResetColor();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await main();
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
#region
|
|
||||||
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Database.Seed.PipelineData;
|
using Database.Seed.PipelineData;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
namespace Database.Seed.Sqlite;
|
namespace Database.Seed.Sqlite;
|
||||||
|
|
||||||
|
public sealed record SeedData(
|
||||||
|
IReadOnlyList<BreweryRecord> Breweries,
|
||||||
|
IReadOnlyList<UserRecord> Users
|
||||||
|
);
|
||||||
|
|
||||||
public sealed class PipelineSeedDataReader
|
public sealed class PipelineSeedDataReader
|
||||||
{
|
{
|
||||||
private readonly string _connectionString;
|
private readonly string _connectionString;
|
||||||
@@ -17,13 +18,40 @@ public sealed class PipelineSeedDataReader
|
|||||||
_connectionString = connectionString;
|
_connectionString = connectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<BreweryRecord> ReadBreweryRecords()
|
/// <summary>
|
||||||
|
/// Reads breweries and users in a single connection, loading the cities
|
||||||
|
/// lookup only once and sharing it between both reads.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SeedData> ReadSeedDataAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using SqliteConnection connection = new(_connectionString);
|
await using SqliteConnection connection = new(_connectionString);
|
||||||
connection.Open();
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
|
IReadOnlyDictionary<long, City> cities = await ReadCitiesAsync(
|
||||||
|
connection,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
IReadOnlyList<BreweryRecord> breweries = await ReadBreweryRecordsAsync(
|
||||||
|
connection,
|
||||||
|
cities,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
IReadOnlyList<UserRecord> users = await ReadUserRecordsAsync(
|
||||||
|
connection,
|
||||||
|
cities,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SeedData(breweries, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<BreweryRecord>> ReadBreweryRecordsAsync(
|
||||||
|
SqliteConnection connection,
|
||||||
|
IReadOnlyDictionary<long, City> cities,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
|
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
|
||||||
ba.city_id, ba.postal_code
|
ba.city_id, ba.postal_code
|
||||||
@@ -31,25 +59,32 @@ public sealed class PipelineSeedDataReader
|
|||||||
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
using SqliteCommand command = new(sql, connection);
|
await using SqliteCommand command = new(sql, connection);
|
||||||
using SqliteDataReader reader = command.ExecuteReader();
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
const int nameEnIndex = 0;
|
||||||
|
const int descriptionEnIndex = 1;
|
||||||
|
const int nameLocalIndex = 2;
|
||||||
|
const int descriptionLocalIndex = 3;
|
||||||
|
const int cityIdIndex = 4;
|
||||||
|
const int postalCodeIndex = 5;
|
||||||
|
|
||||||
List<BreweryRecord> records = [];
|
List<BreweryRecord> records = [];
|
||||||
while (reader.Read())
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
records.Add(
|
records.Add(
|
||||||
new BreweryRecord
|
new BreweryRecord
|
||||||
{
|
{
|
||||||
Brewery = new BreweryResult
|
Brewery = new BreweryResult
|
||||||
{
|
{
|
||||||
NameEn = reader.GetString(0),
|
NameEn = reader.GetString(nameEnIndex),
|
||||||
DescriptionEn = reader.GetString(1),
|
DescriptionEn = reader.GetString(descriptionEnIndex),
|
||||||
NameLocal = reader.GetString(2),
|
NameLocal = reader.GetString(nameLocalIndex),
|
||||||
DescriptionLocal = reader.GetString(3),
|
DescriptionLocal = reader.GetString(descriptionLocalIndex),
|
||||||
},
|
},
|
||||||
Address = new BreweryAddress
|
Address = new BreweryAddress
|
||||||
{
|
{
|
||||||
City = cities[reader.GetInt64(4)],
|
City = cities[reader.GetInt64(cityIdIndex)],
|
||||||
PostalCode = reader.GetString(5),
|
PostalCode = reader.GetString(postalCodeIndex),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -57,13 +92,12 @@ public sealed class PipelineSeedDataReader
|
|||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<UserRecord> ReadUserRecords()
|
private static async Task<IReadOnlyList<UserRecord>> ReadUserRecordsAsync(
|
||||||
|
SqliteConnection connection,
|
||||||
|
IReadOnlyDictionary<long, City> cities,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
using SqliteConnection connection = new(_connectionString);
|
|
||||||
connection.Open();
|
|
||||||
|
|
||||||
IReadOnlyDictionary<long, City> cities = ReadCities(connection);
|
|
||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
|
SELECT u.first_name, u.last_name, u.gender, u.username, u.bio,
|
||||||
u.activity_weight, u.email, u.date_of_birth,
|
u.activity_weight, u.email, u.date_of_birth,
|
||||||
@@ -72,29 +106,40 @@ public sealed class PipelineSeedDataReader
|
|||||||
JOIN user_addresses ua ON ua.user_id = u.id;
|
JOIN user_addresses ua ON ua.user_id = u.id;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
using SqliteCommand command = new(sql, connection);
|
await using SqliteCommand command = new(sql, connection);
|
||||||
using SqliteDataReader reader = command.ExecuteReader();
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
const int firstNameIndex = 0;
|
||||||
|
const int lastNameIndex = 1;
|
||||||
|
const int genderIndex = 2;
|
||||||
|
const int usernameIndex = 3;
|
||||||
|
const int bioIndex = 4;
|
||||||
|
const int activityWeightIndex = 5;
|
||||||
|
const int emailIndex = 6;
|
||||||
|
const int dateOfBirthIndex = 7;
|
||||||
|
const int cityIdIndex = 8;
|
||||||
|
const int postalCodeIndex = 9;
|
||||||
|
|
||||||
List<UserRecord> records = [];
|
List<UserRecord> records = [];
|
||||||
while (reader.Read())
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
records.Add(
|
records.Add(
|
||||||
new UserRecord
|
new UserRecord
|
||||||
{
|
{
|
||||||
User = new UserResult
|
User = new UserResult
|
||||||
{
|
{
|
||||||
FirstName = reader.GetString(0),
|
FirstName = reader.GetString(firstNameIndex),
|
||||||
LastName = reader.GetString(1),
|
LastName = reader.GetString(lastNameIndex),
|
||||||
Gender = reader.GetString(2),
|
Gender = reader.GetString(genderIndex),
|
||||||
Username = reader.GetString(3),
|
Username = reader.GetString(usernameIndex),
|
||||||
Bio = reader.GetString(4),
|
Bio = reader.GetString(bioIndex),
|
||||||
ActivityWeight = (float)reader.GetDouble(5),
|
ActivityWeight = (float)reader.GetDouble(activityWeightIndex),
|
||||||
},
|
},
|
||||||
Email = reader.GetString(6),
|
Email = reader.GetString(emailIndex),
|
||||||
DateOfBirth = reader.GetString(7),
|
DateOfBirth = reader.GetString(dateOfBirthIndex),
|
||||||
Address = new UserAddress
|
Address = new UserAddress
|
||||||
{
|
{
|
||||||
City = cities[reader.GetInt64(8)],
|
City = cities[reader.GetInt64(cityIdIndex)],
|
||||||
PostalCode = reader.GetString(9),
|
PostalCode = reader.GetString(postalCodeIndex),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -102,7 +147,10 @@ public sealed class PipelineSeedDataReader
|
|||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<long, City> ReadCities(SqliteConnection connection)
|
private static async Task<IReadOnlyDictionary<long, City>> ReadCitiesAsync(
|
||||||
|
SqliteConnection connection,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -118,33 +166,34 @@ public sealed class PipelineSeedDataReader
|
|||||||
FROM cities;
|
FROM cities;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
using SqliteCommand command = new(sql, connection);
|
await using SqliteCommand command = new(sql, connection);
|
||||||
using SqliteDataReader reader = command.ExecuteReader();
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
Dictionary<long, City> cities = [];
|
Dictionary<long, City> cities = [];
|
||||||
|
|
||||||
const int cityNameBindIndex = 1;
|
const int idIndex = 0;
|
||||||
const int stateProvinceBindIndex = 2;
|
const int cityNameIndex = 1;
|
||||||
const int iso31662BindIndex = 3;
|
const int stateProvinceIndex = 2;
|
||||||
const int countryBindIndex = 4;
|
const int iso31662Index = 3;
|
||||||
const int iso31661BindIndex = 5;
|
const int countryIndex = 4;
|
||||||
const int localLanguagesBindIndex = 6;
|
const int iso31661Index = 5;
|
||||||
const int countryFormatRegexBindIndex = 7;
|
const int localLanguagesIndex = 6;
|
||||||
const int cityRegexesBindIndex = 8;
|
const int countryFormatRegexIndex = 7;
|
||||||
|
const int cityRegexesIndex = 8;
|
||||||
|
|
||||||
while (reader.Read())
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
cities[reader.GetInt64(0)] = new City
|
cities[reader.GetInt64(idIndex)] = new City
|
||||||
{
|
{
|
||||||
CityName = reader.GetString(cityNameBindIndex),
|
CityName = reader.GetString(cityNameIndex),
|
||||||
StateProvince = reader.GetString(stateProvinceBindIndex),
|
StateProvince = reader.GetString(stateProvinceIndex),
|
||||||
Iso31662 = reader.GetString(iso31662BindIndex),
|
Iso31662 = reader.GetString(iso31662Index),
|
||||||
Country = reader.GetString(countryBindIndex),
|
Country = reader.GetString(countryIndex),
|
||||||
Iso31661 = reader.GetString(iso31661BindIndex),
|
Iso31661 = reader.GetString(iso31661Index),
|
||||||
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesBindIndex)),
|
LocalLanguages = DeserializeStringArray(reader.GetString(localLanguagesIndex)),
|
||||||
PostalCode = new PostalCodeSpec
|
PostalCode = new PostalCodeSpec
|
||||||
{
|
{
|
||||||
CountryFormatRegex = reader.GetString(countryFormatRegexBindIndex),
|
CountryFormatRegex = reader.GetString(countryFormatRegexIndex),
|
||||||
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesBindIndex)),
|
CityRegexes = DeserializeStringArray(reader.GetString(cityRegexesIndex)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace Database.Seed;
|
|
||||||
|
|
||||||
public interface IExportDatabase
|
|
||||||
{
|
|
||||||
void SeedLocations(IEnumerable<Location> locations);
|
|
||||||
void SeedUsers(IEnumerable<User> users);
|
|
||||||
|
|
||||||
void SeedBreweries(IEnumerable<Brewery> breweries);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IImportDatabase
|
|
||||||
{
|
|
||||||
IEnumerable<Location> GetLocations();
|
|
||||||
IEnumerable<User> GetUsers();
|
|
||||||
|
|
||||||
IEnumerable<Brewery> GetBreweries();
|
|
||||||
}
|
|
||||||
29
web/backend/Domain/Domain.Entities/Entities/BeerPost.cs
Normal file
29
web/backend/Domain/Domain.Entities/Entities/BeerPost.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class BeerPost
|
||||||
|
{
|
||||||
|
// PK
|
||||||
|
public Guid BeerPostId { get; set; } = Guid.Empty;
|
||||||
|
|
||||||
|
// FK References
|
||||||
|
public Guid PostedById { get; set; } = Guid.Empty;
|
||||||
|
public Guid BeerStyleId { get; set; } = Guid.Empty;
|
||||||
|
public Guid BrewedById { get; set; } = Guid.Empty;
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal ABV { get; set; }
|
||||||
|
public int IBU { get; set; }
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
// Navigation Properties
|
||||||
|
public UserAccount? PostedBy { get; set; }
|
||||||
|
public BreweryPost? BrewedBy { get; set; }
|
||||||
|
|
||||||
|
public BeerStyle? BeerStyle { get; set; }
|
||||||
|
}
|
||||||
16
web/backend/Domain/Domain.Entities/Entities/BeerStyle.cs
Normal file
16
web/backend/Domain/Domain.Entities/Entities/BeerStyle.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class BeerStyle
|
||||||
|
{
|
||||||
|
// PK
|
||||||
|
public Guid BeerStyleId { get; set; } = Guid.Empty;
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string BeerStyleName { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public class BreweryPost
|
public sealed class BreweryPost
|
||||||
{
|
{
|
||||||
|
// PK
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
public UserAccount PostedBy { get; set; } = null!;
|
|
||||||
|
// Navigation Properties
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
|
public List<BeerPost> BeerPosts { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public class BreweryPostLocation
|
public sealed class BreweryPostLocation
|
||||||
{
|
{
|
||||||
|
// PK
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
public Guid CityId { get; set; }
|
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
public BreweryPost BreweryPost { get; set; } = null!;
|
// Audit Fields
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
// Navigation Properties
|
||||||
|
public City? City { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
19
web/backend/Domain/Domain.Entities/Entities/City.cs
Normal file
19
web/backend/Domain/Domain.Entities/Entities/City.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class City
|
||||||
|
{
|
||||||
|
// PK
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
|
public Guid StateProvinceId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string CityName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
// Navigation Properties
|
||||||
|
public StateProvince? StateProvince { get; set; }
|
||||||
|
}
|
||||||
15
web/backend/Domain/Domain.Entities/Entities/Country.cs
Normal file
15
web/backend/Domain/Domain.Entities/Entities/Country.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class Country
|
||||||
|
{
|
||||||
|
// PK
|
||||||
|
public Guid CountryId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string CountryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ISO3166_1 { get; set; } = string.Empty; // this is the iso 3166-1 alpha-2 code for the country, e.g. "US" for United States, "CA" for Canada, etc.
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
}
|
||||||
23
web/backend/Domain/Domain.Entities/Entities/StateProvince.cs
Normal file
23
web/backend/Domain/Domain.Entities/Entities/StateProvince.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class StateProvince
|
||||||
|
{
|
||||||
|
// PK
|
||||||
|
public Guid StateProvinceId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
|
public Guid CountryId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
public string StateProvinceName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// this is the iso 3166-2 code for the state/province, e.g. "CA" for California, "ON" for Ontario, etc.
|
||||||
|
// and is rendered like US-CA for California, CA-ON for Ontario, etc.
|
||||||
|
public string ISO3166_2 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
|
|
||||||
|
// Navigation Properties
|
||||||
|
public Country? Country { get; set; }
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public class UserAccount
|
public sealed class UserAccount
|
||||||
{
|
{
|
||||||
|
// PK
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
public string FirstName { get; set; } = string.Empty;
|
public string FirstName { get; set; } = string.Empty;
|
||||||
public string LastName { get; set; } = string.Empty;
|
public string LastName { get; set; } = string.Empty;
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public DateTime DateOfBirth { get; set; }
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public DateTime DateOfBirth { get; set; }
|
public byte[]? RowVersion { get; set; }
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
public ICollection<BreweryPost> BreweryPosts { get; set; } = [];
|
// Navigation properties
|
||||||
public ICollection<UserCredential> UserCredentials { get; set; } = [];
|
public UserCredential? UserCredential { get; set; }
|
||||||
public UserVerification? Verification { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public class UserCredential
|
public sealed class UserCredential
|
||||||
{
|
{
|
||||||
|
// PK
|
||||||
public Guid UserCredentialId { get; set; }
|
public Guid UserCredentialId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
|
// Attributes
|
||||||
public DateTime Expiry { get; set; }
|
public DateTime Expiry { get; set; }
|
||||||
public string Hash { get; set; } = string.Empty;
|
public string Hash { get; set; } = string.Empty;
|
||||||
public byte[]? Timer { get; set; }
|
public bool IsRevoked { get; set; }
|
||||||
public UserAccount UserAccount { get; set; } = null!;
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
// Audit Fields
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public class UserVerification
|
public sealed class UserVerification
|
||||||
{
|
{
|
||||||
|
// PK
|
||||||
public Guid UserVerificationId { get; set; }
|
public Guid UserVerificationId { get; set; }
|
||||||
|
|
||||||
|
// FK References
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
// Attributes
|
||||||
public DateTime VerificationDateTime { get; set; }
|
public DateTime VerificationDateTime { get; set; }
|
||||||
public byte[]? Timer { get; set; }
|
|
||||||
public UserAccount UserAccount { get; set; } = null!;
|
// Audit Fields
|
||||||
|
public byte[]? RowVersion { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
new UserRegistrationDto(
|
It.Is<UserAccount>(ua =>
|
||||||
command.Username,
|
ua.FirstName == command.FirstName
|
||||||
command.FirstName,
|
&& ua.LastName == command.LastName
|
||||||
command.LastName,
|
&& ua.Username == command.Username
|
||||||
command.Email,
|
&& ua.Email == command.Email
|
||||||
command.DateOfBirth,
|
&& ua.DateOfBirth == command.DateOfBirth
|
||||||
hashedPassword
|
&& ua.UserCredential!.Hash == hashedPassword
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -141,7 +141,7 @@ public class RegisterUserHandlerTests
|
|||||||
.WithMessage("Username or email already exists");
|
.WithMessage("Username or email already exists");
|
||||||
|
|
||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
|
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
|
||||||
Times.Never
|
Times.Never
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
|
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||||
@@ -204,13 +204,13 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x =>
|
x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
new UserRegistrationDto(
|
It.Is<UserAccount>(ua =>
|
||||||
command.Username,
|
ua.FirstName == command.FirstName
|
||||||
command.FirstName,
|
&& ua.LastName == command.LastName
|
||||||
command.LastName,
|
&& ua.Username == command.Username
|
||||||
command.Email,
|
&& ua.DateOfBirth == command.DateOfBirth
|
||||||
command.DateOfBirth,
|
&& ua.Email == command.Email
|
||||||
hashedPassword
|
&& ua.UserCredential!.Hash == hashedPassword
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Times.Once
|
Times.Once
|
||||||
@@ -230,7 +230,7 @@ public class RegisterUserHandlerTests
|
|||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((UserAccount?)null);
|
||||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
|
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
|
||||||
.ReturnsAsync(
|
.ReturnsAsync(
|
||||||
new UserAccount
|
new UserAccount
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
<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="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Commands.Login;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Queries.Login;
|
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -68,7 +68,7 @@ public class LoginHandlerTests
|
|||||||
.Returns("refresh-token");
|
.Returns("refresh-token");
|
||||||
|
|
||||||
LoginPayload result = await _handler.Handle(
|
LoginPayload result = await _handler.Handle(
|
||||||
new LoginQuery(username, "any-password"),
|
new LoginCommand(username, "any-password"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ public class LoginHandlerTests
|
|||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
@@ -110,7 +110,7 @@ public class LoginHandlerTests
|
|||||||
.ReturnsAsync((UserCredential?)null);
|
.ReturnsAsync((UserCredential?)null);
|
||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should()
|
await act.Should()
|
||||||
.ThrowAsync<UnauthorizedException>()
|
.ThrowAsync<UnauthorizedException>()
|
||||||
@@ -144,7 +144,7 @@ public class LoginHandlerTests
|
|||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(
|
await _handler.Handle(
|
||||||
new LoginQuery(username, "wrong-password"),
|
new LoginCommand(username, "wrong-password"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
|
||||||
using Domain.Entities;
|
|
||||||
using Features.Auth.Dtos;
|
|
||||||
using Features.Auth.Repository;
|
|
||||||
using FluentAssertions;
|
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Repository;
|
|
||||||
|
|
||||||
public class AuthRepositoryTests
|
|
||||||
{
|
|
||||||
private static AuthRepository CreateRepo(MockDbConnection conn)
|
|
||||||
{
|
|
||||||
return new AuthRepository(new TestConnectionFactory(conn));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
|
||||||
{
|
|
||||||
Guid expectedUserId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
|
|
||||||
|
|
||||||
// Mock the subsequent read for the newly created user by id
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("UserAccountId", typeof(Guid)),
|
|
||||||
("Username", typeof(string)),
|
|
||||||
("FirstName", typeof(string)),
|
|
||||||
("LastName", typeof(string)),
|
|
||||||
("Email", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("DateOfBirth", typeof(DateTime)),
|
|
||||||
("Timer", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
expectedUserId,
|
|
||||||
"testuser",
|
|
||||||
"Test",
|
|
||||||
"User",
|
|
||||||
"test@example.com",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
new DateTime(1990, 1, 1),
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserAccount result = await repo.RegisterUserAsync(
|
|
||||||
new UserRegistrationDto(
|
|
||||||
"testuser",
|
|
||||||
"Test",
|
|
||||||
"User",
|
|
||||||
"test@example.com",
|
|
||||||
new DateTime(1990, 1, 1),
|
|
||||||
"hashedpassword123"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result.UserAccountId.Should().Be(expectedUserId);
|
|
||||||
result.Username.Should().Be("testuser");
|
|
||||||
result.FirstName.Should().Be("Test");
|
|
||||||
result.LastName.Should().Be("User");
|
|
||||||
result.Email.Should().Be("test@example.com");
|
|
||||||
result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
|
||||||
{
|
|
||||||
Guid userId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("UserAccountId", typeof(Guid)),
|
|
||||||
("Username", typeof(string)),
|
|
||||||
("FirstName", typeof(string)),
|
|
||||||
("LastName", typeof(string)),
|
|
||||||
("Email", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("DateOfBirth", typeof(DateTime)),
|
|
||||||
("Timer", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
userId,
|
|
||||||
"emailuser",
|
|
||||||
"Email",
|
|
||||||
"User",
|
|
||||||
"emailuser@example.com",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
new DateTime(1990, 5, 15),
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.UserAccountId.Should().Be(userId);
|
|
||||||
result.Username.Should().Be("emailuser");
|
|
||||||
result.Email.Should().Be("emailuser@example.com");
|
|
||||||
result.FirstName.Should().Be("Email");
|
|
||||||
result.LastName.Should().Be("User");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
|
||||||
{
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
|
||||||
|
|
||||||
result.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
|
||||||
{
|
|
||||||
Guid userId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("UserAccountId", typeof(Guid)),
|
|
||||||
("Username", typeof(string)),
|
|
||||||
("FirstName", typeof(string)),
|
|
||||||
("LastName", typeof(string)),
|
|
||||||
("Email", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("DateOfBirth", typeof(DateTime)),
|
|
||||||
("Timer", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
userId,
|
|
||||||
"usernameuser",
|
|
||||||
"Username",
|
|
||||||
"User",
|
|
||||||
"username@example.com",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
new DateTime(1985, 8, 20),
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
|
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.UserAccountId.Should().Be(userId);
|
|
||||||
result.Username.Should().Be("usernameuser");
|
|
||||||
result.Email.Should().Be("username@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
|
||||||
{
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
|
|
||||||
|
|
||||||
result.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
|
||||||
{
|
|
||||||
Guid userId = Guid.NewGuid();
|
|
||||||
Guid credentialId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("UserCredentialId", typeof(Guid)),
|
|
||||||
("UserAccountId", typeof(Guid)),
|
|
||||||
("Hash", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("Timer", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
|
|
||||||
);
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.UserCredentialId.Should().Be(credentialId);
|
|
||||||
result.UserAccountId.Should().Be(userId);
|
|
||||||
result.Hash.Should().Be("hashed_password_value");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
|
||||||
{
|
|
||||||
Guid userId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
|
||||||
|
|
||||||
result.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
|
||||||
{
|
|
||||||
Guid userId = Guid.NewGuid();
|
|
||||||
string newPasswordHash = "new_hashed_password";
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
|
|
||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
|
||||||
|
|
||||||
// Should not throw
|
|
||||||
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
|
|
||||||
await act.Should().NotThrowAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System.Data.Common;
|
|
||||||
using Infrastructure.Sql;
|
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Repository;
|
|
||||||
|
|
||||||
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
|
||||||
{
|
|
||||||
private readonly DbConnection _conn = conn;
|
|
||||||
|
|
||||||
public DbConnection CreateConnection()
|
|
||||||
{
|
|
||||||
return _conn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Commands.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
||||||
/// from the request body of <c>POST /api/auth/login</c>.
|
/// from the request body of <c>POST /api/auth/login</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;
|
public record LoginCommand(string Username, string Password) : IRequest<LoginPayload>;
|
||||||
@@ -6,22 +6,22 @@ using Features.Auth.Services;
|
|||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Commands.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LoginHandler(
|
public class LoginHandler(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
ITokenService tokenService
|
ITokenService tokenService
|
||||||
) : IRequestHandler<LoginQuery, LoginPayload>
|
) : IRequestHandler<LoginCommand, LoginPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="UnauthorizedException">
|
/// <exception cref="UnauthorizedException">
|
||||||
/// Thrown when the username does not match any account, the account has no active credential,
|
/// Thrown when the username does not match any account, the account has no active credential,
|
||||||
/// or the supplied password does not match the stored hash.
|
/// or the supplied password does not match the stored hash.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
|
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
UserAccount user =
|
UserAccount user =
|
||||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Commands.Login;
|
||||||
|
|
||||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
|
||||||
|
public class LoginValidator : AbstractValidator<LoginCommand>
|
||||||
{
|
{
|
||||||
public LoginValidator()
|
public LoginValidator()
|
||||||
{
|
{
|
||||||
@@ -11,6 +11,10 @@ namespace Features.Auth.Commands.RefreshToken;
|
|||||||
public class RefreshTokenHandler(ITokenService tokenService)
|
public class RefreshTokenHandler(ITokenService tokenService)
|
||||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||||
{
|
{
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
|
||||||
|
/// longer exists.
|
||||||
|
/// </exception>
|
||||||
public async Task<LoginPayload> Handle(
|
public async Task<LoginPayload> Handle(
|
||||||
RefreshTokenCommand request,
|
RefreshTokenCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
|
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||||
{
|
{
|
||||||
public RefreshTokenValidator()
|
public RefreshTokenValidator()
|
||||||
|
|||||||
@@ -34,14 +34,18 @@ public class RegisterUserHandler(
|
|||||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||||
|
|
||||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||||
new UserRegistrationDto(
|
new UserAccount
|
||||||
request.Username,
|
{
|
||||||
request.FirstName,
|
Username = request.Username,
|
||||||
request.LastName,
|
FirstName = request.FirstName,
|
||||||
request.Email,
|
LastName = request.LastName,
|
||||||
request.DateOfBirth,
|
DateOfBirth = request.DateOfBirth,
|
||||||
hashed
|
Email = request.Email,
|
||||||
)
|
UserCredential = new UserCredential
|
||||||
|
{
|
||||||
|
Hash = hashed,
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and
|
||||||
|
/// length, name lengths, email format, minimum age of 19, and password complexity.
|
||||||
|
/// </summary>
|
||||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||||
{
|
{
|
||||||
public RegisterUserValidator()
|
public RegisterUserValidator()
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using Features.Auth.Commands.ConfirmUser;
|
using Features.Auth.Commands.ConfirmUser;
|
||||||
|
using Features.Auth.Commands.Login;
|
||||||
using Features.Auth.Commands.RefreshToken;
|
using Features.Auth.Commands.RefreshToken;
|
||||||
using Features.Auth.Commands.RegisterUser;
|
using Features.Auth.Commands.RegisterUser;
|
||||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Queries.Login;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -23,6 +23,11 @@ namespace Features.Auth.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
public class AuthController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>Registers a new user account.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
||||||
|
/// Returns 409 Conflict if the username or email is already taken.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||||
@@ -40,11 +45,15 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Authenticates a user by username and password and issues a new access/refresh token pair.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 200 OK with the tokens, or 401 Unauthorized if the credentials are invalid.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginCommand command)
|
||||||
{
|
{
|
||||||
LoginPayload payload = await mediator.Send(query);
|
LoginPayload payload = await mediator.Send(command);
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<LoginPayload>
|
new ResponseBody<LoginPayload>
|
||||||
{
|
{
|
||||||
@@ -54,6 +63,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Confirms a user account using a confirmation token.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Returns 200 OK on success,
|
||||||
|
/// or 401 Unauthorized if <paramref name="token" /> is invalid or expired.
|
||||||
|
/// </remarks>
|
||||||
[HttpPost("confirm")]
|
[HttpPost("confirm")]
|
||||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||||
[FromQuery] string token
|
[FromQuery] string token
|
||||||
@@ -69,6 +83,12 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resends the account confirmation email for the given user, generating a fresh confirmation token.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires a valid JWT bearer token (no <c>[AllowAnonymous]</c> override). Always returns 200 OK, even
|
||||||
|
/// if <paramref name="userId" /> does not exist or the account is already confirmed, to avoid user
|
||||||
|
/// enumeration.
|
||||||
|
/// </remarks>
|
||||||
[HttpPost("confirm/resend")]
|
[HttpPost("confirm/resend")]
|
||||||
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
||||||
{
|
{
|
||||||
@@ -76,6 +96,11 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
|
||||||
|
/// invalid or expired.
|
||||||
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Auth.DependencyInjection;
|
namespace Features.Auth.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
|
||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
|
||||||
|
/// infrastructure as scoped services.
|
||||||
|
/// </summary>
|
||||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Features.Auth.Dtos;
|
|
||||||
|
|
||||||
public record UserRegistrationDto(
|
|
||||||
string Username,
|
|
||||||
string FirstName,
|
|
||||||
string LastName,
|
|
||||||
string Email,
|
|
||||||
DateTime DateOfBirth,
|
|
||||||
string PasswordHash
|
|
||||||
);
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<RootNamespace>Features.Auth</RootNamespace>
|
<RootNamespace>Features.Auth</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -19,4 +20,8 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Queries\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
|
using Dapper;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
@@ -8,155 +9,184 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Features.Auth.Repository;
|
namespace Features.Auth.Repository;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
|
/// Dapper-based implementation of <see cref="IAuthRepository" />, handling user registration,
|
||||||
/// handling user registration, credential lookup/rotation, and account verification.
|
/// credential lookup/rotation, and account verification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<UserAccount>(connectionFactory),
|
: Repository<UserAccount>(connectionFactory),
|
||||||
IAuthRepository
|
IAuthRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
|
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||||
/// procedure, then fetches and returns the newly created user.
|
public async Task<UserAccount> RegisterUserAsync(UserAccount ua)
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
|
|
||||||
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
|
|
||||||
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
|
|
||||||
/// subsequent lookup to fail.
|
|
||||||
/// </remarks>
|
|
||||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
|
||||||
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||||
|
|
||||||
command.CommandText = "USP_RegisterUser";
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
|
Guid userAccountId;
|
||||||
|
|
||||||
AddParameter(command, "@Username", username);
|
try
|
||||||
AddParameter(command, "@FirstName", firstName);
|
|
||||||
AddParameter(command, "@LastName", lastName);
|
|
||||||
AddParameter(command, "@Email", email);
|
|
||||||
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
|
||||||
AddParameter(command, "@Hash", passwordHash);
|
|
||||||
|
|
||||||
object? result = await command.ExecuteScalarAsync();
|
|
||||||
|
|
||||||
Guid userAccountId = Guid.Empty;
|
|
||||||
if (result != null && result != DBNull.Value)
|
|
||||||
{
|
{
|
||||||
if (result is Guid g)
|
userAccountId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
userAccountId = g;
|
new CommandDefinition(
|
||||||
else if (result is string s && Guid.TryParse(s, out Guid parsed))
|
"""
|
||||||
userAccountId = parsed;
|
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
|
||||||
else if (result is byte[] bytes && bytes.Length == 16)
|
OUTPUT INSERTED.UserAccountID
|
||||||
userAccountId = new Guid(bytes);
|
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||||
else
|
""",
|
||||||
// Fallback: try to convert and parse string representation
|
new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email},
|
||||||
try
|
transaction
|
||||||
{
|
)
|
||||||
string? str = result.ToString();
|
);
|
||||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
|
|
||||||
userAccountId = p;
|
int credentialRows = await connection.ExecuteAsync(
|
||||||
}
|
new CommandDefinition(
|
||||||
catch
|
"""
|
||||||
{
|
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||||
userAccountId = Guid.Empty;
|
VALUES (@UserAccountId, @Hash);
|
||||||
}
|
""",
|
||||||
|
new { UserAccountId = userAccountId, Hash = ua.UserCredential?.Hash },
|
||||||
|
transaction
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (credentialRows == 0)
|
||||||
|
throw new InvalidOperationException("Failed to create user credential.");
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// perform round trip to get new User entity
|
||||||
return await GetUserByIdAsync(userAccountId)
|
return await GetUserByIdAsync(userAccountId)
|
||||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||||
command.CommandText = "usp_GetUserAccountByEmail";
|
"""
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||||
|
FROM dbo.UserAccount
|
||||||
AddParameter(command, "@Email", email);
|
WHERE Email = @Email
|
||||||
|
""",
|
||||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
new { Email = email }
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||||
command.CommandText = "usp_GetUserAccountByUsername";
|
"""
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||||
|
FROM dbo.UserAccount
|
||||||
AddParameter(command, "@Username", username);
|
WHERE Username = @Username
|
||||||
|
""",
|
||||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
new { Username = username }
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Retrieves the active (non-revoked) credential for a user account using the
|
|
||||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
return await connection.QueryFirstOrDefaultAsync<UserCredential>(
|
||||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
"""
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion
|
||||||
|
FROM dbo.UserCredential
|
||||||
AddParameter(command, "@UserAccountId", userAccountId);
|
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||||
|
""",
|
||||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
new { UserAccountId = userAccountId }
|
||||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
|
||||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||||
command.CommandText = "USP_RotateUserCredential";
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
AddParameter(command, "@UserAccountId_", userAccountId);
|
try
|
||||||
AddParameter(command, "@Hash", newPasswordHash);
|
{
|
||||||
|
bool exists =
|
||||||
|
await connection.ExecuteScalarAsync<int?>(
|
||||||
|
new CommandDefinition(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE UserAccountID = @UserAccountId
|
||||||
|
""",
|
||||||
|
new { UserAccountId = userAccountId },
|
||||||
|
transaction
|
||||||
|
)
|
||||||
|
) is not null;
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
if (!exists)
|
||||||
|
throw new NotFoundException("User account not found.");
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"""
|
||||||
|
UPDATE dbo.UserCredential
|
||||||
|
SET IsRevoked = 1, RevokedAt = GETDATE()
|
||||||
|
WHERE UserAccountId = @UserAccountId
|
||||||
|
""",
|
||||||
|
new { UserAccountId = userAccountId },
|
||||||
|
transaction
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"""
|
||||||
|
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||||
|
VALUES (@UserAccountId, @Hash)
|
||||||
|
""",
|
||||||
|
new { UserAccountId = userAccountId, Hash = newPasswordHash },
|
||||||
|
transaction
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||||
command.CommandText = "usp_GetUserAccountById";
|
"""
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||||
|
FROM dbo.UserAccount
|
||||||
AddParameter(command, "@UserAccountId", userAccountId);
|
WHERE UserAccountID = @UserAccountId
|
||||||
|
""",
|
||||||
await using DbDataReader reader = await command.ExecuteReaderAsync();
|
new { UserAccountId = userAccountId }
|
||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Marks a user account as confirmed by creating a verification record via the
|
/// <remarks>
|
||||||
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
|
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. If a concurrent
|
||||||
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
/// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627)
|
||||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
/// is swallowed to keep the operation idempotent.
|
||||||
/// </summary>
|
/// </remarks>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||||
/// Thrown when the database command fails for a reason other than
|
/// Thrown when the database command fails for a reason other than a duplicate verification record.
|
||||||
/// a duplicate verification record.
|
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
|
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
@@ -169,15 +199,16 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return user;
|
return user;
|
||||||
|
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
|
||||||
command.CommandText = "USP_CreateUserVerification";
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
AddParameter(command, "@UserAccountID_", userAccountId);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await command.ExecuteNonQueryAsync();
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
|
||||||
|
VALUES (@UserAccountId, GETDATE())
|
||||||
|
""",
|
||||||
|
new { UserAccountId = userAccountId }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
||||||
{
|
{
|
||||||
@@ -188,19 +219,19 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
|
/// <inheritdoc />
|
||||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
int? result = await connection.ExecuteScalarAsync<int?>(
|
||||||
command.CommandText =
|
"""
|
||||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
SELECT TOP 1 1
|
||||||
command.CommandType = CommandType.Text;
|
FROM dbo.UserVerification
|
||||||
|
WHERE UserAccountID = @UserAccountId
|
||||||
AddParameter(command, "@UserAccountID", userAccountId);
|
""",
|
||||||
|
new { UserAccountId = userAccountId }
|
||||||
object? result = await command.ExecuteScalarAsync();
|
);
|
||||||
return result != null && result != DBNull.Value;
|
return result.HasValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||||
@@ -209,67 +240,4 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
// 2601/2627 are duplicate key violations in SQL Server.
|
// 2601/2627 are duplicate key violations in SQL Server.
|
||||||
return ex.Number == 2601 || ex.Number == 2627;
|
return ex.Number == 2601 || ex.Number == 2627;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override UserAccount MapToEntity(DbDataReader reader)
|
|
||||||
{
|
|
||||||
return new UserAccount
|
|
||||||
{
|
|
||||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
|
||||||
Username = reader.GetString(reader.GetOrdinal("Username")),
|
|
||||||
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
|
|
||||||
LastName = reader.GetString(reader.GetOrdinal("LastName")),
|
|
||||||
Email = reader.GetString(reader.GetOrdinal("Email")),
|
|
||||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
|
||||||
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
|
|
||||||
? null
|
|
||||||
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
|
||||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
|
||||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
|
||||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
|
||||||
/// </summary>
|
|
||||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
|
||||||
{
|
|
||||||
UserCredential entity = new()
|
|
||||||
{
|
|
||||||
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
|
|
||||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
|
||||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
|
||||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional columns
|
|
||||||
bool hasTimer =
|
|
||||||
reader
|
|
||||||
.GetSchemaTable()
|
|
||||||
?.Rows.Cast<DataRow>()
|
|
||||||
.Any(r =>
|
|
||||||
string.Equals(
|
|
||||||
r["ColumnName"]?.ToString(),
|
|
||||||
"Timer",
|
|
||||||
StringComparison.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
)
|
|
||||||
?? false;
|
|
||||||
|
|
||||||
if (hasTimer)
|
|
||||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
|
||||||
? null
|
|
||||||
: (byte[])reader["Timer"];
|
|
||||||
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
|
|
||||||
private static void AddParameter(DbCommand command, string name, object? value)
|
|
||||||
{
|
|
||||||
DbParameter p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,32 @@ namespace Features.Auth.Repository;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuthRepository
|
public interface IAuthRepository
|
||||||
{
|
{
|
||||||
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
/// <summary>
|
||||||
|
/// Registers a new user account and its initial credential as a single atomic operation, then
|
||||||
|
/// returns the created account.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||||
|
Task<UserAccount> RegisterUserAsync(UserAccount ua);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||||
|
|
||||||
|
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
|
||||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>Determines whether a user account has been verified.</summary>
|
||||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
|
/// <summary>1 hour.</summary>
|
||||||
public const double AccessTokenHours = 1;
|
public const double AccessTokenHours = 1;
|
||||||
|
|
||||||
/// <summary>21 days.</summary>
|
/// <summary>21 days.</summary>
|
||||||
@@ -43,10 +44,22 @@ public static class TokenServiceExpirationHours
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed access token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed refresh token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a signed confirmation token for the given user, expiring after
|
||||||
|
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||||
|
/// </summary>
|
||||||
string GenerateConfirmationToken(UserAccount user);
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
||||||
@@ -54,12 +67,28 @@ public interface ITokenService
|
|||||||
string GenerateToken<T>(UserAccount user)
|
string GenerateToken<T>(UserAccount user)
|
||||||
where T : struct, Enum;
|
where T : struct, Enum;
|
||||||
|
|
||||||
|
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>Validates a refresh token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>Validates a confirmation token's signature and expiration and extracts the caller's identity.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid or expired, or when the user account it refers to no
|
||||||
|
/// longer exists.
|
||||||
|
/// </exception>
|
||||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
@@ -59,6 +60,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -72,6 +74,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -85,12 +88,7 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
|
||||||
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
|
||||||
/// <see cref="GenerateConfirmationToken" />.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -114,33 +112,25 @@ public class TokenService : ITokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
{
|
{
|
||||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <inheritdoc />
|
||||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
|
||||||
/// </exception>
|
|
||||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
{
|
{
|
||||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
|||||||
20
web/backend/Features/Features.Beers/Features.Beers.csproj
Normal file
20
web/backend/Features/Features.Beers/Features.Beers.csproj
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Features.Breweries</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -21,6 +21,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
{
|
{
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
"Renamed",
|
"Renamed",
|
||||||
"New description",
|
"New description",
|
||||||
@@ -31,7 +32,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
DateTime before = DateTime.UtcNow;
|
DateTime before = DateTime.UtcNow;
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -42,6 +43,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
persisted.PostedById.Should().Be(command.PostedById);
|
persisted.PostedById.Should().Be(command.PostedById);
|
||||||
persisted.BreweryName.Should().Be("Renamed");
|
persisted.BreweryName.Should().Be("Renamed");
|
||||||
persisted.Description.Should().Be("New description");
|
persisted.Description.Should().Be("New description");
|
||||||
|
persisted.RowVersion.Should().Equal(command.RowVersion);
|
||||||
persisted.UpdatedAt.Should().NotBeNull();
|
persisted.UpdatedAt.Should().NotBeNull();
|
||||||
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
{
|
{
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
"Name",
|
"Name",
|
||||||
"Description",
|
"Description",
|
||||||
@@ -61,7 +64,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
);
|
);
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
"Name",
|
"Name",
|
||||||
"Description",
|
"Description",
|
||||||
@@ -91,7 +95,7 @@ public class UpdateBreweryHandlerTests
|
|||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
<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="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
|
||||||
using Domain.Entities;
|
|
||||||
using Features.Breweries.Repository;
|
|
||||||
using FluentAssertions;
|
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Repository;
|
|
||||||
|
|
||||||
public class BreweryRepositoryTests
|
|
||||||
{
|
|
||||||
private static BreweryRepository CreateRepo(MockDbConnection conn)
|
|
||||||
{
|
|
||||||
return new BreweryRepository(new TestConnectionFactory(conn));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
|
||||||
{
|
|
||||||
Guid breweryId = Guid.NewGuid();
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
|
|
||||||
// Repository calls the stored procedure
|
|
||||||
const string getByIdSql = "USP_GetBreweryById";
|
|
||||||
|
|
||||||
Guid locationId = Guid.NewGuid();
|
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
|
||||||
.ReturnsTable(
|
|
||||||
MockTable
|
|
||||||
.WithColumns(
|
|
||||||
("BreweryPostId", typeof(Guid)),
|
|
||||||
("PostedById", typeof(Guid)),
|
|
||||||
("BreweryName", typeof(string)),
|
|
||||||
("Description", typeof(string)),
|
|
||||||
("CreatedAt", typeof(DateTime)),
|
|
||||||
("UpdatedAt", typeof(DateTime?)),
|
|
||||||
("Timer", typeof(byte[])),
|
|
||||||
("BreweryPostLocationId", typeof(Guid)),
|
|
||||||
("CityId", typeof(Guid)),
|
|
||||||
("AddressLine1", typeof(string)),
|
|
||||||
("AddressLine2", typeof(string)),
|
|
||||||
("PostalCode", typeof(string)),
|
|
||||||
("Coordinates", typeof(byte[]))
|
|
||||||
)
|
|
||||||
.AddRow(
|
|
||||||
breweryId,
|
|
||||||
Guid.NewGuid(),
|
|
||||||
"Test Brewery",
|
|
||||||
"A test brewery description",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
locationId,
|
|
||||||
Guid.NewGuid(),
|
|
||||||
"123 Main St",
|
|
||||||
null,
|
|
||||||
"12345",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
BreweryRepository repo = CreateRepo(conn);
|
|
||||||
BreweryPost? result = await repo.GetByIdAsync(breweryId);
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
result!.BreweryPostId.Should().Be(breweryId);
|
|
||||||
result.Location.Should().NotBeNull();
|
|
||||||
result.Location!.BreweryPostLocationId.Should().Be(locationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
|
||||||
{
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
|
||||||
BreweryRepository repo = CreateRepo(conn);
|
|
||||||
BreweryPost? result = await repo.GetByIdAsync(Guid.NewGuid());
|
|
||||||
result.Should().BeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_ExecutesSuccessfully()
|
|
||||||
{
|
|
||||||
MockDbConnection conn = new();
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery").ReturnsScalar(1);
|
|
||||||
BreweryRepository repo = CreateRepo(conn);
|
|
||||||
BreweryPost brewery = new()
|
|
||||||
{
|
|
||||||
BreweryPostId = Guid.NewGuid(),
|
|
||||||
PostedById = Guid.NewGuid(),
|
|
||||||
BreweryName = "Test Brewery",
|
|
||||||
Description = "A test brewery description",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
Location = new BreweryPostLocation
|
|
||||||
{
|
|
||||||
BreweryPostLocationId = Guid.NewGuid(),
|
|
||||||
CityId = Guid.NewGuid(),
|
|
||||||
AddressLine1 = "123 Main St",
|
|
||||||
PostalCode = "12345",
|
|
||||||
Coordinates = [0x00, 0x01],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw
|
|
||||||
Func<Task> act = async () => await repo.CreateAsync(brewery);
|
|
||||||
await act.Should().NotThrowAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System.Data.Common;
|
|
||||||
using Infrastructure.Sql;
|
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Repository;
|
|
||||||
|
|
||||||
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
|
||||||
{
|
|
||||||
private readonly DbConnection _conn = conn;
|
|
||||||
|
|
||||||
public DbConnection CreateConnection()
|
|
||||||
{
|
|
||||||
return _conn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Location details for a brewery being created.</summary>
|
||||||
|
/// <param name="Coordinates">Raw binary representation of the brewery's geographic coordinates.</param>
|
||||||
public record CreateBreweryLocation(
|
public record CreateBreweryLocation(
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
string AddressLine1,
|
string AddressLine1,
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post.</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 a new brewery post, generating new identifiers for the post and its location.</summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when <paramref name="request" />'s <c>PostedById</c> or <c>Location.CityId</c> does not exist.
|
||||||
|
/// </exception>
|
||||||
public async Task<BreweryDto> Handle(
|
public async Task<BreweryDto> Handle(
|
||||||
CreateBreweryCommand request,
|
CreateBreweryCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Validates <see cref="CreateBreweryCommand" />.</summary>
|
||||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||||
{
|
{
|
||||||
public CreateBreweryValidator()
|
public CreateBreweryValidator()
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
|
||||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="DeleteBreweryCommand" /> by deleting the corresponding brewery post.</summary>
|
||||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<DeleteBreweryCommand>
|
: IRequestHandler<DeleteBreweryCommand>
|
||||||
{
|
{
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
|
||||||
|
/// </exception>
|
||||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return repository.DeleteAsync(request.BreweryPostId);
|
return repository.DeleteAsync(request.BreweryPostId);
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Location details for a brewery being updated.</summary>
|
||||||
|
/// <param name="BreweryPostLocationId">
|
||||||
|
/// Identifier of the existing location row, if any; a new row is created if none exists yet.
|
||||||
|
/// </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,
|
||||||
@@ -14,10 +19,13 @@ public record UpdateBreweryLocation(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
|
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
|
||||||
/// A <c>null</c> <see cref="Location" /> clears the brewery's location.
|
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the
|
||||||
|
/// row-version last read for this brewery (e.g. from a prior <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,
|
||||||
|
byte[] RowVersion,
|
||||||
Guid PostedById,
|
Guid PostedById,
|
||||||
string BreweryName,
|
string BreweryName,
|
||||||
string Description,
|
string Description,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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>
|
||||||
public class UpdateBreweryHandler(IBreweryRepository repository)
|
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,14 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
|
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
|
||||||
/// the brewery's location is cleared.
|
/// the brewery's location is cleared.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
|
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>, or its
|
||||||
|
/// location's <c>CityId</c> does not exist.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="Domain.Exceptions.ConflictException">
|
||||||
|
/// Thrown when the brewery was modified by another request since <paramref name="request" />.
|
||||||
|
/// <c>RowVersion</c> was read.
|
||||||
|
/// </exception>
|
||||||
public async Task<BreweryDto> Handle(
|
public async Task<BreweryDto> Handle(
|
||||||
UpdateBreweryCommand request,
|
UpdateBreweryCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
@@ -23,6 +32,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
PostedById = request.PostedById,
|
PostedById = request.PostedById,
|
||||||
BreweryName = request.BreweryName,
|
BreweryName = request.BreweryName,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
|
RowVersion = request.RowVersion,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
Location = request.Location is null
|
Location = request.Location is null
|
||||||
? null
|
? null
|
||||||
@@ -38,7 +48,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await repository.UpdateAsync(entity);
|
BreweryPost updated = await repository.UpdateAsync(entity);
|
||||||
return entity.ToDto();
|
return updated.ToDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>Validates <see cref="UpdateBreweryCommand" />.</summary>
|
||||||
|
public class UpdateBreweryValidator : AbstractValidator<UpdateBreweryCommand>
|
||||||
|
{
|
||||||
|
public UpdateBreweryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RowVersion)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("RowVersion is required to detect conflicting concurrent updates.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user