32 Commits

Author SHA1 Message Date
Aaron Po
3711591db1 commit csharpier config update 2026-06-20 15:54:53 -04:00
Aaron Po
194509f9c9 update mailkit 2026-06-20 15:54:53 -04:00
Aaron Po
67d6350425 Format ./web/backend/Shared/Shared.Contracts 2026-06-20 15:54:53 -04:00
Aaron Po
cee2917556 Format ./web/backend/Shared/Shared.Application 2026-06-20 15:54:53 -04:00
Aaron Po
6f8e5dc722 Format ./web/backend/API/API.Specs 2026-06-20 15:54:53 -04:00
Aaron Po
db3ed3581d Format ./web/backend/API/API.Core 2026-06-20 15:54:53 -04:00
Aaron Po
b54f70ccbe Format ./web/backend/Domain/Domain.Exceptions 2026-06-20 15:54:53 -04:00
Aaron Po
d4734ae9e7 Format ./web/backend/Domain/Domain.Entities 2026-06-20 15:54:53 -04:00
Aaron Po
27f28d5207 Format ./web/backend/Database/Database.Seed 2026-06-20 15:54:53 -04:00
Aaron Po
bf0d29fa54 Format ./web/backend/Database/Database.Migrations 2026-06-20 15:54:53 -04:00
Aaron Po
4772f59a68 Format ./web/backend/Infrastructure/Infrastructure.Sql 2026-06-20 15:54:53 -04:00
Aaron Po
2e5d4923c0 Format ./web/backend/Infrastructure/Infrastructure.PasswordHashing 2026-06-20 15:54:53 -04:00
Aaron Po
62766b7638 Format ./web/backend/Infrastructure/Infrastructure.Jwt 2026-06-20 15:54:53 -04:00
Aaron Po
744d4c7f8c Format ./web/backend/Infrastructure/Infrastructure.Email.Templates 2026-06-20 15:54:53 -04:00
Aaron Po
2f28ac9350 Format ./web/backend/Infrastructure/Infrastructure.Email 2026-06-20 15:54:53 -04:00
Aaron Po
c8bddae817 Format ./web/backend/Features/Features.UserManagement.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
b298bd02d3 Format ./web/backend/Features/Features.UserManagement 2026-06-20 15:54:53 -04:00
Aaron Po
e2e5eb89e3 Format ./web/backend/Features/Features.Emails.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
3dd44fca5a Format ./web/backend/Features/Features.Emails 2026-06-20 15:54:53 -04:00
Aaron Po
0c55d6c83f Format ./web/backend/Features/Features.Breweries.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
58cbc5b5ba Format ./web/backend/Features/Features.Breweries 2026-06-20 15:54:53 -04:00
Aaron Po
5bef24696b Format ./web/backend/Features/Features.Auth.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
79ae18b3e2 Format ./web/backend/Features/Features.Auth 2026-06-20 15:54:53 -04:00
Aaron Po
254431928f code style cleanup 2026-06-20 15:54:53 -04:00
Aaron Po
07aedcb866 Update documentation to reflect new architecture and previous refactors 2026-06-20 15:54:53 -04:00
Aaron Po
fd341e332c Migrate Auth and Emails to vertical slices (Features.Auth, Features.Emails)
Auth and Emails land together since Auth's registration/resend flows need
Emails to exist first. Service.Auth's four services (Register, Login,
Confirmation, Token) collapse into MediatR commands/queries in
Features.Auth; TokenService stays as a slice-internal service since
multiple handlers call it. Auth no longer references Emails directly —
it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand
(defined in Shared.Application) which Features.Emails handles, so neither
slice has a project reference on the other.

Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher,
simplified to take (firstName, email, token) instead of a full UserAccount
since that's all it ever used. API.Specs' TestApiFactory/MockEmailService
are updated to swap in the relocated interface.

Also deletes Infrastructure.Repository now that Breweries, UserManagement,
and Auth have all moved their repos out of it, and replaces the two
now-dead docker-compose test services (repository.tests, service.auth.tests,
both pointing at deleted Dockerfiles) with a single unit.tests service that
runs every Features.*.Tests project via Dockerfile.tests.
2026-06-20 15:54:53 -04:00
Aaron Po
5cf4df1fd8 Migrate UserManagement to a vertical slice (Features.UserManagement)
Same pattern as the Breweries migration: Service.UserManagement and the
UserAccount repository fold into Features.UserManagement, with MediatR
queries replacing UserService. UpdateUserCommand/Handler carry forward
IUserService.UpdateAsync as-is (it has no HTTP route today, and adding one
is a separate decision from this migration). Adds handler/repository test
coverage that didn't exist before, since Service.UserManagement had no
test project.
2026-06-20 15:54:53 -04:00
Aaron Po
34ba7e8271 Migrate Breweries to a vertical slice (Features.Breweries)
Moves brewery CRUD from the Service.Breweries/Infrastructure.Repository
layered split into a single Features.Breweries project: MediatR
commands/queries replace BreweryService, and the repository moves in
alongside its own controller. Extracts Infrastructure.Sql out of
Infrastructure.Repository (just the generic ADO.NET connection/base-repo
plumbing) since Breweries no longer needs the rest of that project, while
Auth and UserAccount repos still do until their own migration.

Also fixes the API.Core and Infrastructure.Repository.Tests Dockerfiles,
which were restoring against COPY destinations that didn't match the
projects' actual relative paths (silently working only because of the
COPY . . that followed); both now restore against Core.slnx with correctly
nested paths, verified with a real docker build.
2026-06-20 15:54:53 -04:00
Aaron Po
60a7b790d4 Scaffold vertical-slice migration: Shared.Contracts, Shared.Application, MediatR
Begins the move to vertical-slice architecture. ResponseBody moves out of
API.Core into Shared.Contracts so slices won't have to depend on the API
host project for it. Shared.Application adds the MediatR pipeline's
ValidationBehavior plus the cross-slice email commands that Features.Auth
will send and Features.Emails will handle, keeping slices decoupled from
each other.
2026-06-20 15:54:53 -04:00
Aaron Po
4554fff534 add tests 2026-06-20 15:54:53 -04:00
Aaron Po
86a0c50f6e finish brewery feature set 2026-06-20 15:54:53 -04:00
Aaron Po
3034020d56 add xmldoc comments 2026-06-20 15:54:53 -04:00
231 changed files with 15710 additions and 13463 deletions

View File

@@ -41,7 +41,7 @@ Data generation pipeline (C++):
Active areas in the repository:
- .NET 10 backend (layered architecture) + SQL Server
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
- React 19 website (React Router 7 + Vite)
- Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose)
@@ -105,7 +105,8 @@ npm run test:storybook:playwright
```text
web/
backend/ .NET API + domain/service/infrastructure + DB projects
backend/ .NET API: API.Core (host), Features.* (vertical slices),
Shared.*, Domain.*, Infrastructure.*, Database.* projects
frontend/ React Router website + Storybook + Playwright/Vitest
tooling/

View File

@@ -7,61 +7,65 @@ This document describes the active architecture of The Biergarten App.
The Biergarten App is a monorepo with a clear split between the backend and the
active website:
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
- **Frontend**: React 19 + React Router 7 website in `src/Website`
- **Architecture Style**: Layered backend plus server-rendered React frontend
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
slices with MediatR
- **Frontend**: React 19 + React Router 7 website in `web/frontend`
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
The legacy Next.js frontend has been retained in `src/Website-v1` for reference
only and is documented in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
for reference only.
## Diagrams
For visual representations, see:
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture
- [architecture.svg](website/diagrams-out/architecture.svg) - Vertical-slice
architecture diagram
- [deployment.svg](website/diagrams-out/deployment.svg) - Docker deployment
diagram
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) -
- [authentication-flow.svg](website/diagrams-out/authentication-flow.svg) -
Authentication workflow
- [database-schema.svg](diagrams-out/database-schema.svg) - Database
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
relationships
## Backend Architecture
### Layered Architecture Pattern
### Vertical Slice Architecture Pattern
The backend follows a strict layered architecture:
The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`) is a single project that owns
its own controller, MediatR commands/queries/handlers, validators, and
repository, end to end:
```
┌─────────────────────────────────────┐
API Layer (Controllers)
│ - HTTP Endpoints
│ - Request/Response mapping
│ - Swagger/OpenAPI │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
Service Layer (Business Logic)
- Authentication logic
- User management
- Validation & orchestration
└─────────────────────────────────────┘
─────────────────────────────────────
Infrastructure Layer (Tools) │
│ - JWT token generation │
- Password hashing (Argon2id)
- Email services
- Repository implementations
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
Domain Layer (Entities)
- UserAccount, UserCredential
│ - Pure POCO classes │
│ - No external dependencies │
└─────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────────
API.Core (thin host)
│ - Program.cs wiring: MediatR + AddApplicationPart per slice
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter
└───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │
│Breweries │ UserManagement │ (no controller,
Controller │ Controller │ Controller │ internal only)
Commands/ │ Commands/ │ Commands/ │ Commands/
Queries + │ Queries + │ Queries + │ Handlers
│ Handlers │ Handlers │ Handlers │ │
│ Repository │ Repository │ Repository │ EmailDispatcher │
└───────────────┴──────────────────────────────────┴───────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐
Shared.Contracts │ Shared.Application │ Domain.Entities /
(ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions
│ cross-slice email cmds) │
└─────────────────────────┴─────────────────────────┴────────────────────┘
┌─────────────────────────────────────────────────────────────────────────
Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing,
Infrastructure.Email, Infrastructure.Email.Templates
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Database (SQL Server) │
@@ -70,75 +74,123 @@ The backend follows a strict layered architecture:
└─────────────────────────────────────┘
```
Slices never reference each other's project. The one cross-slice
interaction (`Features.Auth` triggering a confirmation email handled by
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other.
### Layer Responsibilities
#### API Layer (`API.Core`)
**Purpose**: HTTP interface and request handling
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own
**Components**:
- Controllers (`AuthController`, `UserController`)
- Middleware for error handling
- Swagger/OpenAPI documentation
- Health check endpoints
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
FluentValidation, and uses `AddApplicationPart` so each slice's controllers
are discovered by MVC
- `GlobalException.cs`: global exception filter (host-level cross-cutting
concern)
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
- Swagger/OpenAPI documentation, health check endpoints
**Dependencies**:
- Service layer
- ASP.NET Core framework
- Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**:
- No business logic
- Only request/response transformation
- Delegates all work to Service layer
- No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
**Purpose**: Business logic and orchestration
**Purpose**: Each slice is the complete vertical for one business capability
**Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
as the request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
operation, each containing the Command/Query record, its
`IRequestHandler`, and (for commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository
implementation
- `Dtos/`: response shapes returned by query handlers (never the raw
domain entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
the slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via
MediatR commands sent from other slices, never over HTTP.
**Dependencies**:
- `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
infrastructure project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails)
- `Shared.Contracts`, `Shared.Application`
- **Never** another `Features.*` project
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through
MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared Projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every
slice needs it, or because duplicating it four times would be worse than
sharing it
**Components**:
- Authentication services (login, registration)
- User management services
- Business rule validation
- Transaction coordination
**Dependencies**:
- Infrastructure layer (repositories, JWT, password hashing)
- Domain entities
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
envelope every controller returns
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
MediatR pipeline behavior that runs FluentValidation before a handler
executes) and the cross-slice email commands
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
**Rules**:
- Contains all business logic
- Coordinates multiple infrastructure components
- No direct database access (uses repositories)
- Returns domain models, not DTOs
- Kept deliberately small: this is the exception to "no slice depends on
another slice," not a general-purpose dumping ground
#### Infrastructure Layer
**Purpose**: Technical capabilities and external integrations
**Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them
**Components**:
- **Infrastructure.Repository**: Data access via stored procedures
- **Infrastructure.Sql**: generic ADO.NET connection/command plumbing
(`ISqlConnectionFactory`, the abstract `Repository<T>` base class), not
domain-specific; each slice's own `Repository/` folder builds on this
- **Infrastructure.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities
- **Infrastructure.Email.Templates**: Email template rendering
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
- **Infrastructure.Email.Templates**: Email template rendering (Razor
components)
**Dependencies**:
- Domain entities
- External libraries (ADO.NET, JWT, Argon2, etc.)
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
**Rules**:
- Implements technical concerns
- No business logic
- Reusable across services
- Implements technical concerns only, no business logic
- Reusable across slices
#### Domain Layer (`Domain.Entities`)
@@ -163,21 +215,55 @@ The backend follows a strict layered architecture:
### Design Patterns
#### Vertical Slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one
project
**Implementation**:
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (e.g. `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
- Query handlers map to a dedicated response `Dto`, so domain entities never
leak over the wire
**Example**:
```csharp
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
: IRequest<BreweryDto>;
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
{
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
}
```
#### Repository Pattern
**Purpose**: Abstract database access behind interfaces
**Implementation**:
**Implementation**: each slice owns its own repository, scoped to that
feature only:
- `IAuthRepository` - Authentication queries
- `IUserAccountRepository` - User account queries
- `DefaultSqlConnectionFactory` - Connection management
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
**Benefits**:
- Testable (easy to mock)
- SQL-first approach (stored procedures)
- Centralized data access logic
- Each slice's data access logic is self-contained
**Example**:
@@ -193,17 +279,19 @@ public interface IAuthRepository
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` registers all services
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
extension method that registers its repository and slice-internal services
**Lifetimes**:
- Scoped: Repositories, Services (per request)
- Singleton: Connection factories, JWT configuration
- Scoped: Repositories, slice-internal services (per request)
- Singleton: `ISqlConnectionFactory`
- Transient: Utilities, helpers
#### SQL-First Approach
**Purpose**: Leverage database capabilities
**Purpose**: Push complex logic into the database
**Strategy**:
@@ -220,13 +308,13 @@ public interface IAuthRepository
## Frontend Architecture
### Active Website (`src/Website`)
### Active Website (`web/frontend`)
The current website is a React Router 7 application with server-side rendering
enabled.
```text
src/Website/
web/frontend/
├── app/
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
│ ├── lib/ Auth helpers, schemas, and theme metadata
@@ -262,10 +350,9 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy Frontend
The previous Next.js frontend has been archived at `src/Website-v1`. Active
product and engineering documentation should point to `src/Website`, while
legacy notes live in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point
to `web/frontend`.
## Security Architecture
@@ -390,7 +477,7 @@ scripts/
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
For details, see [Docker Guide](docker.md).
For details, see [Docker Guide](website/docker.md).
### Health Checks
@@ -416,11 +503,10 @@ healthcheck:
│ Integration │ ← API.Specs (Reqnroll)
│ Tests │
├──────────────┤
│ Unit Tests │ ← Service.Auth.Tests
(Service)Repository.Tests
├──────────────┤
Unit Tests │
│ (Repository) │
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
(per slice,Features.UserManagement.Tests, Features.Emails.Tests
│ handlers + │ (commands/queries/handlers + that slice's own
repository) │ repository, mocked with Moq/DbMocker)
└──────────────┘
```
@@ -431,4 +517,4 @@ healthcheck:
- Mock external dependencies
- Test database for integration tests
For details, see [Testing Guide](testing.md).
For details, see [Testing Guide](website/testing.md).

File diff suppressed because one or more lines are too long

View File

@@ -4,41 +4,67 @@ skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam packageStyle rectangle
title The Biergarten App - Layered Architecture
title The Biergarten App - Vertical Slice Architecture
package "API Layer" #E3F2FD {
[API.Core\nASP.NET Core Web API] as API
package "API.Core (thin host)" #E3F2FD {
[API.Core\nASP.NET Core Web API host] as API
note right of API
- Controllers (Auth, User)
- Swagger/OpenAPI
- Middleware
- Health Checks
- Program.cs wiring only
- MediatR + AddApplicationPart
per Features.* assembly
- Swagger/OpenAPI, health checks
- JWT auth middleware
- Global exception filter
end note
}
package "Service Layer" #F3E5F5 {
[Service.Auth] as AuthSvc
[Service.UserManagement] as UserSvc
note right of AuthSvc
- Business Logic
- Validation
- Orchestration
package "Feature Slices" #F3E5F5 {
[Features.Auth] as AuthSlice
[Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice
note right of AuthSlice
Each slice owns its own:
- Controller (HTTP endpoints)
- Commands/Queries + Handlers
- Validators
- Repository
end note
note right of EmailsSlice
No controller - invoked only
via MediatR commands sent
from other slices
end note
}
package "Shared" #FCE4EC {
[Shared.Contracts] as SharedContracts
[Shared.Application] as SharedApp
note right of SharedApp
- ValidationBehavior
(MediatR pipeline)
- Cross-slice email commands
(SendRegistrationEmailCommand,
SendResendConfirmationEmailCommand)
end note
}
package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Repository] as Repo
[Infrastructure.Sql] as Sql
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email
[Infrastructure.Email.Templates] as EmailTemplates
}
package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions
note right of Domain
- UserAccount
- UserCredential
- UserVerification
- BreweryPost
end note
}
@@ -48,28 +74,53 @@ database "SQL Server" {
}
' Relationships
API --> AuthSvc
API --> UserSvc
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller)
AuthSvc --> Repo
AuthSvc --> JWT
AuthSvc --> PwdHash
AuthSvc --> Email
AuthSlice --> Sql
AuthSlice --> JWT
AuthSlice --> PwdHash
AuthSlice --> SharedContracts
AuthSlice --> SharedApp
UserSvc --> Repo
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
Repo --> SP
Repo --> Domain
UserSlice --> Sql
UserSlice --> SharedContracts
UserSlice --> SharedApp
EmailsSlice --> Email
EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
' The one cross-slice interaction: a MediatR command contract, not a project reference
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> SP
Sql --> Domain
SP --> Tables
AuthSvc --> Domain
UserSvc --> Domain
AuthSlice --> Domain
BrewerySlice --> Domain
UserSlice --> Domain
AuthSlice --> DomainExceptions
' Notes
note left of Repo
note left of Sql
SQL-first approach
All queries via
stored procedures
end note
note bottom of AuthSlice
No Features.* project
ever references another
Features.* project directly
end note
@enduml

View File

@@ -112,38 +112,32 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
end note
}
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
component "xUnit + DbMocker" as RepoComp
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
component "xUnit + Moq + DbMocker" as UnitComp
note right
Tests:
- AuthRepository
- UserAccountRepository
- SQL command building
Builds Dockerfile.tests, which
restores/builds against Core.slnx
then runs `dotnet test` for every
Features/*.Tests project in turn:
- Features.Auth.Tests
- Features.Breweries.Tests
- Features.UserManagement.Tests
- Features.Emails.Tests
Uses: Mock connections
Uses: Moq for handlers,
DbMocker for repositories
No real database needed
end note
}
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
component "xUnit + Moq" as SvcComp
note right
Tests:
- RegisterService
- LoginService
- Token generation
Uses: Mocked dependencies
No database or infrastructure
end note
}
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "repository-tests/\n results.trx" as Result2
file "service-auth-tests/\n results.trx" as Result3
file "Features.Auth.Tests.trx" as Result2
file "Features.Breweries.Tests.trx" as Result3
file "Features.UserManagement.Tests.trx" as Result4
file "Features.Emails.Tests.trx" as Result5
note bottom
TRX format
@@ -170,17 +164,16 @@ DevAPI .up.> DevSeed : depends_on
TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test
RepoTests ..> TestDB : Mock (no connection)
SvcTests ..> TestDB : Mock (no connection)
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection)
TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on
Specs .up.> TestSeed : depends_on
UnitTests .up.> TestSeed : depends_on
' Test results export
Specs --> Results : Export TRX
RepoTests --> Results : Export TRX
SvcTests --> Results : Export TRX
UnitTests --> Results : Export TRX (one .trx per slice)
' Network notes
note bottom of DevDB

View File

@@ -13,7 +13,7 @@ The project uses Docker Compose to orchestrate multiple services:
- .NET API
- Test runners
See the [deployment diagram](diagrams/pdf/deployment.pdf) for visual
See the [deployment diagram](diagrams-out/deployment.svg) for visual
representation.
## Docker Compose Environments
@@ -25,9 +25,10 @@ representation.
**Features**:
- Persistent SQL Server volume
- Hot reload support
- NuGet package cache volume (speeds up rebuilds)
- Swagger UI enabled
- Seed data included
- Local Mailpit SMTP server for dev email testing
- `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**:
@@ -37,28 +38,30 @@ sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
mailpit # Local dev SMTP server + UI (port 8025)
```
**Start Development Environment**:
```bash
docker compose -f docker-compose.dev.yaml up -d
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
**Access**:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SQL Server: localhost:1433 (sa credentials from .env.dev)
**Stop Environment**:
```bash
# Stop services (keep volumes)
docker compose -f docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### 2. Testing (`docker-compose.test.yaml`)
@@ -80,24 +83,22 @@ sqlserver # Test database
database.migrations # Fresh schema
database.seed # Test data
api.specs # Reqnroll BDD tests
repository.tests # Repository unit tests
service.auth.tests # Service unit tests
unit.tests # All Features.*.Tests unit test projects
```
**Run Tests**:
```bash
# Run all tests
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
cat test-results/Features.Auth.Tests.trx
# Clean up
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
### 3. Production (`docker-compose.prod.yaml`)
@@ -124,7 +125,7 @@ api.core # Production API
**Deploy Production**:
```bash
docker compose -f docker-compose.prod.yaml up -d
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
```
## Service Dependencies
@@ -194,13 +195,6 @@ volumes:
Test results are written to host filesystem for CI/CD integration.
**Code Volumes** (development only):
```yaml
volumes:
- ./src:/app/src # Hot reload for development
```
## Networks
Each environment uses isolated bridge networks:
@@ -223,7 +217,9 @@ environment:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
JWT_SECRET: "${JWT_SECRET}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
```
For complete list, see [Environment Variables](environment-variables.md).
@@ -234,7 +230,7 @@ For complete list, see [Environment Variables](environment-variables.md).
```bash
# Running services
docker compose -f docker-compose.dev.yaml ps
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
# All containers (including stopped)
docker ps -a
@@ -244,13 +240,13 @@ docker ps -a
```bash
# All services
docker compose -f docker-compose.dev.yaml logs -f
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
# Last 100 lines
docker compose -f docker-compose.dev.yaml logs --tail=100 api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
```
### Execute Commands in Container
@@ -267,39 +263,39 @@ docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -
```bash
# Restart all services
docker compose -f docker-compose.dev.yaml restart
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
# Restart specific service
docker compose -f docker-compose.dev.yaml restart api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
# Rebuild and restart
docker compose -f docker-compose.dev.yaml up -d --build api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
```
### Build Images
```bash
# Build all images
docker compose -f docker-compose.dev.yaml build
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
# Build specific service
docker compose -f docker-compose.dev.yaml build api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
# Build without cache
docker compose -f docker-compose.dev.yaml build --no-cache
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
```
### Clean Up
```bash
# Stop and remove containers
docker compose -f docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Remove containers and volumes
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
# Remove containers, volumes, and images
docker compose -f docker-compose.dev.yaml down -v --rmi all
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
# System-wide cleanup
docker system prune -af --volumes

View File

@@ -17,7 +17,7 @@ The application uses environment variables for:
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (`src/Website`)
### Frontend (`web/frontend`)
The active website reads runtime values from the server environment for its auth
and API integration.
@@ -130,7 +130,7 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
## Frontend Variables (`src/Website`)
## Frontend Variables (`web/frontend`)
The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are:
@@ -147,7 +147,7 @@ NODE_ENV=development # Standard Node runtime mode
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `src/Website/app/lib/auth.server.ts`
- **Used by**: `web/frontend/app/lib/auth.server.ts`
- **Purpose**: Routes website auth actions to the .NET API
#### `SESSION_SECRET`
@@ -163,15 +163,27 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode
### Admin Account (Seeding)
### SMTP Configuration (Backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
and account emails.
```bash
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
SMTP_HOST=mailpit # Required, no default
SMTP_PORT=1025 # Optional, defaults to 587
SMTP_USERNAME=<username> # Optional, no default
SMTP_PASSWORD=<password> # Optional, no default
SMTP_USE_SSL=false # Optional, defaults to true
SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
```
- **Required**: No (only needed for seeding)
- **Purpose**: Sets admin account password during database seeding
- **Security**: Use strong password, change immediately in production
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
etc.)
## Docker-Specific Variables
@@ -191,34 +203,33 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
## Environment File Structure
### Backend/Docker (Root Directory)
### Backend/Docker (`web/` Directory)
```
.env.example # Template (tracked in Git)
.env.dev # Development config (gitignored)
.env.test # Testing config (gitignored)
.env.prod # Production config (gitignored)
web/.env.example # Template (tracked in Git)
web/.env.dev # Development config (gitignored)
web/.env.test # Testing config (gitignored)
web/.env.prod # Production config (gitignored)
```
**Setup**:
```bash
cp .env.example .env.dev
# Edit .env.dev with your values
cp web/.env.example web/.env.dev
# Edit web/.env.dev with your values
```
## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed
from this active reference. See
[archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you need the
legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
been removed from this active reference, since that app is retained for
reference only and is not run as part of the active stack.
**Docker Compose Mapping**:
- `docker-compose.dev.yaml``.env.dev`
- `docker-compose.test.yaml``.env.test`
- `docker-compose.prod.yaml``.env.prod`
- `web/docker-compose.dev.yaml``web/.env.dev`
- `web/docker-compose.test.yaml``web/.env.test`
- `web/docker-compose.prod.yaml``web/.env.prod`
## Variable Reference Table
@@ -234,6 +245,13 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
@@ -254,9 +272,12 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
Variables are validated at startup:
- Missing required variables cause application to fail
- JWT_SECRET length is enforced (min 32 chars)
- Connection string format is validated
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
values) cause the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the
"minimum 32 characters" guidance above is a recommendation, not an
enforced check
### Frontend Validation
@@ -284,6 +305,13 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# SMTP (Mailpit in dev)
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
# Migration
CLEAR_DATABASE=true

View File

@@ -1,7 +1,7 @@
# Getting Started
This guide covers local setup for the current Biergarten stack: the .NET backend
in `src/Core` and the active React Router frontend in `src/Website`.
in `web/backend` and the active React Router frontend in `web/frontend`.
## Prerequisites
@@ -22,7 +22,7 @@ cd the-biergarten-app
### 2. Configure Backend Environment Variables
```bash
cp .env.example .env.dev
cp web/.env.example web/.env.dev
```
At minimum, ensure `.env.dev` includes valid database and token values:
@@ -43,20 +43,21 @@ See [Environment Variables](environment-variables.md) for the full list.
### 3. Start the Backend Stack
```bash
docker compose -f 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, and the API.
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP).
Available endpoints:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
### 4. Start the Active Frontend
```bash
cd src/Website
cd web/frontend
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
```
@@ -71,7 +72,7 @@ Required frontend runtime variables for local work:
### 5. Optional: Run Storybook
```bash
cd src/Website
cd web/frontend
npm run storybook
```
@@ -82,15 +83,15 @@ Storybook runs at http://localhost:6006 by default.
### Backend
```bash
docker compose -f docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### Frontend
```bash
cd src/Website
cd web/frontend
npm run lint
npm run typecheck
npm run format:check
@@ -115,7 +116,7 @@ export WEBSITE_BASE_URL="http://localhost:3000"
### 2. Run Migrations and Seed
```bash
cd src/Core
cd web/backend
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
dotnet run --project Database/Database.Seed/Database.Seed.csproj
```
@@ -128,12 +129,11 @@ dotnet run --project API/API.Core/API.Core.csproj
## Legacy Frontend Note
The previous Next.js frontend now lives in `src/Website-v1` and is not the
active website. Legacy setup details have been moved to
[docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
only and is not the active website.
## Next Steps
- Review [Architecture](architecture.md)
- Review [Architecture](../architecture.md)
- Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting

View File

@@ -7,9 +7,13 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
- **Service.Auth.Tests** - Unit tests for authentication business logic
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
a live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
slice's command/query handlers and its own repository (mocked with Moq /
DbMocker, no real database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared
website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -21,7 +25,7 @@ The easiest way to run all tests is using Docker Compose, which sets up an
isolated test environment:
```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
```
This command:
@@ -41,15 +45,17 @@ ls -la test-results/
# View specific test results
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx
```
### Clean Up
```bash
# Remove test containers and volumes
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
## Running Tests Locally
@@ -59,7 +65,7 @@ You can run individual test projects locally without Docker:
### Integration Tests (API.Specs)
```bash
cd src/Core
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
@@ -69,32 +75,31 @@ dotnet test API/API.Specs/API.Specs.csproj
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Repository Tests
### Feature Slice Unit Tests
Each feature slice has its own test project, covering its command/query
handlers and repository:
```bash
cd src/Core
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
cd web/backend
dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
# Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done
```
**Requirements**:
- SQL Server instance running (uses mock data)
### Service Tests
```bash
cd src/Core
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
**Requirements**:
- No database required (uses Moq for mocking)
- No database required (handlers use Moq; repository tests use DbMocker to
simulate SQL Server responses)
### Frontend Storybook Tests
```bash
cd src/Website
cd web/frontend
npm install
npm run test:storybook
```
@@ -108,7 +113,7 @@ npm run test:storybook
### Frontend Playwright Storybook Tests
```bash
cd src/Website
cd web/frontend
npm install
npm run test:storybook:playwright
```
@@ -124,27 +129,28 @@ npm run test:storybook:playwright
### Current Coverage
**Authentication & User Management**:
**Features.Auth.Tests**:
- User registration with validation
- User login with JWT token generation
- Password hashing and verification (Argon2id)
- JWT token generation and claims
- Invalid credentials handling
- 404 error responses
- Email confirmation and confirmation-email resend
- Refresh token exchange
- JWT token generation, validation, and claims handling
- `IAuthRepository` data access (DbMocker-backed)
- Invalid credentials and 404 error responses (via API.Specs)
**Repository Layer**:
**Features.Breweries.Tests**:
- User account creation
- User credential management
- GetUserByUsername queries
- Stored procedure execution
- Brewery create/update/delete commands and get-by-id/get-all queries
**Service Layer**:
**Features.UserManagement.Tests**:
- Login service with password verification
- Register service with validation
- Business logic for authentication flow
- User get-by-id/get-all queries and the (currently unrouted) update command
**Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers
**Frontend UI Coverage**:
@@ -156,10 +162,7 @@ npm run test:storybook:playwright
### Planned Coverage
- [ ] Email verification workflow
- [ ] Password reset functionality
- [ ] Token refresh mechanism
- [ ] Brewery data management
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
@@ -204,21 +207,28 @@ npm run test:storybook:playwright
```
API.Specs/
├── Features/
│ ├── Authentication.feature # Login/register scenarios
── UserManagement.feature # User CRUD scenarios
│ ├── Registration.feature # Registration scenarios
── Login.feature # Login scenarios
│ ├── Confirmation.feature # Email confirmation scenarios
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ └── NotFound.feature # 404 handling
├── Steps/
│ ├── AuthenticationSteps.cs # Step definitions
│ └── UserManagementSteps.cs
── Mocks/
── TestApiFactory.cs # Test server setup
│ ├── AuthSteps.cs # Step definitions for the Auth features
│ └── ApiGeneralSteps.cs # Shared/general step definitions
── Mocks/
── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
```
**Example Feature**:
```gherkin
Feature: User Authentication
Feature: User Registration
As a user
I want to register and login
I want to register
So that I can access the platform
Scenario: Successful user registration
@@ -228,45 +238,51 @@ Scenario: Successful user registration
And my account should be created
```
### Infrastructure.Repository.Tests
### Features.Auth.Tests
```
Infrastructure.Repository.Tests/
├── AuthRepositoryTests.cs # Auth repository tests
├── UserAccountRepositoryTests.cs # User account tests
└── TestFixtures/
── DatabaseFixture.cs # Shared test setup
Features.Auth.Tests/
├── Commands/
│ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs
── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs
├── Queries/
│ └── LoginHandlerTests.cs
├── Repository/
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
└── Services/
├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs
```
### Service.Auth.Tests
```
Service.Auth.Tests/
├── LoginService.test.cs # Login business logic tests
└── RegisterService.test.cs # Registration business logic tests
```
Each of the other three slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
shape: a `Commands/`/`Queries/` folder with one test file per handler.
## Writing Tests
### Unit Test Example (xUnit)
```csharp
public class LoginServiceTests
public class LoginHandlerTests
{
[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken()
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
// Arrange
var mockRepo = new Mock<IAuthRepository>();
var mockJwt = new Mock<IJwtService>();
var service = new AuthService(mockRepo.Object, mockJwt.Object);
var authRepoMock = new Mock<IAuthRepository>();
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
var tokenServiceMock = new Mock<ITokenService>();
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
// ...set up mocks for a known user/credential...
// Act
var result = await service.LoginAsync("testuser", "password123");
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Token.Should().NotBeNullOrEmpty();
result.AccessToken.Should().NotBeNullOrEmpty();
}
}
```
@@ -288,9 +304,9 @@ configuration:
```bash
# CI/CD command
docker compose -f docker-compose.test.yaml build
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
Exit codes:
@@ -302,7 +318,7 @@ Frontend UI checks should also be included in CI for the active website
workspace:
```bash
cd src/Website
cd web/frontend
npm ci
npm run test:storybook
npm run test:storybook:playwright
@@ -315,7 +331,7 @@ npm run test:storybook:playwright
Ensure SQL Server is running and environment variables are set:
```bash
docker compose -f docker-compose.test.yaml ps
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
```
### Port Conflicts
@@ -328,13 +344,13 @@ If port 1433 is in use, stop other SQL Server instances or modify the port in
Clean up test database:
```bash
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
### View Container Logs
```bash
docker compose -f docker-compose.test.yaml logs <service-name>
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
```
## Best Practices

View File

@@ -2,8 +2,7 @@
## Overview
The Core project implements comprehensive JWT token validation across three
token types:
The Core project validates JWTs across three token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
@@ -15,7 +14,7 @@ token types:
### Infrastructure Layer
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations.
@@ -25,77 +24,92 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:**
[JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Service Layer
### Features.Auth Slice
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
High-level token validation with context (token type, user extraction).
Both token generation and validation live on the same slice-internal
service (there is no separate validation service/class).
**Methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns:** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:**
[TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
#### [ITokenService](Service.Auth/ITokenService.cs)
Token generation (existing service extended).
**Methods:**
**Generation methods:**
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation
token
**Validation methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns (validation):** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Auth`'s own
`AddFeaturesAuth()` extension method, except for the lower-level
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
registered by the host (`API.Core/Program.cs`) since
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
it directly.
### Integration Points
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
**Flow:**
1. Receives confirmation token from user
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database
5. Returns confirmation result
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
**Flow:**
1. Receives refresh token from user
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
4. Issues new access and refresh tokens via `TokenService`
5. Returns new token pair
1. Receives a user ID via `ResendConfirmationEmailCommand`
2. Looks up the user and checks they aren't already verified
3. Generates a fresh confirmation token via `ITokenService`
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
`Features.Emails` slice, with no direct project reference between the two
slices
#### [AuthController](API.Core/Controllers/AuthController.cs)
#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs)
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
and issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
**Endpoints:**
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Authenticate user
- `POST /api/auth/confirm?token=...` - Confirm email
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token
## Validation Security
@@ -158,32 +172,44 @@ Validation failures return HTTP 401 Unauthorized:
1. **Generation**: During user registration (30-minute validity)
2. **Delivery**: Emailed to user in confirmation link
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
4. **Validation**: Validated by ConfirmationService
4. **Validation**: Validated by `ConfirmUserHandler`
5. **Completion**: User account marked as confirmed
6. **Expiration**: Token becomes invalid after 30 minutes
## Testing
All of the following live in `Features.Auth.Tests`.
### Unit Tests
**TokenValidationService.test.cs**
**Services/TokenServiceValidationTests.cs**
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
**RefreshTokenService.test.cs**
**Services/TokenServiceRefreshTests.cs**
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
**ConfirmationService.test.cs**
**Commands/RefreshTokenHandlerTests.cs**
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
`LoginPayload`
**Commands/ConfirmUserHandlerTests.cs**
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
**Commands/ResendConfirmationEmailHandlerTests.cs**
- Sends a fresh confirmation email when the user exists and is unverified
- No-ops when the user doesn't exist or is already verified
### BDD Tests (Reqnroll)
**TokenRefresh.feature**

View File

@@ -49,8 +49,10 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USERNAME=ayerble
SMTP_PASSWORD=checkers
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000

View File

@@ -1,5 +1,5 @@
{
"printWidth": 80,
"printWidth": 100,
"useTabs": false,
"indentSize": 4,
"endOfLine": "lf",

View File

@@ -8,15 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.OpenApi"
Version="9.0.11"
/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference
Include="FluentValidation.AspNetCore"
Version="11.3.0"
/>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup>
<ItemGroup>
@@ -26,13 +21,14 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,13 +1,22 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using API.Core.Contracts.Common;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication;
/// <summary>
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
/// </summary>
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
public class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger,
@@ -16,71 +25,76 @@ public class JwtAuthenticationHandler(
IConfiguration configuration
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
/// <summary>
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
/// </summary>
/// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Authentication fails if the secret is not configured, the
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
/// token fails validation.
/// </remarks>
/// <returns>
/// A successful <see cref="AuthenticateResult" /> containing the validated
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists
if (
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
)
)
{
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString();
if (
!authHeader.StartsWith(
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
{
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
}
string authHeader = authHeaderValue.ToString();
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return AuthenticateResult.Fail("Invalid authorization header format");
var token = authHeader.Substring("Bearer ".Length).Trim();
string token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token,
secret
);
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(
$"Token validation failed: {ex.Message}"
);
return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
}
}
/// <summary>
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
/// </summary>
/// <param name="properties">The authentication properties associated with the challenge.</param>
/// <returns>A task that completes once the response body has been written.</returns>
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.ContentType = "application/json";
Response.StatusCode = 401;
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
ResponseBody response = new()
{
Message = "Unauthorized: Invalid or missing authentication token",
};
await Response.WriteAsJsonAsync(response);
}
}
/// <summary>
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }

View File

@@ -1,21 +0,0 @@
using Domain.Entities;
using Org.BouncyCastle.Asn1.Cms;
namespace API.Core.Contracts.Auth;
public record LoginPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken
);
public record RegistrationPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken,
bool ConfirmationEmailSent
);
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -1,20 +0,0 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record LoginRequest
{
public string Username { get; init; } = default!;
public string Password { get; init; } = default!;
}
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -1,19 +0,0 @@
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record RefreshTokenRequest
{
public string RefreshToken { get; init; } = default!;
}
public class RefreshTokenRequestValidator
: AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenRequestValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty()
.WithMessage("Refresh token is required");
}
}

View File

@@ -1,41 +0,0 @@
namespace API.Core.Contracts.Breweries;
public class BreweryLocationCreateDto
{
public Guid CityId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public byte[]? Coordinates { get; set; }
}
public class BreweryLocationDto
{
public Guid BreweryPostLocationId { get; set; }
public Guid BreweryPostId { get; set; }
public Guid CityId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public byte[]? Coordinates { get; set; }
}
public class BreweryCreateDto
{
public Guid PostedById { get; set; }
public string BreweryName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public BreweryLocationCreateDto Location { get; set; } = null!;
}
public class BreweryDto
{
public Guid BreweryPostId { get; set; }
public Guid PostedById { get; set; }
public string BreweryName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public byte[]? Timer { get; set; }
public BreweryLocationDto? Location { get; set; }
}

View File

@@ -1,12 +0,0 @@
namespace API.Core.Contracts.Common;
public record ResponseBody<T>
{
public required string Message { get; init; }
public required T Payload { get; init; }
}
public record ResponseBody
{
public required string Message { get; init; }
}

View File

@@ -1,118 +0,0 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Auth;
namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
IConfirmationService confirmationService,
ITokenService tokenService
) : ControllerBase
{
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req
)
{
var rtn = await registerService.RegisterAsync(
new UserAccount
{
UserAccountId = Guid.Empty,
Username = req.Username,
FirstName = req.FirstName,
LastName = req.LastName,
Email = req.Email,
DateOfBirth = req.DateOfBirth,
},
req.Password
);
var response = new ResponseBody<RegistrationPayload>
{
Message = "User registered successfully.",
Payload = new RegistrationPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken,
rtn.EmailSent
),
};
return Created("/", response);
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var rtn = await loginService.LoginAsync(req.Username, req.Password);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
[HttpPost("confirm")]
public async Task<ActionResult> Confirm([FromQuery] string token)
{
var rtn = await confirmationService.ConfirmUserAsync(token);
return Ok(
new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + rtn.UserId + " is confirmed.",
Payload = new ConfirmationPayload(
rtn.UserId,
rtn.ConfirmedAt
),
}
);
}
[HttpPost("confirm/resend")]
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
{
await confirmationService.ResendConfirmationEmailAsync(userId);
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req
)
{
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
}
}

View File

@@ -1,129 +0,0 @@
using API.Core.Contracts.Breweries;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Breweries;
namespace API.Core.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IBreweryService breweryService) : ControllerBase
{
[AllowAnonymous]
[HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
{
var brewery = await breweryService.GetByIdAsync(id);
if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery retrieved successfully.",
Payload = MapToDto(brewery),
});
}
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset)
{
var breweries = await breweryService.GetAllAsync(limit, offset);
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
{
Message = "Breweries retrieved successfully.",
Payload = breweries.Select(MapToDto),
});
}
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
{
var request = new BreweryCreateRequest(
dto.PostedById,
dto.BreweryName,
dto.Description,
new BreweryLocationCreateRequest(
dto.Location.CityId,
dto.Location.AddressLine1,
dto.Location.AddressLine2,
dto.Location.PostalCode,
dto.Location.Coordinates
)
);
var result = await breweryService.CreateAsync(request);
if (!result.Success)
return BadRequest(new ResponseBody { Message = result.Message });
return Created($"/api/brewery/{result.Brewery.BreweryPostId}", new ResponseBody<BreweryDto>
{
Message = "Brewery created successfully.",
Payload = MapToDto(result.Brewery),
});
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
{
if (dto.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
var request = new BreweryUpdateRequest(
dto.BreweryPostId,
dto.PostedById,
dto.BreweryName,
dto.Description,
dto.Location is null ? null : new BreweryLocationUpdateRequest(
dto.Location.BreweryPostLocationId,
dto.Location.CityId,
dto.Location.AddressLine1,
dto.Location.AddressLine2,
dto.Location.PostalCode,
dto.Location.Coordinates
)
);
var result = await breweryService.UpdateAsync(request);
if (!result.Success)
return BadRequest(new ResponseBody { Message = result.Message });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery updated successfully.",
Payload = MapToDto(result.Brewery),
});
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{
await breweryService.DeleteAsync(id);
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
}
private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new()
{
BreweryPostId = b.BreweryPostId,
PostedById = b.PostedById,
BreweryName = b.BreweryName,
Description = b.Description,
CreatedAt = b.CreatedAt,
UpdatedAt = b.UpdatedAt,
Timer = b.Timer,
Location = b.Location is null ? null : new BreweryLocationDto
{
BreweryPostLocationId = b.Location.BreweryPostLocationId,
BreweryPostId = b.Location.BreweryPostId,
CityId = b.Location.CityId,
AddressLine1 = b.Location.AddressLine1,
AddressLine2 = b.Location.AddressLine2,
PostalCode = b.Location.PostalCode,
Coordinates = b.Location.Coordinates,
},
};
}

View File

@@ -1,16 +1,27 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers
namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
/// <summary>
/// Returns a generic 404 response for any request that did not match a defined route.
/// </summary>
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
[HttpGet("404")] //required
public IActionResult Handle404()
{
[HttpGet("404")] //required
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });
}
return NotFound(new { message = "Route not found." });
}
}

View File

@@ -1,20 +1,31 @@
using System.Security.Claims;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase
{
/// <summary>
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
/// </summary>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns>
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value;
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok(
new ResponseBody<object>

View File

@@ -1,28 +0,0 @@
using Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Service.UserManagement.User;
namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UserController(IUserService userService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset
)
{
var users = await userService.GetAllAsync(limit, offset);
return Ok(users);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<UserAccount>> GetById(Guid id)
{
var user = await userService.GetByIdAsync(id);
return Ok(user);
}
}
}

View File

@@ -8,16 +8,35 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy the solution file and every project file first (preserving their real relative paths, since
# ProjectReference paths are resolved relative to the referencing .csproj's location), so the
# `dotnet restore` layer stays cached as long as no project file or package reference changes,
# independent of source-only edits. Restoring against Core.slnx avoids hand-maintaining a per-project
# dependency list here that silently drifts out of sync as ProjectReferences change.
COPY ["Core.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
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.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
RUN dotnet restore "API/API.Core/API.Core.csproj"
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
COPY . .
WORKDIR "/src/API/API.Core"
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -1,30 +1,89 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
: IExceptionFilter
/// <summary>
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
/// consistent JSON error responses with appropriate HTTP status codes.
/// </summary>
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{
/// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
/// marking the exception as handled.
/// </summary>
/// <remarks>
/// Maps exception types to responses as follows:
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// validation error messages.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
/// </item>
/// </list>
/// </remarks>
/// <param name="context">
/// The context for the unhandled exception, including the exception itself and the result to
/// populate.
/// </param>
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
var errors = fluentValidationException
case ValidationException fluentValidationException:
Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
context.Result = new BadRequestObjectResult(
new { message = "Validation failed", errors }
@@ -33,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case ConflictException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 409,
};
@@ -43,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case NotFoundException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 404,
};
@@ -53,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case UnauthorizedException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 401,
};
@@ -63,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case ForbiddenException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 403,
};
@@ -83,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 400,
};
@@ -94,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
default:
context.Result = new ObjectResult(
new ResponseBody
{
Message = "An unexpected error occurred",
}
new ResponseBody { Message = "An unexpected error occurred" }
)
{
StatusCode = 500,

View File

@@ -1,26 +1,30 @@
using API.Core;
using API.Core.Authentication;
using Features.Auth.Controllers;
using Features.Auth.DependencyInjection;
using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Infrastructure.Repository.Breweries;
using Service.Auth;
using Service.Emails;
using Service.UserManagement.User;
using Infrastructure.Sql;
using Shared.Application.Behaviors;
var builder = WebApplication.CreateBuilder(args);
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
builder
.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
})
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -28,8 +32,23 @@ builder.Services.AddOpenApi();
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
// Add health checks
builder.Services.AddHealthChecks();
@@ -37,32 +56,20 @@ builder.Services.AddHealthChecks();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
{
builder.Logging.AddDebug();
}
// Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton<
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>();
builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
@@ -70,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
"JWT",
options => { }
);
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
builder.Services.AddAuthorization();
var app = builder.Build();
WebApplication app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
@@ -95,7 +99,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Application is shutting down gracefully...");

View File

@@ -24,10 +24,7 @@
/>
<!-- ASP.NET Core integration testing -->
<PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
@@ -42,5 +39,6 @@
<ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,17 +1,32 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and
# restores against Core.slnx rather than hand-listing API.Specs' transitive dependencies.
COPY ["Core.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
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.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
COPY . .
WORKDIR "/src/API/API.Specs"
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -1,51 +1,51 @@
Feature: Protected Endpoint Access Token Validation
As a backend developer
I want protected endpoints to validate access tokens
So that unauthorized requests are rejected
As a backend developer
I want protected endpoints to validate access tokens
So that unauthorized requests are rejected
Scenario: Protected endpoint accepts valid access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with a valid access token
Then the response has HTTP status 200
Scenario: Protected endpoint accepts valid access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with a valid access token
Then the response has HTTP status 200
Scenario: Protected endpoint rejects missing access token
Given the API is running
When I submit a request to a protected endpoint without an access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects missing access token
Given the API is running
When I submit a request to a protected endpoint without an access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects invalid access token
Given the API is running
When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects invalid access token
Given the API is running
When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring access token
When I submit a request to a protected endpoint with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring access token
When I submit a request to a protected endpoint with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running
And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running
And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects refresh token as access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with my refresh token instead of access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects refresh token as access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with my refresh token instead of access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects confirmation token as access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token
When I submit a request to a protected endpoint with my confirmation token instead of access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects confirmation token as access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token
When I submit a request to a protected endpoint with my confirmation token instead of access token
Then the response has HTTP status 401

View File

@@ -1,76 +1,77 @@
Feature: User Account Confirmation
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Re-confirming an already verified account remains successful
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token
And I submit the same confirmation request again
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with invalid token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Re-confirming an already verified account remains successful
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the valid token
And I submit the same confirmation request again
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with expired token
Given the API is running
And I have registered a new account
And I have an expired confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with invalid token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with an invalid token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running
And I have registered a new account
And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
When I submit a confirmation request with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with expired token
Given the API is running
And I have registered a new account
And I have an expired confirmation token for my account
And I have a valid access token for my account
When I submit a confirmation request with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running
And I have registered a new account
And I have a confirmation token signed with the wrong secret
And I have a valid access token for my account
When I submit a confirmation request with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation endpoint only accepts POST requests
Given the API is running
And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404
Scenario: Confirmation fails when token is missing
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation fails with malformed token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation endpoint only accepts POST requests
Given the API is running
And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401
Scenario: Confirmation fails with malformed token
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a confirmation request with a malformed token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails without an access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token without an access token
Then the response has HTTP status 401

View File

@@ -3,37 +3,37 @@ As a registered user
I want to log in to my account
So that I receive an authentication token to access authenticated routes
Scenario: Successful login with valid credentials
Given the API is running
And I have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token
Scenario: Successful login with valid credentials
Given the API is running
And I have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 200
And the response JSON should have "message" equal "Logged in successfully."
And the response JSON should have an access token
Scenario: Login fails with invalid credentials
Given the API is running
And I do not have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails with invalid credentials
Given the API is running
And I do not have an existing account
When I submit a login request with a username and password
Then the response has HTTP status 401
And the response JSON should have "message" equal "Invalid username or password."
Scenario: Login fails when required missing username
Given the API is running
When I submit a login request with a missing username
Then the response has HTTP status 400
Scenario: Login fails when required missing username
Given the API is running
When I submit a login request with a missing username
Then the response has HTTP status 400
Scenario: Login fails when required missing password
Given the API is running
When I submit a login request with a missing password
Then the response has HTTP status 400
Scenario: Login fails when required missing password
Given the API is running
When I submit a login request with a missing password
Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing
Given the API is running
When I submit a login request with both username and password missing
Then the response has HTTP status 400
Scenario: Login fails when both username and password are missing
Given the API is running
When I submit a login request with both username and password missing
Then the response has HTTP status 400
Scenario: Login endpoint only accepts POST requests
Given the API is running
When I submit a login request using a GET request
Then the response has HTTP status 404
Scenario: Login endpoint only accepts POST requests
Given the API is running
When I submit a login request using a GET request
Then the response has HTTP status 404

View File

@@ -3,8 +3,8 @@ As a client of the API
I want consistent 404 responses
So that consumers can gracefully handle missing routes
Scenario: GET request to an invalid route returns 404
Given the API is running
When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found."
Scenario: GET request to an invalid route returns 404
Given the API is running
When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404
And the response JSON should have "message" equal "Route not found."

View File

@@ -1,60 +1,60 @@
Feature: User Registration
As a new user
I want to register an account
So that I can log in and access authenticated routes
As a new user
I want to register an account
So that I can log in and access authenticated routes
Scenario: Successful registration with valid details
Scenario: Successful registration with valid details
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
Then the response has HTTP status 201
And the response JSON should have "message" equal "User registered successfully."
And the response JSON should have an access token
Scenario: Registration fails with existing username
Scenario: Registration fails with existing username
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
Then the response has HTTP status 409
Scenario: Registration fails with existing email
Scenario: Registration fails with existing email
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
Then the response has HTTP status 409
Scenario: Registration fails with missing required fields
Scenario: Registration fails with missing required fields
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| | New | User | | | Password1! |
Then the response has HTTP status 400
Scenario: Registration fails with invalid email format
Scenario: Registration fails with invalid email format
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
Then the response has HTTP status 400
Scenario: Registration fails with weak password
Scenario: Registration fails with weak password
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running
When I submit a registration request with values:
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
| Username | FirstName | LastName | Email | DateOfBirth | Password |
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
Then the response has HTTP status 400
Scenario: Registration endpoint only accepts POST requests
Scenario: Registration endpoint only accepts POST requests
Given the API is running
When I submit a registration request using a GET request
Then the response has HTTP status 404

View File

@@ -1,36 +1,36 @@
Feature: Resend Confirmation Email
As a user who did not receive the confirmation email
I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse
As a user who did not receive the confirmation email
I want to request a resend of the confirmation email
So that I can obtain a working confirmation link while preventing abuse
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Legitimate resend for an unconfirmed user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for an already confirmed user
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
And I have a valid access token for my account
And I have confirmed my account
When I submit a resend confirmation request for my account
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend is a no-op for a non-existent user
Given the API is running
And I have registered a new account
And I have a valid access token for my account
When I submit a resend confirmation request for a non-existent user
Then the response has HTTP status 200
And the response JSON should have "message" containing "confirmation email has been resent"
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401
Scenario: Resend requires authentication
Given the API is running
And I have registered a new account
When I submit a resend confirmation request without an access token
Then the response has HTTP status 401

View File

@@ -1,39 +1,39 @@
Feature: Token Refresh
As an authenticated user
I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again
As an authenticated user
I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again
Scenario: Successful token refresh with valid refresh token
Given the API is running
And I have an existing account
And I am logged in
When I submit a refresh token request with a valid refresh token
Then the response has HTTP status 200
And the response JSON should have "message" equal "Token refreshed successfully."
And the response JSON should have a new access token
And the response JSON should have a new refresh token
Scenario: Successful token refresh with valid refresh token
Given the API is running
And I have an existing account
And I am logged in
When I submit a refresh token request with a valid refresh token
Then the response has HTTP status 200
And the response JSON should have "message" equal "Token refreshed successfully."
And the response JSON should have a new access token
And the response JSON should have a new refresh token
Scenario: Token refresh fails with invalid refresh token
Given the API is running
When I submit a refresh token request with an invalid refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid"
Scenario: Token refresh fails with invalid refresh token
Given the API is running
When I submit a refresh token request with an invalid refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid"
Scenario: Token refresh fails with expired refresh token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring refresh token
When I submit a refresh token request with the expired refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails with expired refresh token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring refresh token
When I submit a refresh token request with the expired refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails when refresh token is missing
Given the API is running
When I submit a refresh token request with a missing refresh token
Then the response has HTTP status 400
Scenario: Token refresh fails when refresh token is missing
Given the API is running
When I submit a refresh token request with a missing refresh token
Then the response has HTTP status 400
Scenario: Token refresh endpoint only accepts POST requests
Given the API is running
And I have a valid refresh token
When I submit a refresh token request using a GET request
Then the response has HTTP status 404
Scenario: Token refresh endpoint only accepts POST requests
Given the API is running
And I have a valid refresh token
When I submit a refresh token request using a GET request
Then the response has HTTP status 404

View File

@@ -1,23 +1,20 @@
using Domain.Entities;
using Service.Emails;
using Features.Emails.Services;
namespace API.Specs.Mocks;
public class MockEmailService : IEmailService
public class MockEmailDispatcher : IEmailDispatcher
{
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
)
public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
{
SentRegistrationEmails.Add(
new RegistrationEmail
{
UserAccount = createdUser,
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
@@ -27,14 +24,16 @@ public class MockEmailService : IEmailService
}
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string firstName,
string email,
string confirmationToken
)
{
SentResendConfirmationEmails.Add(
new ResendConfirmationEmail
{
UserAccount = user,
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
@@ -51,14 +50,16 @@ public class MockEmailService : IEmailService
public class RegistrationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string FirstName { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
public class ResendConfirmationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string FirstName { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}

View File

@@ -3,19 +3,14 @@ using Infrastructure.Email;
namespace API.Specs.Mocks;
/// <summary>
/// Mock email provider for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// Mock email provider for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// </summary>
public class MockEmailProvider : IEmailProvider
{
public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync(
string to,
string subject,
string body,
bool isHtml = true
)
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(
new SentEmail
@@ -31,12 +26,7 @@ public class MockEmailProvider : IEmailProvider
return Task.CompletedTask;
}
public Task SendAsync(
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(
new SentEmail

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json;
using API.Specs;
using FluentAssertions;
using Reqnroll;
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
}
var factory = scenario.TryGetValue<TestApiFactory>(
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out var f
out TestApiFactory? f
)
? f
: new TestApiFactory();
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody
)
{
var client = GetClient();
HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
{
Content = new StringContent(
jsonBody,
System.Text.Encoding.UTF8,
"application/json"
),
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString(
string method,
string url
)
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
new HttpMethod(method),
url
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpClient client = GetClient();
HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(expected);
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode);
}
[Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString(
string field,
string expected
)
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
}
value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
value.GetString().Should().Be(expected);
}
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
}
value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
string? actualValue = value.GetString();
actualValue
.Should()
.Contain(

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,39 @@
using System.Collections.Generic;
using API.Specs.Mocks;
using Features.Emails.Services;
using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Service.Emails;
namespace API.Specs
namespace API.Specs;
public class TestApiFactory : WebApplicationFactory<Program>
{
public class TestApiFactory : WebApplicationFactory<Program>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
builder.UseEnvironment("Testing");
// Replace the real email provider with mock for testing
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
builder.ConfigureServices(services =>
{
// Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
if (emailProviderDescriptor != null)
services.Remove(emailProviderDescriptor);
if (emailProviderDescriptor != null)
{
services.Remove(emailProviderDescriptor);
}
services.AddScoped<IEmailProvider, MockEmailProvider>();
services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email dispatcher with mock for testing
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
// Replace the real email service with mock for testing
var emailServiceDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailService)
);
if (emailDispatcherDescriptor != null)
services.Remove(emailDispatcherDescriptor);
if (emailServiceDescriptor != null)
{
services.Remove(emailServiceDescriptor);
}
services.AddScoped<IEmailService, MockEmailService>();
});
}
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
}

View File

@@ -1,32 +1,37 @@
<Solution>
<Folder Name="/API/">
<Project Path="API/API.Core/API.Core.csproj" />
<Project Path="API/API.Specs/API.Specs.csproj" />
<Project Path="API/API.Core/API.Core.csproj"/>
<Project Path="API/API.Specs/API.Specs.csproj"/>
</Folder>
<Folder Name="/Database/">
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
<Project Path="Database/Database.Seed/Database.Seed.csproj"/>
</Folder>
<Folder Name="/Domain/">
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
<Project
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
<Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
<Project
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
</Folder>
<Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service/Service.Auth/Service.Auth.csproj" />
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
<Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
<Project Path="Features/Features.Auth/Features.Auth.csproj"/>
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
<Project Path="Features/Features.Emails/Features.Emails.csproj"/>
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
<Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
</Folder>
</Solution>

View File

@@ -1,23 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Database.Migrations</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Database.Migrations</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,95 +1,136 @@
using System.Data;
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
/// <summary>
/// Entry point for the database migration runner. Reads connection details from
/// environment variables, optionally clears and recreates the target database, and
/// applies all SQL migration scripts embedded in this assembly using DbUp.
/// </summary>
public static class Program
{
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
/// environment variables.
/// </summary>
/// <param name="databaseName">
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
/// <c>DB_NAME</c> environment variable.
/// </param>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception>
private static string BuildConnectionString(string? databaseName = null)
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName
string dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true
Encrypt = true,
};
return builder.ConnectionString;
}
private static readonly string connectionString = BuildConnectionString();
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console.
/// </summary>
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations()
{
var upgrader = DeployChanges
UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful;
}
/// <summary>
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
/// mode with rollback to terminate any existing connections.
/// </summary>
/// <returns>
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
/// if an error occurred while connecting or dropping the database.
/// </returns>
private static bool ClearDatabase()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
try
{
myConn.Open();
// First, set the database to single user mode to close all connections
var setModeCommand = new SqlCommand(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn);
SqlCommand setModeCommand = new(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn
);
try
{
setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
}
// Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try
{
dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error dropping database: {ex}");
return false;
}
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error clearing database: {ex}");
return false;
@@ -97,50 +138,61 @@ public static class Program
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
}
return true;
}
/// <summary>
/// Creates the <c>Biergarten</c> database on the master connection if it does not
/// already exist. Errors encountered while creating the database are logged but do
/// not stop execution.
/// </summary>
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten]
""";
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten]
""";
var myCommand = new SqlCommand(str, myConn);
SqlCommand myCommand = new(str, myConn);
try
{
myConn.Open();
myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error creating database: {ex}");
}
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
}
return true;
}
/// <summary>
/// Migration runner entry point. Optionally clears the existing database when the
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
/// database exists, then deploys all pending migrations.
/// </summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
public static int Main(string[] args)
{
Console.WriteLine("Starting database migrations...");
try
{
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true")
{
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -148,18 +200,16 @@ public static class Program
}
CreateDatabaseIfNotExists();
var success = DeployMigrations();
bool success = DeployMigrations();
if (success)
{
Console.WriteLine("Database migrations completed successfully.");
return 0;
}
else
{
Console.WriteLine("Database migrations failed.");
return 1;
}
Console.WriteLine("Database migrations failed.");
return 1;
}
catch (Exception ex)
{

View File

@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
UserAccountID UNIQUEIDENTIFIER
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(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
DateOfBirth DATE NOT NULL,
DateOfBirth DATE NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserAccount
PRIMARY KEY (UserAccountID),
@@ -56,29 +56,30 @@ 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
(
PhotoID UNIQUEIDENTIFIER
PhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
Hyperlink NVARCHAR(256),
Hyperlink NVARCHAR(256),
-- storage is handled via filesystem or cloud service
UploadedByID UNIQUEIDENTIFIER NOT NULL,
UploadedAt DATETIME NOT NULL
UploadedAt DATETIME NOT NULL
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_Photo
PRIMARY KEY (PhotoID),
CONSTRAINT FK_Photo_UploadedBy
FOREIGN KEY (UploadedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID);
----------------------------------------------------------------------------
@@ -86,31 +87,32 @@ CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
(
UserAvatarID UNIQUEIDENTIFIER
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE,
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID),
REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);
----------------------------------------------------------------------------
@@ -118,29 +120,30 @@ CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE TABLE UserVerification -- delete verification data when user account is deleted
(
UserVerificationID UNIQUEIDENTIFIER
UserVerificationID UNIQUEIDENTIFIER
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserVerification
PRIMARY KEY (UserVerificationID),
CONSTRAINT FK_UserVerification_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE,
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT AK_UserVerification_UserAccountID
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID);
----------------------------------------------------------------------------
@@ -151,37 +154,39 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserCredentialID UNIQUEIDENTIFIER
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(),
Expiry DATETIME NOT NULL
Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(256) NOT NULL,
Hash NVARCHAR(256) NOT NULL,
-- uses argon2
IsRevoked BIT NOT NULL
IsRevoked BIT NOT NULL
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
RevokedAt DATETIME NULL,
RevokedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserCredential
PRIMARY KEY (UserCredentialID),
CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
@@ -190,39 +195,42 @@ CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE TABLE UserFollow
(
UserFollowID UNIQUEIDENTIFIER
UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_UserFollow
PRIMARY KEY (UserFollowID),
CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID)
);
CHECK (UserAccountID != FollowingID
)
);
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID);
----------------------------------------------------------------------------
@@ -230,14 +238,14 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE TABLE Country
(
CountryID UNIQUEIDENTIFIER
CountryID UNIQUEIDENTIFIER
CONSTRAINT DF_CountryID DEFAULT NEWID(),
CountryName NVARCHAR(100) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
ISO3166_1 CHAR(2) NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_Country
PRIMARY KEY (CountryID),
@@ -251,17 +259,17 @@ CREATE TABLE Country
CREATE TABLE StateProvince
(
StateProvinceID UNIQUEIDENTIFIER
StateProvinceID UNIQUEIDENTIFIER
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
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
CountryID UNIQUEIDENTIFIER NOT NULL,
CountryID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_StateProvince
PRIMARY KEY (StateProvinceID),
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID)
REFERENCES Country(CountryID)
REFERENCES Country (CountryID)
);
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID);
----------------------------------------------------------------------------
@@ -282,24 +291,25 @@ CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE TABLE City
(
CityID UNIQUEIDENTIFIER
CityID UNIQUEIDENTIFIER
CONSTRAINT DF_CityID DEFAULT NEWID(),
CityName NVARCHAR(100) NOT NULL,
CityName NVARCHAR(100) NOT NULL,
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_City
PRIMARY KEY (CityID),
CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID)
REFERENCES StateProvince(StateProvinceID)
REFERENCES StateProvince (StateProvinceID)
);
CREATE NONCLUSTERED INDEX IX_City_StateProvince
CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID);
----------------------------------------------------------------------------
@@ -310,29 +320,30 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER
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(512) NOT NULL,
CreatedAt DATETIME NOT NULL
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BreweryPost
PRIMARY KEY (BreweryPostID),
CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID);
----------------------------------------------------------------------------
@@ -343,19 +354,19 @@ CREATE TABLE BreweryPostLocation
BreweryPostLocationID UNIQUEIDENTIFIER
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,
Timer ROWVERSION,
CONSTRAINT PK_BreweryPostLocation
PRIMARY KEY (BreweryPostLocationID),
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE,
REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID)
REFERENCES City(CityID)
REFERENCES City (CityID)
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
-- To assess when the time comes:
@@ -399,33 +412,35 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
BreweryPostPhotoID UNIQUEIDENTIFIER
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BreweryPostPhoto
PRIMARY KEY (BreweryPostPhotoID),
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE,
REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostPhoto_Photo
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID)
ON DELETE CASCADE
REFERENCES Photo (PhotoID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID);
----------------------------------------------------------------------------
@@ -436,11 +451,11 @@ CREATE TABLE BeerStyle
BeerStyleID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
StyleName NVARCHAR(100) NOT NULL,
StyleName NVARCHAR(100) NOT NULL,
Description NVARCHAR(MAX),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerStyle
PRIMARY KEY (BeerStyleID),
@@ -454,47 +469,47 @@ CREATE TABLE BeerStyle
CREATE TABLE BeerPost
(
BeerPostID UNIQUEIDENTIFIER
BeerPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
Name NVARCHAR(100) NOT NULL,
Name NVARCHAR(100) 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%)
IBU INT NOT NULL,
IBU INT NOT NULL,
-- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL,
PostedByID 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(),
UpdatedAt DATETIME,
UpdatedAt DATETIME,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPost
PRIMARY KEY (BeerPostID),
CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID)
REFERENCES BeerStyle(BeerStyleID),
REFERENCES BeerStyle (BeerStyleID),
CONSTRAINT FK_BeerPost_Brewery
FOREIGN KEY (BrewedByID)
REFERENCES BreweryPost(BreweryPostID),
REFERENCES BreweryPost (BreweryPostID),
CONSTRAINT CHK_BeerPost_ABV
CHECK (ABV >= 0 AND ABV <= 67),
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120)
);
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID);
----------------------------------------------------------------------------
@@ -520,33 +538,35 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
BeerPostPhotoID UNIQUEIDENTIFIER
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(),
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostPhoto
PRIMARY KEY (BeerPostPhotoID),
CONSTRAINT FK_BeerPostPhoto_BeerPost
FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID)
ON DELETE CASCADE,
REFERENCES BeerPost (BeerPostID)
ON DELETE CASCADE,
CONSTRAINT FK_BeerPostPhoto_Photo
FOREIGN KEY (PhotoID)
REFERENCES Photo(PhotoID)
ON DELETE CASCADE
REFERENCES Photo (PhotoID)
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID);
----------------------------------------------------------------------------
@@ -557,39 +577,41 @@ CREATE TABLE BeerPostComment
BeerPostCommentID UNIQUEIDENTIFIER
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(),
UpdatedAt DATETIME NULL,
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID),
REFERENCES BeerPost (BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

@@ -1,15 +1,18 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
CREATE
OR
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER;
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
SELECT @CountryId = CountryID
FROM dbo.Country
WHERE ISO3166_1 = @CountryCode;
RETURN @CountryId;
RETURN @CountryId;
END;

View File

@@ -1,13 +1,16 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
(
CREATE
OR
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;
RETURN @StateProvinceId;
END;

View File

@@ -1,36 +1,34 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
(
CREATE
OR
ALTER PROCEDURE usp_CreateUserAccount
(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR(64),
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128)
)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(
Username,
FirstName,
LastName,
DateOfBirth,
Email
@Email VARCHAR (128)
)
AS
BEGIN
SET
NOCOUNT ON;
DECLARE
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(Username,
FirstName,
LastName,
DateOfBirth,
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES
VALUES
(
@Username,
@FirstName,
@LastName,
@DateOfBirth,
@Email
@Username, @FirstName, @LastName, @DateOfBirth, @Email
);
SELECT @UserAccountId = UserAccountID FROM @Inserted;
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +1,24 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
(
CREATE
OR
ALTER PROCEDURE usp_DeleteUserAccount
(
@UserAccountId UNIQUEIDENTIFIER
)
AS
)
AS
BEGIN
SET NOCOUNT ON
SET
NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR('UserAccount with the specified ID does not exist.', 16,
BEGIN
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
ROLLBACK TRANSACTION
RETURN
END
END
DELETE FROM UserAccount
WHERE UserAccountId = @UserAccountId;
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,16 +1,19 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
CREATE
OR
ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR(128)
)
AS
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR (128)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
CREATE
OR
ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END

View File

@@ -1,19 +1,22 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR(64)
)
AS
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR (64)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;

View File

@@ -1,27 +1,30 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR(64),
CREATE
OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Email VARCHAR (128),
@UserAccountId UNIQUEIDENTIFIER
)
AS
)
AS
BEGIN
SET
NOCOUNT ON;
NOCOUNT ON;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
UPDATE UserAccount
SET Username = @Username,
FirstName = @FirstName,
LastName = @LastName,
DateOfBirth = @DateOfBirth,
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0
BEGIN
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;
END
50001, 'UserAccount with the specified ID does not exist.', 1;
END
END;

View File

@@ -1,17 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
CREATE
OR
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT
UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,24 +1,30 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_
AND IsRevoked != 1;
COMMIT TRANSACTION;
COMMIT TRANSACTION;
END;

View File

@@ -1,21 +1,27 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR(64),
CREATE
OR
ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@DateOfBirth DATETIME,
@Email VARCHAR(128),
@Email VARCHAR (128),
@Hash NVARCHAR(MAX)
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@Username = @Username,
@FirstName = @FirstName,
@@ -23,20 +29,24 @@ BEGIN
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF @UserAccountId_ IS NULL
BEGIN
THROW 50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF @@ROWCOUNT = 0
BEGIN
THROW 50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
IF
@UserAccountId_ IS NULL
BEGIN
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF
@@ROWCOUNT = 0
BEGIN
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;
SELECT @UserAccountId_ AS UserAccountId;
END

View File

@@ -1,28 +1,33 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
IF @@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials -- set them to revoked
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
UPDATE dbo.UserCredential
SET IsRevoked = 1,
RevokedAt = GETDATE()
WHERE UserAccountId = @UserAccountId_;
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
END;

View File

@@ -1,22 +1,29 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @VerificationDateTime IS NULL
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF @@ROWCOUNT = 0
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
INSERT INTO dbo.UserVerification
(UserAccountId, VerificationDateTime)
VALUES (@UserAccountID_, @VerificationDateTime);
COMMIT TRANSACTION;
COMMIT TRANSACTION;
END

View File

@@ -1,30 +1,40 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF @StateProvinceId IS NULL
BEGIN
THROW 50001, 'State/province does not exist', 1;
END
BEGIN
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
BEGIN
THROW 50002, 'City already exists.', 1;
END
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
INSERT INTO dbo.City
(StateProvinceID, CityName)
VALUES (@StateProvinceId, @CityName);
COMMIT TRANSACTION
END;

View File

@@ -1,21 +1,26 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES
(@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,27 +1,34 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF @CountryId IS NULL
BEGIN
THROW 50001, 'Country does not exist', 1;
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES
(@StateProvinceName, @ISO3166_2, @CountryId);
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@@ -7,44 +9,53 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
COMMIT TRANSACTION;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
SELECT @NewBreweryID AS BreweryPostID,
@NewBrewerLocationID AS BreweryPostLocationID;
END

View File

@@ -0,0 +1,20 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_DeleteBrewery
@BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,30 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,9 +1,11 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
CREATE
OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
SELECT *
FROM BreweryPost bp
INNER JOIN BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
WHERE bp.BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,79 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -1,25 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Database.Seed</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Database.Seed</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="dbup" Version="5.0.41" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="dbup" Version="5.0.41" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,7 +2,18 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Defines a unit of seed data that can be applied to the database. Implementations
/// should be safe to run against an already-seeded database (e.g. by checking for
/// existing data before inserting) and may depend on data created by seeders that run
/// before them.
/// </summary>
internal interface ISeeder
{
/// <summary>
/// Inserts this seeder's data into the database using the supplied open connection.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when seeding is finished.</returns>
Task SeedAsync(SqlConnection connection);
}

View File

@@ -3,22 +3,34 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
/// associate other entities (e.g. breweries, users) with a geographic location.
/// Countries must be seeded before states/provinces, which must be seeded before
/// cities, since each level references its parent by code. The underlying stored
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
/// against an already-seeded database is safe.
/// </summary>
internal class LocationSeeder : ISeeder
{
private static readonly IReadOnlyList<(
string CountryName,
string CountryCode
)> Countries =
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
[
("Canada", "CA"),
("Mexico", "MX"),
("United States", "US"),
];
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
{
get;
} =
/// <summary>
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
/// and the ISO 3166-1 code of its parent country.
/// </summary>
private static IReadOnlyList<(
string StateProvinceName,
string StateProvinceCode,
string CountryCode
)> States { get; } =
[
("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"),
@@ -123,6 +135,10 @@ internal class LocationSeeder : ISeeder
("Ciudad de México", "MX-CMX", "MX"),
];
/// <summary>
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
/// parent state/province.
/// </summary>
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[
("US-CA", "Los Angeles"),
@@ -240,41 +256,41 @@ internal class LocationSeeder : ISeeder
("MX-ZAC", "Zacatecas"),
];
/// <summary>
/// Seeds all countries, then states/provinces, then cities, in that order, so that
/// each level's parent reference already exists by the time it is created.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all locations have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
foreach (var (countryName, countryCode) in Countries)
{
foreach ((string countryName, string countryCode) in Countries)
await CreateCountryAsync(connection, countryName, countryCode);
}
foreach (
var (stateProvinceName, stateProvinceCode, countryCode) in States
)
{
foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
await CreateStateProvinceAsync(
connection,
stateProvinceName,
stateProvinceCode,
countryCode
);
}
foreach (var (stateProvinceCode, cityName) in Cities)
{
foreach ((string stateProvinceCode, string cityName) in Cities)
await CreateCityAsync(connection, cityName, stateProvinceCode);
}
}
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="countryName">The display name of the country.</param>
/// <param name="countryCode">The ISO 3166-1 code of the country.</param>
/// <returns>A task that completes when the country has been created.</returns>
private static async Task CreateCountryAsync(
SqlConnection connection,
string countryName,
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCountry",
connection
);
await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
@@ -282,6 +298,12 @@ internal class LocationSeeder : ISeeder
await command.ExecuteNonQueryAsync();
}
/// <summary>Creates a single state/province by invoking the <c>dbo.USP_CreateStateProvince</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="stateProvinceName">The display name of the state/province.</param>
/// <param name="stateProvinceCode">The ISO 3166-2 code of the state/province.</param>
/// <param name="countryCode">The ISO 3166-1 code of the parent country, which must already exist.</param>
/// <returns>A task that completes when the state/province has been created.</returns>
private static async Task CreateStateProvinceAsync(
SqlConnection connection,
string stateProvinceName,
@@ -289,37 +311,30 @@ internal class LocationSeeder : ISeeder
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateStateProvince",
connection
);
await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue(
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode);
await command.ExecuteNonQueryAsync();
}
/// <summary>Creates a single city by invoking the <c>dbo.USP_CreateCity</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="cityName">The display name of the city.</param>
/// <param name="stateProvinceCode">The ISO 3166-2 code of the parent state/province, which must already exist.</param>
/// <returns>A task that completes when the city has been created.</returns>
private static async Task CreateCityAsync(
SqlConnection connection,
string cityName,
string stateProvinceCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCity",
connection
);
await using SqlCommand command = new("dbo.USP_CreateCity", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue(
"@StateProvinceCode",
stateProvinceCode
);
command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
await command.ExecuteNonQueryAsync();
}

View File

@@ -1,42 +1,57 @@
using Microsoft.Data.SqlClient;
using DbUp;
using System.Reflection;
using Database.Seed;
using Database.Seed;
using Microsoft.Data.SqlClient;
// Entry point for the database seeding utility. Connects to the target database
// (retrying on transient failures), then runs each registered ISeeder in order to
// populate location and user data.
/// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
/// environment variables.
/// </summary>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
/// is not set.
/// </exception>
string BuildConnectionString()
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
string dbName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true
Encrypt = true,
};
return builder.ConnectionString;
}
try
{
var connectionString = BuildConnectionString();
string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database...");
@@ -46,7 +61,6 @@ try
int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
connection = new SqlConnection(connectionString);
@@ -62,24 +76,17 @@ try
connection?.Dispose();
connection = null;
}
}
if (connection == null)
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
Console.WriteLine("Starting seeding...");
using (connection)
{
ISeeder[] seeders =
[
new LocationSeeder(),
new UserSeeder(),
];
ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
foreach (var seeder in seeders)
foreach (ISeeder seeder in seeders)
{
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection);

View File

@@ -7,12 +7,19 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
/// for testing, followed by a randomly-generated account for each name in
/// <see cref="SeedNames" />, each with a random password and date of birth. Skips
/// adding a verification record for a user that already has one, so this seeder is
/// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist.
/// </summary>
internal class UserSeeder : ISeeder
{
private static readonly IReadOnlyList<(
string FirstName,
string LastName
)> SeedNames =
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
[
("Aarya", "Mathews"),
("Aiden", "Wells"),
@@ -116,10 +123,18 @@ internal class UserSeeder : ISeeder
("Zoie", "Armstrong"),
];
/// <summary>
/// Registers a fixed test user account followed by one randomly-generated account
/// per entry in <see cref="SeedNames" />, adding a user verification record for each
/// newly created account that does not already have one. Progress counts are
/// written to the console on completion.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
var generator = new PasswordGenerator();
var rng = new Random();
PasswordGenerator generator = new();
Random rng = new();
int createdUsers = 0;
int createdCredentials = 0;
int createdVerifications = 0;
@@ -129,8 +144,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test";
const string lastName = "User";
const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
DateTime dob = new(1985, 03, 01);
string hash = GeneratePasswordHash("password");
await RegisterUserAsync(
connection,
@@ -142,24 +157,19 @@ internal class UserSeeder : ISeeder
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
foreach ((string firstName, string lastName) in SeedNames)
{
// prepare user fields
var username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng);
string username = $"{firstName[0]}.{lastName}";
string email = $"{firstName}.{lastName}@thebiergarten.app";
DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string pwd = generator.Generate(64, 10, 10);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
Guid id = await RegisterUserAsync(
connection,
username,
firstName,
@@ -171,9 +181,9 @@ internal class UserSeeder : ISeeder
createdUsers++;
createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
if (await HasUserVerificationAsync(connection, id))
continue;
await AddUserVerificationAsync(connection, id);
createdVerifications++;
@@ -184,6 +194,18 @@ internal class UserSeeder : ISeeder
Console.WriteLine($"Added {createdVerifications} user verifications.");
}
/// <summary>
/// Registers a new user account and its credential by invoking the
/// <c>dbo.USP_RegisterUser</c> stored procedure.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="username">The unique username for the account.</param>
/// <param name="firstName">The user's first name.</param>
/// <param name="lastName">The user's last name.</param>
/// <param name="dateOfBirth">The user's date of birth.</param>
/// <param name="email">The user's email address.</param>
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
/// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
private static async Task<Guid> RegisterUserAsync(
SqlConnection connection,
string username,
@@ -194,10 +216,9 @@ internal class UserSeeder : ISeeder
string hash
)
{
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
@@ -205,17 +226,23 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
/// <summary>
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
/// salt, a degree of parallelism matching the available processor count, 64 MB of
/// memory, and 4 iterations.
/// </summary>
/// <param name="pwd">The plaintext password to hash.</param>
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
private static string GeneratePasswordHash(string pwd)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
@@ -227,37 +254,45 @@ internal class UserSeeder : ISeeder
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
/// <summary>Checks whether a user verification record already exists for the given user account.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The user account identifier to check.</param>
/// <returns><c>true</c> if a verification record already exists; otherwise <c>false</c>.</returns>
private static async Task<bool> HasUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
{
const string sql = """
SELECT 1
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
SELECT 1
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return result is not null;
}
private static async Task AddUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The identifier of the user account to verify.</param>
/// <returns>A task that completes when the verification record has been created.</returns>
private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
{
await using var command = new SqlCommand(
"dbo.USP_CreateUserVerification",
connection
);
await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Generates a random date of birth corresponding to an age between 19 and 48
/// years (inclusive), with a random day offset within that birth year.
/// </summary>
/// <param name="random">The random number source to use.</param>
/// <returns>A randomly generated date of birth.</returns>
private static DateTime GenerateDateOfBirth(Random random)
{
int age = 19 + random.Next(0, 30);

View File

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

View File

@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Domain</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Domain</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -1,13 +1,50 @@
namespace Domain.Entities;
/// <summary>
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
/// A user account cannot be deleted while it has an associated brewery post.
/// </summary>
public class BreweryPost
{
/// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
/// </summary>
public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery being posted about.
/// </summary>
public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// Free-text description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time the post was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
/// <summary>
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
/// navigation property.
/// </summary>
public BreweryPostLocation? Location { get; set; }
}

View File

@@ -1,13 +1,52 @@
namespace Domain.Entities;
/// <summary>
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
/// deleted.
/// </summary>
public class BreweryPostLocation
{
public Guid BreweryPostLocationId { get; set; }
public Guid BreweryPostId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public Guid CityId { get; set; }
public byte[]? Coordinates { get; set; }
public byte[]? Timer { get; set; }
/// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary>
public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// The primary street address line.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g., suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the location.
/// </summary>
public string PostalCode { get; set; } = string.Empty;
/// <summary>
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
/// </summary>
public Guid CityId { get; set; }
/// <summary>
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
/// <c>null</c> if not set.
/// </summary>
public byte[]? Coordinates { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,14 +1,55 @@
namespace Domain.Entities;
/// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
/// data.
/// </summary>
public class UserAccount
{
public Guid UserAccountId { get; set; }
public string Username { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public DateTime DateOfBirth { get; set; }
public byte[]? Timer { get; set; }
/// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The user's unique username, used for login and display. Enforced unique at the database level.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// The user's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// The user's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// The user's email address. Enforced unique at the database level.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// The date and time the account was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// The user's date of birth.
/// </summary>
public DateTime DateOfBirth { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,11 +1,40 @@
namespace Domain.Entities;
/// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
/// deleted.
/// </summary>
public class UserCredential
{
public Guid UserCredentialId { get; set; }
public Guid UserAccountId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime Expiry { get; set; }
public string Hash { get; set; } = string.Empty;
public byte[]? Timer { get; set; }
/// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary>
public Guid UserCredentialId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time the credential was issued.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time after which the credential is no longer valid.
/// </summary>
public DateTime Expiry { get; set; }
/// <summary>
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,9 +1,31 @@
namespace Domain.Entities;
/// <summary>
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
/// and it is deleted automatically when the owning user account is deleted.
/// </summary>
public class UserVerification
{
public Guid UserVerificationId { get; set; }
public Guid UserAccountId { get; set; }
public DateTime VerificationDateTime { get; set; }
public byte[]? Timer { get; set; }
/// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary>
public Guid UserVerificationId { get; set; }
/// <summary>
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time at which the user account was verified.
/// </summary>
public DateTime VerificationDateTime { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,33 +1,33 @@
namespace Domain.Exceptions;
/// <summary>
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
/// Maps to HTTP 409 Conflict.
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
/// Maps to HTTP 409 Conflict.
/// </summary>
public class ConflictException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a requested resource is not found.
/// Maps to HTTP 404 Not Found.
/// Exception thrown when a requested resource is not found.
/// Maps to HTTP 404 Not Found.
/// </summary>
public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs
/// <summary>
/// Exception thrown when authentication fails or is required.
/// Maps to HTTP 401 Unauthorized.
/// Exception thrown when authentication fails or is required.
/// Maps to HTTP 401 Unauthorized.
/// </summary>
public class UnauthorizedException(string message) : Exception(message);
/// <summary>
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
/// Maps to HTTP 403 Forbidden.
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
/// Maps to HTTP 403 Forbidden.
/// </summary>
public class ForbiddenException(string message) : Exception(message);
/// <summary>
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
/// Maps to HTTP 400 Bad Request.
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
/// Maps to HTTP 400 Bad Request.
/// </summary>
public class ValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,102 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
public ConfirmUserHandlerTests()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object);
}
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
{
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
return new ValidatedToken(userId, username, principal);
}
[Fact]
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
{
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string confirmationToken = "valid-confirmation-token";
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
ConfirmationPayload result = await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
CancellationToken.None
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId);
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task Handle_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{
const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
{
Guid userId = Guid.NewGuid();
const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null);
Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
CancellationToken.None
);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Dtos;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
public class RefreshTokenHandlerTests
{
[Fact]
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
{
Mock<ITokenService> tokenServiceMock = new();
RefreshTokenHandler handler = new(tokenServiceMock.Object);
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
tokenServiceMock
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
LoginPayload result = await handler.Handle(
new RefreshTokenCommand("old-refresh-token"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("testuser");
result.RefreshToken.Should().Be("new-refresh-token");
result.AccessToken.Should().Be("new-access-token");
}
}

View File

@@ -0,0 +1,278 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using MediatR;
using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
public RegisterUserHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>();
_mediatorMock = new Mock<IMediator>();
_handler = new RegisterUserHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
}
private static RegisterUserCommand ValidCommand(
string username = "newuser",
string email = "john.doe@example.com"
)
{
return new RegisterUserCommand(
username,
"John",
"Doe",
email,
new DateTime(1990, 1, 1),
"SecurePassword123!"
);
}
[Fact]
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value";
Guid expectedUserId = Guid.NewGuid();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = expectedUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
Email = command.Email,
DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow,
}
);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Returns("confirmation-token");
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be(command.Username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
result.ConfirmationEmailSent.Should().BeTrue();
_mediatorMock.Verify(
x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
public async Task Handle_WithExistingUsername_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Username = "existinguser",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync(existingUser);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
_authRepoMock.Verify(
x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Never
);
}
[Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Email = "existing@example.com",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
}
[Fact]
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
hashedPassword
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
await _handler.Handle(command, CancellationToken.None);
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
_authRepoMock.Verify(
x =>
x.RegisterUserAsync(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
),
Times.Once
);
}
[Fact]
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
{
RegisterUserCommand command = ValidCommand();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_mediatorMock
.Setup(x =>
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
)
.ThrowsAsync(new Exception("smtp down"));
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.ConfirmationEmailSent.Should().BeFalse();
result.AccessToken.Should().Be("access-token");
}
}

View File

@@ -0,0 +1,96 @@
using Domain.Entities;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
public ResendConfirmationEmailHandlerTests()
{
_handler = new ResendConfirmationEmailHandler(
_authRepositoryMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
}
[Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new()
{
UserAccountId = userId,
FirstName = "Aaron",
Email = "aaron@example.com",
};
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.Is<SendResendConfirmationEmailCommand>(c =>
c.FirstName == "Aaron"
&& c.Email == "aaron@example.com"
&& c.ConfirmationToken == "fresh-token"
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{
Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
[Fact]
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.Auth.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,155 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using Moq;
namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
public LoginHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
}
[Fact]
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
const string username = "CogitoErgoSum";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new()
{
UserAccountId = userAccountId,
Username = username,
FirstName = "René",
LastName = "Descartes",
Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31),
};
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "some-hash",
Expiry = DateTime.MaxValue,
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
LoginPayload result = await _handler.Handle(
new LoginQuery(username, "any-password"),
CancellationToken.None
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId);
result.Username.Should().Be(username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
}
[Fact]
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{
const string username = "de_beauvoir";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(username))
.ReturnsAsync((UserAccount?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{
const string username = "BRussell";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{
const string username = "RCarnap";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "hashed-password",
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(
new LoginQuery(username, "wrong-password"),
CancellationToken.None
);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
}
}

View File

@@ -1,23 +1,24 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Features.Auth.Repository;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Tests.Database;
namespace Infrastructure.Repository.Tests.Auth;
namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTest
public class AuthRepositoryTests
{
private static AuthRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
private static AuthRepository CreateRepo(MockDbConnection conn)
{
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{
var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsScalar(expectedUserId);
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")
@@ -47,14 +48,14 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync(
username: "testuser",
firstName: "Test",
lastName: "User",
email: "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123"
AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync(
"testuser",
"Test",
"User",
"test@example.com",
new DateTime(1990, 1, 1),
"hashedpassword123"
);
result.Should().NotBeNull();
@@ -69,8 +70,8 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
@@ -99,8 +100,8 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -113,13 +114,13 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull();
}
@@ -127,12 +128,10 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(
MockTable
.WithColumns(
@@ -159,8 +158,8 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -171,15 +170,13 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull();
}
@@ -187,13 +184,11 @@ public class AuthRepositoryTest
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{
var userId = Guid.NewGuid();
var credentialId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(
MockTable
.WithColumns(
@@ -203,17 +198,11 @@ public class AuthRepositoryTest
("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
)
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
);
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId);
@@ -224,16 +213,14 @@ public class AuthRepositoryTest
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull();
}
@@ -241,18 +228,16 @@ public class AuthRepositoryTest
[Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
var repo = CreateRepo(conn);
AuthRepository repo = CreateRepo(conn);
// Should not throw
var act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash);
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
}

View File

@@ -1,11 +1,14 @@
using System.Data.Common;
using Infrastructure.Repository.Sql;
using Infrastructure.Sql;
namespace Infrastructure.Repository.Tests.Database;
namespace Features.Auth.Tests.Repository;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{
private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn;
public DbConnection CreateConnection()
{
return _conn;
}
}

View File

@@ -0,0 +1,173 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceRefreshTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
}
[Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
UserAccount userAccount = new()
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
};
// Mock the validation of refresh token
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
// Mock the generation of new tokens
_tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns(
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
// Act
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert
result.Should().NotBeNull();
result.UserAccount.UserAccountId.Should().Be(userId);
result.UserAccount.Username.Should().Be(username);
result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
// Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify(
x =>
x.GenerateJwt(
It.IsAny<Guid>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Exactly(2)
);
}
[Fact]
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string invalidToken = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string expiredToken = "expired-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}

View File

@@ -0,0 +1,293 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceValidationTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.Principal.Should().NotBeNull();
result
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
?.Value.Should()
.Be(userId.ToString());
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-confirmation-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "expired-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Token has expired"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-without-user-id";
// Claims without Sub (user ID)
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string token = "token-without-username";
// Claims without UniqueName (username)
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*");
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-confirmation-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
}

View File

@@ -0,0 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -0,0 +1,38 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
/// </exception>
public async Task<ConfirmationPayload> Handle(
ConfirmUserCommand request,
CancellationToken cancellationToken
)
{
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
request.Token
);
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
/// request body of <c>POST /api/auth/refresh</c>.
/// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -0,0 +1,28 @@
using Features.Auth.Dtos;
using Features.Auth.Services;
using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair.
/// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload>
{
public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken
)
{
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(
result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.RefreshToken,
result.AccessToken
);
}
}

View File

@@ -0,0 +1,17 @@
using FluentValidation;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{
/// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary>
public RefreshTokenValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
}
}

View File

@@ -0,0 +1,28 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
/// </summary>
/// <param name="Username">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
/// <param name="Password">
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
/// lowercase letter, a number, and a special character.
/// </param>
public record RegisterUserCommand(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
) : IRequest<RegistrationPayload>;

View File

@@ -0,0 +1,102 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
public class RegisterUserHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
public async Task<RegistrationPayload> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken
)
{
await ValidateUserDoesNotExist(request.Username, request.Email);
string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username,
request.FirstName,
request.LastName,
request.Email,
request.DateOfBirth,
hashed
);
string accessToken = tokenService.GenerateAccessToken(createdUser);
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
bool emailSent = false;
try
{
await mediator.Send(
new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken
);
emailSent = true;
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
}
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
private async Task ValidateUserDoesNotExist(string username, string email)
{
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
throw new ConflictException("Username or email already exists");
}
}

View File

@@ -1,20 +1,17 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
namespace Features.Auth.Commands.RegisterUser;
public record RegisterRequest(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
);
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
/// <summary>
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterRequestValidator()
/// <summary>
/// Configures validation rules for username format and length, first/last name length, email format and
/// length, minimum age based on date of birth, and password strength requirements.
/// </summary>
public RegisterUserValidator()
{
RuleFor(x => x.Username)
.NotEmpty()
@@ -64,8 +61,6 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
.Matches("[0-9]")
.WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]")
.WithMessage(
"Password must contain at least one special character"
);
.WithMessage("Password must contain at least one special character");
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

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