mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
update stale docs
This commit is contained in:
@@ -71,7 +71,6 @@ MediatR/DI, never over HTTP:
|
|||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ Database (SQL Server) │
|
│ Database (SQL Server) │
|
||||||
│ - Stored procedures │
|
|
||||||
│ - Tables & constraints │
|
│ - Tables & constraints │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -121,7 +120,7 @@ own
|
|||||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
||||||
each containing the Command/Query record, its `IRequestHandler`, and (for
|
each containing the Command/Query record, its `IRequestHandler`, and (for
|
||||||
commands) a FluentValidation validator
|
commands) a FluentValidation validator
|
||||||
- `Repository/`: the slice's own stored-procedure repository implementation
|
- `Repository/`: the slice's own Dapper repository implementation
|
||||||
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
||||||
entity)
|
entity)
|
||||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
||||||
@@ -208,6 +207,11 @@ whichever slices need them
|
|||||||
- `BreweryPost` - A user-submitted brewery listing
|
- `BreweryPost` - A user-submitted brewery listing
|
||||||
- `BreweryPostLocation` - A brewery's address and coordinates
|
- `BreweryPostLocation` - A brewery's address and coordinates
|
||||||
- `City` - A city referenced by a brewery location
|
- `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**:
|
||||||
|
|
||||||
@@ -301,15 +305,16 @@ it, without an ORM's change-tracking or mapping magic
|
|||||||
|
|
||||||
**Strategy**:
|
**Strategy**:
|
||||||
|
|
||||||
- No ORM (Entity Framework not used); each
|
- No ORM (Entity Framework not used); each repository issues inline SQL
|
||||||
repository issues inline SQL (a mix of Dapper and raw
|
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
||||||
`DbCommand`/`DbDataReader` for the few reads that need manual column mapping,
|
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
||||||
such as the `GEOGRAPHY` column on `BreweryPostLocation`)
|
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
|
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||||
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||||
repository method, run inside the same transaction as the write
|
repository method, run inside the same transaction as the write
|
||||||
- Optimistic concurrency uses each table's `Timer` (`ROWVERSION`) column,
|
- Optimistic concurrency uses each table's `RowVersion` (`ROWVERSION`) column,
|
||||||
checked in the `UPDATE ... WHERE ... AND Timer = @Timer` clause
|
checked in the `UPDATE ... WHERE ... AND RowVersion = @RowVersion` clause
|
||||||
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
- Repositories throw `Domain.Exceptions` types (`NotFoundException`,
|
||||||
`ConflictException`, ...) directly when a check fails, rather than relying on
|
`ConflictException`, ...) directly when a check fails, rather than relying on
|
||||||
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
a database-side `THROW`; `API.Core`'s `GlobalExceptionFilter` maps those
|
||||||
@@ -436,23 +441,22 @@ for reference only. Active product and engineering documentation should point to
|
|||||||
|
|
||||||
## Database architecture
|
## Database architecture
|
||||||
|
|
||||||
For the table-by-table schema, the stored procedure reference (parameters,
|
For the table-by-table schema and the custom SQL error code scheme, see
|
||||||
behavior, and exactly what each one throws), and the custom SQL error code
|
[Database](website/database.md).
|
||||||
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
|
||||||
|
|
||||||
@@ -488,14 +492,15 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
|
|||||||
|
|
||||||
**Seed Data**:
|
**Seed Data**:
|
||||||
|
|
||||||
- Cities (via `Features.Locations`' `ILocationRepository`)
|
- Countries, state/provinces, and cities (via `Features.Locations`'
|
||||||
|
`ILocationRepository`, which creates `Country`/`StateProvince` rows on
|
||||||
|
demand while resolving a city)
|
||||||
- User accounts (via `Features.Auth`'s repository)
|
- User accounts (via `Features.Auth`'s repository)
|
||||||
- Brewery posts with locations (via `Features.Breweries`' repository)
|
- Brewery posts with locations (via `Features.Breweries`' repository)
|
||||||
|
|
||||||
Tables that exist in the schema but have no corresponding `Domain.Entities` type
|
Tables that exist in the schema but have no repository yet (`Photo`,
|
||||||
or feature slice yet (`Photo`, `UserAvatar`, `UserFollow`, `Country`,
|
`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`,
|
||||||
`StateProvince`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
|
`BeerPostComment`, `BreweryPostPhoto`) are not seeded.
|
||||||
`BreweryPostPhoto`) are not seeded.
|
|
||||||
|
|
||||||
## Deployment architecture
|
## Deployment architecture
|
||||||
|
|
||||||
@@ -543,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)
|
||||||
└──────────────┘
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ owning feature slice's repository (see
|
|||||||
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
|
||||||
|
|
||||||
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
|
||||||
most carry a `Timer ROWVERSION` column used for optimistic concurrency on
|
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
|
||||||
updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
|
updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||||
|
|
||||||
### Tables backed by the API today
|
### Tables backed by the API today
|
||||||
|
|
||||||
@@ -24,23 +24,23 @@ updates (`UPDATE ... WHERE ... AND Timer = @Timer`).
|
|||||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
| `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 |
|
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
|
||||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read manually via `DbDataReader.GetFieldValue<byte[]>` because Dapper can't deserialize SQL Server UDTs |
|
| `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`; looked up via `ILocationRepository`, currently consumed only by `Database.Seed` |
|
| `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
|
### 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
|
These exist so the schema can support planned functionality, but have no
|
||||||
`Domain.Entities` type, repository, or seed data yet:
|
repository or seed data yet:
|
||||||
|
|
||||||
| Table | Purpose (from schema) |
|
| Table | Purpose (from schema) |
|
||||||
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
| ------------------ | -------------------------------------------------------------------------------------------------------- |
|
||||||
| `Photo` | A photo uploaded by a user account |
|
| `Photo` | A photo uploaded by a user account |
|
||||||
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
|
||||||
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
|
||||||
| `StateProvince` | Referenced by `City.StateProvinceID` |
|
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
|
||||||
| `Country` | Referenced by `StateProvince.CountryID` |
|
| `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 |
|
||||||
| `BeerStyle` | A beer style/category |
|
|
||||||
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`) |
|
|
||||||
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
|
||||||
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
|
||||||
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
|
||||||
@@ -58,14 +58,15 @@ already-applied script means it won't re-run against existing databases.
|
|||||||
## Data seeding
|
## Data seeding
|
||||||
|
|
||||||
`Database.Seed` populates a target database from data produced by the C++
|
`Database.Seed` populates a target database from data produced by the C++
|
||||||
pipeline (see [Pipeline README](../pipeline/README.md)): cities, user accounts,
|
pipeline (see [Pipeline README](../pipeline/README.md)): countries,
|
||||||
and brewery posts with locations. It only seeds tables that have a feature slice
|
state/provinces, and cities (created on demand while resolving each brewery's
|
||||||
to write through (see the table above).
|
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
|
## Database error handling
|
||||||
|
|
||||||
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
- Optimistic-concurrency conflicts are detected by an `UPDATE` affecting zero
|
||||||
rows against the `Timer` column.
|
rows against the `RowVersion` column.
|
||||||
- On failure, the repository throws a `Domain.Exceptions` type directly
|
- On failure, the repository throws a `Domain.Exceptions` type directly
|
||||||
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
(`NotFoundException`, `ConflictException`, `ArgumentException`, ...).
|
||||||
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
`API.Core`'s `GlobalExceptionFilter` maps these to HTTP status codes (404,
|
||||||
|
|||||||
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
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 71 KiB |
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ skinparam linetype ortho
|
|||||||
skinparam packageStyle rectangle
|
skinparam packageStyle rectangle
|
||||||
|
|
||||||
title Database Schema
|
title Database Schema
|
||||||
caption Every table also has a Timer ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no CRUD stored procedures yet.
|
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
|
||||||
|
|
||||||
package "User & Auth" {
|
package "User & Auth" {
|
||||||
entity "UserAccount" as User {
|
entity "UserAccount" as User {
|
||||||
@@ -190,20 +190,18 @@ Beer ||--o{ BeerComment : "has"
|
|||||||
User ||--o{ BeerComment : "commented"
|
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_UpdateUserAccount
|
UserAccountRepository) - registration, credential
|
||||||
- usp_DeleteUserAccount
|
rotation, and verification each issue their own
|
||||||
- USP_RotateUserCredential
|
inline SQL, no stored procedures
|
||||||
- USP_CreateUserVerification
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of Brewery
|
note bottom of Brewery
|
||||||
Core stored procedures:
|
Backed by Features.Breweries' BreweryRepository
|
||||||
- USP_CreateBrewery
|
(Dapper); create/update/delete run inline SQL
|
||||||
- USP_UpdateBrewery
|
inside a transaction, with optimistic concurrency
|
||||||
- USP_DeleteBrewery
|
via RowVersion
|
||||||
(optimistic concurrency via Timer)
|
|
||||||
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
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ The project uses a multi-layered testing approach across backend and frontend:
|
|||||||
- **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
|
||||||
@@ -77,8 +77,8 @@ 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 handlers
|
Each feature slice has its own test project, covering its command/query
|
||||||
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user