mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
Compare commits
2 Commits
49d7f860de
...
cd0f33f603
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0f33f603 | ||
|
|
8b429be857 |
55
.github/workflows/tests.yml
vendored
Normal file
55
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
containerized-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Generate test environment file
|
||||||
|
run: ./generate-env.sh .env.test
|
||||||
|
|
||||||
|
- name: Build test images
|
||||||
|
run: docker compose --env-file .env.test -f docker-compose.test.yaml build
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: docker compose --env-file .env.test -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
|
|
||||||
|
- name: Check test container exit codes
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
status=0
|
||||||
|
for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
|
||||||
|
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
|
||||||
|
echo "$name exited with $code"
|
||||||
|
if [ "$code" != "0" ]; then
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $status
|
||||||
|
|
||||||
|
- name: Show service logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose --env-file .env.test -f docker-compose.test.yaml logs
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: web/test-results
|
||||||
|
|
||||||
|
- name: Tear down
|
||||||
|
if: always()
|
||||||
|
run: docker compose --env-file .env.test -f docker-compose.test.yaml down -v
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -501,3 +501,4 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
web/test-results/frontend/vitest-storybook.xml
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -84,7 +84,7 @@ prerequisite list, including the pipeline's C++ toolchain.
|
|||||||
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
||||||
This section is the shortest path to a working dev environment.
|
This section is the shortest path to a working dev environment.
|
||||||
|
|
||||||
### Backend (Docker)
|
### Full stack (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
@@ -94,14 +94,15 @@ cp web/.env.example web/.env.dev
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend access:
|
Access:
|
||||||
|
|
||||||
|
- Website: http://localhost:3000
|
||||||
- API Swagger: http://localhost:8080/swagger
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
- Mailpit UI (dev SMTP): http://localhost:8025
|
- Mailpit UI (dev SMTP): http://localhost:8025
|
||||||
- SeaweedFS S3 API (dev object storage): http://localhost:8333
|
- SeaweedFS S3 API (dev object storage): http://localhost:8333
|
||||||
|
|
||||||
### Frontend (Node)
|
### Frontend (Node, outside Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -140,14 +141,18 @@ archive/
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Create a test environment file, then run the backend test stack with Docker:
|
Create a test environment file, then run the full test stack (backend and
|
||||||
|
frontend) with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp web/.env.example web/.env.test
|
cp web/.env.example web/.env.test
|
||||||
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 up --abort-on-container-exit
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Testing](docs/website/testing.md) for the full command list.
|
The same command runs in CI via
|
||||||
|
[`.github/workflows/tests.yml`](.github/workflows/tests.yml) on every push
|
||||||
|
and pull request to `main`. See [Testing](docs/website/testing.md) for the
|
||||||
|
full command list.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ The project uses Docker Compose to orchestrate multiple services:
|
|||||||
- Database migrations runner (DbUp)
|
- Database migrations runner (DbUp)
|
||||||
- Database seeder
|
- Database seeder
|
||||||
- .NET API
|
- .NET API
|
||||||
|
- React Router website (SSR frontend)
|
||||||
- Test runners
|
- Test runners
|
||||||
|
|
||||||
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
||||||
@@ -39,6 +40,7 @@ sqlserver # SQL Server 2022 (port 1434)
|
|||||||
database.migrations # DbUp migrations
|
database.migrations # DbUp migrations
|
||||||
database.seed # Seed initial data
|
database.seed # Seed initial data
|
||||||
api.core # Web API (ports 8080, 8081)
|
api.core # Web API (ports 8080, 8081)
|
||||||
|
frontend # React Router website (port 3000)
|
||||||
mailpit # Local dev SMTP server + UI (port 8025)
|
mailpit # Local dev SMTP server + UI (port 8025)
|
||||||
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
|
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
|
||||||
```
|
```
|
||||||
@@ -51,6 +53,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
|||||||
|
|
||||||
**Access**:
|
**Access**:
|
||||||
|
|
||||||
|
- Website: http://localhost:3000
|
||||||
- API Swagger: http://localhost:8080/swagger
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
- Mailpit UI: http://localhost:8025
|
- Mailpit UI: http://localhost:8025
|
||||||
@@ -90,6 +93,7 @@ database.migrations # Fresh schema
|
|||||||
database.seed # Test data
|
database.seed # Test data
|
||||||
api.specs # Reqnroll BDD tests
|
api.specs # Reqnroll BDD tests
|
||||||
unit.tests # All Features.*.Tests unit test projects
|
unit.tests # All Features.*.Tests unit test projects
|
||||||
|
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Run Tests**:
|
**Run Tests**:
|
||||||
@@ -119,13 +123,16 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
|||||||
- Health checks enabled
|
- Health checks enabled
|
||||||
- Restart policies (unless-stopped)
|
- Restart policies (unless-stopped)
|
||||||
- Security hardening
|
- Security hardening
|
||||||
|
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core`
|
||||||
|
are reachable only from other containers on `prodnet`
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sqlserver # Production SQL Server
|
sqlserver # Production SQL Server (internal only)
|
||||||
database.migrations # Schema updates only
|
database.migrations # Schema updates only
|
||||||
api.core # Production API
|
api.core # Production API (internal only, no published ports)
|
||||||
|
frontend # Production website (port 3000, the only public binding)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deploy Production**:
|
**Deploy Production**:
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
|||||||
### Frontend (`web/frontend`)
|
### Frontend (`web/frontend`)
|
||||||
|
|
||||||
The active website reads runtime values from the server environment for its auth
|
The active website reads runtime values from the server environment for its auth
|
||||||
and API integration.
|
and API integration. In Docker, the `frontend` service is wired into the same
|
||||||
|
`web/.env.dev` / `.env.test` / `.env.prod` files as the backend; running it
|
||||||
|
locally with `npm run dev` still reads from the shell environment.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -34,8 +36,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
|||||||
|
|
||||||
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
|
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
|
||||||
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
|
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
|
||||||
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
|
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
|
||||||
`SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`, `SEAWEEDFS_SECRET_ACCESS_KEY`)
|
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
|
||||||
|
`SEAWEEDFS_SECRET_ACCESS_KEY`)
|
||||||
with freshly randomized ones via `openssl`. Non-secret values are left
|
with freshly randomized ones via `openssl`. Non-secret values are left
|
||||||
untouched. The output file is written with `600` permissions.
|
untouched. The output file is written with `600` permissions.
|
||||||
|
|
||||||
@@ -173,19 +176,26 @@ The active website does not use the old Next.js/Prisma environment model. Its
|
|||||||
core runtime variables are:
|
core runtime variables are:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
API_BASE_URL=http://api.core:8080 # Base URL for the .NET API (container DNS name in Docker)
|
||||||
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||||
NODE_ENV=development # Standard Node runtime mode
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
|
PORT=3000 # Port the built server listens on
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080`
|
||||||
|
(the backend's in-network service name) and `PORT` to `3000`; only
|
||||||
|
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per
|
||||||
|
compose file (`development` in dev, `production` in prod).
|
||||||
|
|
||||||
### Frontend variable details
|
### Frontend variable details
|
||||||
|
|
||||||
#### `API_BASE_URL`
|
#### `API_BASE_URL`
|
||||||
|
|
||||||
- **Required**: Yes for local development
|
- **Required**: Yes for local development
|
||||||
- **Default in code**: `http://localhost:8080`
|
- **Default in code**: `http://localhost:8080`
|
||||||
- **Used by**: `web/frontend/app/lib/auth.server.ts`
|
- **Used by**: `web/frontend/app/features/auth/auth.server.ts`,
|
||||||
- **Purpose**: Routes website auth actions to the .NET API
|
`web/frontend/app/features/breweries/breweries.server.ts`
|
||||||
|
- **Purpose**: Routes website auth and API calls to the .NET API
|
||||||
|
|
||||||
#### `SESSION_SECRET`
|
#### `SESSION_SECRET`
|
||||||
|
|
||||||
@@ -200,6 +210,12 @@ NODE_ENV=development # Standard Node runtime mode
|
|||||||
- **Typical values**: `development`, `production`, `test`
|
- **Typical values**: `development`, `production`, `test`
|
||||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
|
|
||||||
|
#### `PORT`
|
||||||
|
|
||||||
|
- **Required**: No
|
||||||
|
- **Default**: `3000`
|
||||||
|
- **Purpose**: Port the built server (`build/server/index.js`) binds to
|
||||||
|
|
||||||
### SMTP configuration (backend)
|
### SMTP configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
||||||
@@ -312,9 +328,10 @@ reference only and is not run as part of the active stack.
|
|||||||
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
||||||
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
||||||
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
||||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
| `API_BASE_URL` | | ✓ | ✓ | Yes | Website-to-API base URL |
|
||||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
| `SESSION_SECRET` | | ✓ | ✓ | Yes | Website session signing |
|
||||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
| `NODE_ENV` | | ✓ | ✓ | No | Runtime mode |
|
||||||
|
| `PORT` | | ✓ | ✓ | No | Frontend server port |
|
||||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||||
@@ -366,6 +383,9 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
|||||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||||
WEBSITE_BASE_URL=http://localhost:3000
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Frontend session signing
|
||||||
|
SESSION_SECRET=<generated-with-openssl>
|
||||||
|
|
||||||
# SMTP (Mailpit in dev)
|
# SMTP (Mailpit in dev)
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ This command:
|
|||||||
1. Starts a fresh SQL Server instance
|
1. Starts a fresh SQL Server instance
|
||||||
2. Runs database migrations
|
2. Runs database migrations
|
||||||
3. Seeds test data
|
3. Seeds test data
|
||||||
4. Executes all test suites in parallel
|
4. Executes all test suites in parallel, including the frontend's Storybook
|
||||||
|
Vitest and Playwright suites (`frontend.tests`, which needs no database)
|
||||||
5. Exports results to `./test-results/`
|
5. Exports results to `./test-results/`
|
||||||
6. Exits when tests complete
|
6. Exits when tests complete
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ cat test-results/Features.Users.Tests.trx
|
|||||||
cat test-results/Features.Breweries.Tests.trx
|
cat test-results/Features.Breweries.Tests.trx
|
||||||
cat test-results/Features.Emails.Tests.trx
|
cat test-results/Features.Emails.Tests.trx
|
||||||
cat test-results/Features.PhotoUpload.Tests.trx
|
cat test-results/Features.PhotoUpload.Tests.trx
|
||||||
|
cat test-results/frontend/vitest-storybook.xml
|
||||||
|
cat test-results/frontend/playwright-storybook.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clean up
|
### Clean up
|
||||||
@@ -327,8 +330,10 @@ Scenario: User login with valid credentials
|
|||||||
|
|
||||||
## Continuous integration
|
## Continuous integration
|
||||||
|
|
||||||
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push
|
||||||
configuration:
|
and pull request to `main`. It runs the same test Docker Compose
|
||||||
|
configuration used locally, so backend and frontend tests execute in the same
|
||||||
|
containerized environment in CI as they do on a developer machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CI/CD command
|
# CI/CD command
|
||||||
@@ -337,13 +342,22 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abo
|
|||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Because `--abort-on-container-exit` stops the stack as soon as any one-shot
|
||||||
|
test container exits (success or failure), the workflow separately inspects
|
||||||
|
the exit code of each test container (`test-env-api-specs`,
|
||||||
|
`test-env-unit-tests`, `test-env-frontend-tests`) after `up` returns and fails
|
||||||
|
the job if any of them is non-zero. `./test-results/` is uploaded as a build
|
||||||
|
artifact regardless of outcome.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
|
|
||||||
- `0` - All tests passed
|
- `0` - All tests passed
|
||||||
- Non-zero - Test failures occurred
|
- Non-zero - Test failures occurred
|
||||||
|
|
||||||
Frontend UI checks should also be included in CI for the active website
|
The frontend's Storybook Vitest and Playwright suites run inside the
|
||||||
workspace:
|
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that
|
||||||
|
same compose run, so no separate frontend CI step is needed. To run them
|
||||||
|
locally without Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
|
|||||||
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
||||||
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Frontend (Website) Configuration
|
||||||
|
# ======================
|
||||||
|
|
||||||
|
# Cookie session signing secret for the React Router website container
|
||||||
|
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
||||||
|
# Command: openssl rand -base64 32
|
||||||
|
SESSION_SECRET=your-secure-session-secret-key
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
|
|||||||
@@ -41,5 +41,7 @@
|
|||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
95
web/backend/API/API.Specs/Features/Breweries.feature
Normal file
95
web/backend/API/API.Specs/Features/Breweries.feature
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
Feature: Brewery Management
|
||||||
|
As an authenticated user
|
||||||
|
I want to create, view, update and delete brewery posts
|
||||||
|
So that I can share information about breweries with the community
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the API is running
|
||||||
|
|
||||||
|
Scenario: Successful brewery creation
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
When I create a brewery with values:
|
||||||
|
| BreweryName | Description | AddressLine1 | PostalCode |
|
||||||
|
| Roundhouse Brewery | A cozy local brewery | 123 Main St | 90210 |
|
||||||
|
Then the response has HTTP status 201
|
||||||
|
And the response JSON should have "breweryName" equal "Roundhouse Brewery"
|
||||||
|
|
||||||
|
Scenario: Brewery creation fails without authentication
|
||||||
|
Given a city exists
|
||||||
|
When I create a brewery with values:
|
||||||
|
| BreweryName | Description | AddressLine1 | PostalCode |
|
||||||
|
| No Auth Brew | A brewery | 1 Nowhere St | 00000 |
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
|
Scenario: Brewery creation fails with a non-existent city
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I create a brewery with a non-existent city
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Retrieve an existing brewery by ID
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I retrieve the brewery by ID
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "breweryName" equal "Test Brewery"
|
||||||
|
|
||||||
|
Scenario: Retrieving a non-existent brewery returns not found
|
||||||
|
When I retrieve a brewery by a non-existent ID
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Owner can update their brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I update the brewery with values:
|
||||||
|
| BreweryName | Description |
|
||||||
|
| Renamed Brewery | An updated description |
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "breweryName" equal "Renamed Brewery"
|
||||||
|
|
||||||
|
Scenario: A different user cannot update someone else's brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
And I am logged in as a different user
|
||||||
|
When I update the brewery with values:
|
||||||
|
| BreweryName | Description |
|
||||||
|
| Hijacked | Nope |
|
||||||
|
Then the response has HTTP status 403
|
||||||
|
|
||||||
|
Scenario: Updating a non-existent brewery returns not found
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I update a non-existent brewery
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Owner can delete their brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
When I delete the brewery
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And retrieving the brewery by ID should now return HTTP status 404
|
||||||
|
|
||||||
|
Scenario: A different user cannot delete someone else's brewery
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
And a city exists
|
||||||
|
And I have created a brewery
|
||||||
|
And I am logged in as a different user
|
||||||
|
When I delete the brewery
|
||||||
|
Then the response has HTTP status 403
|
||||||
|
|
||||||
|
Scenario: Deleting a non-existent brewery returns not found
|
||||||
|
Given I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I delete a non-existent brewery
|
||||||
|
Then the response has HTTP status 404
|
||||||
39
web/backend/API/API.Specs/Steps/ApiStepsBase.cs
Normal file
39
web/backend/API/API.Specs/Steps/ApiStepsBase.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using Reqnroll;
|
||||||
|
|
||||||
|
namespace API.Specs.Steps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared <see cref="TestApiFactory" />/<see cref="HttpClient" /> plumbing and the <see cref="ScenarioContext" />
|
||||||
|
/// keys used to coordinate it, common to every step definition class that drives the test API.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ApiStepsBase(ScenarioContext scenario)
|
||||||
|
{
|
||||||
|
protected ScenarioContext Scenario { get; } = scenario;
|
||||||
|
|
||||||
|
protected const string ClientKey = "client";
|
||||||
|
protected const string FactoryKey = "factory";
|
||||||
|
protected const string ResponseKey = "response";
|
||||||
|
protected const string ResponseBodyKey = "responseBody";
|
||||||
|
|
||||||
|
protected TestApiFactory GetFactory()
|
||||||
|
{
|
||||||
|
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
|
||||||
|
FactoryKey,
|
||||||
|
out TestApiFactory? f
|
||||||
|
)
|
||||||
|
? f
|
||||||
|
: new TestApiFactory();
|
||||||
|
Scenario[FactoryKey] = factory;
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected HttpClient GetClient()
|
||||||
|
{
|
||||||
|
if (Scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||||
|
return client;
|
||||||
|
|
||||||
|
client = GetFactory().CreateClient();
|
||||||
|
Scenario[ClientKey] = client;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,40 +10,14 @@ using Reqnroll;
|
|||||||
namespace API.Specs.Steps;
|
namespace API.Specs.Steps;
|
||||||
|
|
||||||
[Binding]
|
[Binding]
|
||||||
public class AuthSteps(ScenarioContext scenario)
|
public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||||
{
|
{
|
||||||
private const string ClientKey = "client";
|
|
||||||
private const string FactoryKey = "factory";
|
|
||||||
private const string ResponseKey = "response";
|
|
||||||
private const string ResponseBodyKey = "responseBody";
|
|
||||||
private const string TestUserKey = "testUser";
|
private const string TestUserKey = "testUser";
|
||||||
private const string RegisteredUserIdKey = "registeredUserId";
|
private const string RegisteredUserIdKey = "registeredUserId";
|
||||||
private const string RegisteredUsernameKey = "registeredUsername";
|
private const string RegisteredUsernameKey = "registeredUsername";
|
||||||
private const string PreviousAccessTokenKey = "previousAccessToken";
|
private const string PreviousAccessTokenKey = "previousAccessToken";
|
||||||
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
||||||
|
|
||||||
private TestApiFactory GetFactory()
|
|
||||||
{
|
|
||||||
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
|
||||||
FactoryKey,
|
|
||||||
out TestApiFactory? f
|
|
||||||
)
|
|
||||||
? f
|
|
||||||
: new TestApiFactory();
|
|
||||||
scenario[FactoryKey] = factory;
|
|
||||||
return factory;
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpClient GetClient()
|
|
||||||
{
|
|
||||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
|
||||||
return client;
|
|
||||||
|
|
||||||
client = GetFactory().CreateClient();
|
|
||||||
scenario[ClientKey] = client;
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own
|
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own
|
||||||
/// <see cref="IConfiguration" />, so forged tokens are always signed with the exact secret the
|
/// <see cref="IConfiguration" />, so forged tokens are always signed with the exact secret the
|
||||||
@@ -107,7 +81,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I have an existing account")]
|
[Given("I have an existing account")]
|
||||||
public async Task GivenIHaveAnExistingAccount()
|
public async Task GivenIHaveAnExistingAccount()
|
||||||
{
|
{
|
||||||
scenario[TestUserKey] = (FixtureUsername, FixturePassword);
|
Scenario[TestUserKey] = (FixtureUsername, FixturePassword);
|
||||||
|
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
var registrationData = new
|
var registrationData = new
|
||||||
@@ -153,14 +127,14 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I do not have an existing account")]
|
[Given("I do not have an existing account")]
|
||||||
public void GivenIDoNotHaveAnExistingAccount()
|
public void GivenIDoNotHaveAnExistingAccount()
|
||||||
{
|
{
|
||||||
scenario[TestUserKey] = ("Failing", "User");
|
Scenario[TestUserKey] = ("Failing", "User");
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a login request with a username and password")]
|
[When("I submit a login request with a username and password")]
|
||||||
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
|
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
(string username, string password) = scenario.TryGetValue(TestUserKey, out (string username, string password) user)
|
(string username, string password) = Scenario.TryGetValue(TestUserKey, out (string username, string password) user)
|
||||||
? user
|
? user
|
||||||
: ("test.user", "password");
|
: ("test.user", "password");
|
||||||
|
|
||||||
@@ -174,8 +148,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a login request with a missing username")]
|
[When("I submit a login request with a missing username")]
|
||||||
@@ -192,8 +166,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a login request with a missing password")]
|
[When("I submit a login request with a missing password")]
|
||||||
@@ -210,8 +184,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a login request with both username and password missing")]
|
[When("I submit a login request with both username and password missing")]
|
||||||
@@ -226,18 +200,18 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Then("the response JSON should have an access token")]
|
[Then("the response JSON should have an access token")]
|
||||||
public void ThenTheResponseJsonShouldHaveAnAccessToken()
|
public void ThenTheResponseJsonShouldHaveAnAccessToken()
|
||||||
{
|
{
|
||||||
scenario
|
Scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
|
||||||
JsonDocument doc = JsonDocument.Parse(responseBody!);
|
JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
JsonElement root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
@@ -270,8 +244,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a registration request with values:")]
|
[When("I submit a registration request with values:")]
|
||||||
@@ -321,8 +295,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a registration request using a GET request")]
|
[When("I submit a registration request using a GET request")]
|
||||||
@@ -337,8 +311,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I have registered a new account")]
|
[Given("I have registered a new account")]
|
||||||
@@ -364,13 +338,13 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||||
JsonElement root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
|
Scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
|
||||||
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I am logged in")]
|
[Given("I am logged in")]
|
||||||
@@ -396,12 +370,12 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem)
|
payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||||
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
|
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
|
||||||
)
|
)
|
||||||
scenario["accessToken"] = tokenElem.GetString();
|
Scenario["accessToken"] = tokenElem.GetString();
|
||||||
if (
|
if (
|
||||||
payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem)
|
payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem)
|
||||||
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
|
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
|
||||||
)
|
)
|
||||||
scenario["refreshToken"] = refreshElem.GetString();
|
Scenario["refreshToken"] = refreshElem.GetString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,15 +395,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I have a valid access token for my account")]
|
[Given("I have a valid access token for my account")]
|
||||||
public void GivenIHaveAValidAccessTokenForMyAccount()
|
public void GivenIHaveAValidAccessTokenForMyAccount()
|
||||||
{
|
{
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||||
? id
|
? id
|
||||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||||
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret);
|
string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret);
|
||||||
scenario["accessToken"] = GenerateJwtToken(
|
Scenario["accessToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
secret,
|
secret,
|
||||||
@@ -440,15 +414,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I have a valid confirmation token for my account")]
|
[Given("I have a valid confirmation token for my account")]
|
||||||
public void GivenIHaveAValidConfirmationTokenForMyAccount()
|
public void GivenIHaveAValidConfirmationTokenForMyAccount()
|
||||||
{
|
{
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||||
? id
|
? id
|
||||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||||
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
|
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
|
||||||
scenario["confirmationToken"] = GenerateJwtToken(
|
Scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
secret,
|
secret,
|
||||||
@@ -459,15 +433,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I have an expired confirmation token for my account")]
|
[Given("I have an expired confirmation token for my account")]
|
||||||
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
|
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
|
||||||
{
|
{
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||||
? id
|
? id
|
||||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||||
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
|
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
|
||||||
scenario["confirmationToken"] = GenerateJwtToken(
|
Scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
secret,
|
secret,
|
||||||
@@ -478,15 +452,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
[Given("I have a confirmation token signed with the wrong secret")]
|
[Given("I have a confirmation token signed with the wrong secret")]
|
||||||
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
|
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
|
||||||
{
|
{
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||||
? id
|
? id
|
||||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||||
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
|
||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
|
const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
|
||||||
scenario["confirmationToken"] = GenerateJwtToken(
|
Scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
wrongSecret,
|
wrongSecret,
|
||||||
@@ -498,7 +472,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
|
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "invalid-token";
|
: "invalid-token";
|
||||||
|
|
||||||
@@ -509,8 +483,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a request to a protected endpoint with an invalid access token")]
|
[When("I submit a request to a protected endpoint with an invalid access token")]
|
||||||
@@ -524,18 +498,18 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with the valid token")]
|
[When("I submit a confirmation request with the valid token")]
|
||||||
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
|
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-token";
|
: "valid-token";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -548,18 +522,18 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit the same confirmation request again")]
|
[When("I submit the same confirmation request again")]
|
||||||
public async Task WhenISubmitTheSameConfirmationRequestAgain()
|
public async Task WhenISubmitTheSameConfirmationRequestAgain()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-token";
|
: "valid-token";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -572,8 +546,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with a malformed token")]
|
[When("I submit a confirmation request with a malformed token")]
|
||||||
@@ -581,7 +555,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
const string token = "malformed-token-not-jwt";
|
const string token = "malformed-token-not-jwt";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -594,20 +568,20 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a refresh token request with a valid refresh token")]
|
[When("I submit a refresh token request with a valid refresh token")]
|
||||||
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
|
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
if (scenario.TryGetValue<string>("accessToken", out string? oldAccessToken))
|
if (Scenario.TryGetValue<string>("accessToken", out string? oldAccessToken))
|
||||||
scenario[PreviousAccessTokenKey] = oldAccessToken;
|
Scenario[PreviousAccessTokenKey] = oldAccessToken;
|
||||||
if (scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken))
|
if (Scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken))
|
||||||
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
|
Scenario[PreviousRefreshTokenKey] = oldRefreshToken;
|
||||||
|
|
||||||
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-refresh-token";
|
: "valid-refresh-token";
|
||||||
|
|
||||||
@@ -616,8 +590,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a refresh token request with an invalid refresh token")]
|
[When("I submit a refresh token request with an invalid refresh token")]
|
||||||
@@ -630,8 +604,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a refresh token request with the expired refresh token")]
|
[When("I submit a refresh token request with the expired refresh token")]
|
||||||
@@ -644,8 +618,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a refresh token request with a missing refresh token")]
|
[When("I submit a refresh token request with a missing refresh token")]
|
||||||
@@ -657,8 +631,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a refresh token request using a GET request")]
|
[When("I submit a refresh token request using a GET request")]
|
||||||
@@ -672,8 +646,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected Endpoint Steps
|
// Protected Endpoint Steps
|
||||||
@@ -685,22 +659,22 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I am logged in with an immediately-expiring access token")]
|
[Given("I am logged in with an immediately-expiring access token")]
|
||||||
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
|
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
|
||||||
{
|
{
|
||||||
// Simulate an expired access token for auth rejection behavior.
|
// Simulate an expired access token for auth rejection behavior.
|
||||||
scenario["accessToken"] = "expired-access-token";
|
Scenario["accessToken"] = "expired-access-token";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I have an access token signed with the wrong secret")]
|
[Given("I have an access token signed with the wrong secret")]
|
||||||
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
||||||
{
|
{
|
||||||
scenario["accessToken"] =
|
Scenario["accessToken"] =
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +682,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
|
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "expired-token";
|
: "expired-token";
|
||||||
|
|
||||||
@@ -719,15 +693,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a request to a protected endpoint with the tampered token")]
|
[When("I submit a request to a protected endpoint with the tampered token")]
|
||||||
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
|
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "tampered-token";
|
: "tampered-token";
|
||||||
|
|
||||||
@@ -738,8 +712,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When(
|
[When(
|
||||||
@@ -748,7 +722,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
|
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "refresh-token";
|
: "refresh-token";
|
||||||
|
|
||||||
@@ -759,24 +733,24 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Given("I have a valid confirmation token")]
|
[Given("I have a valid confirmation token")]
|
||||||
public void GivenIHaveAValidConfirmationToken()
|
public void GivenIHaveAValidConfirmationToken()
|
||||||
{
|
{
|
||||||
scenario["confirmationToken"] = "valid-confirmation-token";
|
Scenario["confirmationToken"] = "valid-confirmation-token";
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with the expired token")]
|
[When("I submit a confirmation request with the expired token")]
|
||||||
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
|
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "expired-confirmation-token";
|
: "expired-confirmation-token";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -789,18 +763,18 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with the tampered token")]
|
[When("I submit a confirmation request with the tampered token")]
|
||||||
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
|
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "tampered-confirmation-token";
|
: "tampered-confirmation-token";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -813,15 +787,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with a missing token")]
|
[When("I submit a confirmation request with a missing token")]
|
||||||
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
|
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -831,15 +805,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request using an invalid HTTP method")]
|
[When("I submit a confirmation request using an invalid HTTP method")]
|
||||||
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
|
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-confirmation-token";
|
: "valid-confirmation-token";
|
||||||
|
|
||||||
@@ -850,8 +824,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When(
|
[When(
|
||||||
@@ -860,7 +834,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
|
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "confirmation-token";
|
: "confirmation-token";
|
||||||
|
|
||||||
@@ -871,8 +845,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with an invalid token")]
|
[When("I submit a confirmation request with an invalid token")]
|
||||||
@@ -880,7 +854,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
const string token = "invalid-confirmation-token";
|
const string token = "invalid-confirmation-token";
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -893,15 +867,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a confirmation request with the valid token without an access token")]
|
[When("I submit a confirmation request with the valid token without an access token")]
|
||||||
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
|
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-token";
|
: "valid-token";
|
||||||
|
|
||||||
@@ -912,14 +886,14 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Then("the response JSON should have a new access token")]
|
[Then("the response JSON should have a new access token")]
|
||||||
public void ThenTheResponseJsonShouldHaveANewAccessToken()
|
public void ThenTheResponseJsonShouldHaveANewAccessToken()
|
||||||
{
|
{
|
||||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||||
@@ -927,14 +901,14 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
accessToken.Should().NotBeNullOrWhiteSpace();
|
accessToken.Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
if (scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
|
if (Scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
|
||||||
accessToken.Should().NotBe(previousAccessToken);
|
accessToken.Should().NotBe(previousAccessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Then("the response JSON should have a new refresh token")]
|
[Then("the response JSON should have a new refresh token")]
|
||||||
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
|
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
|
||||||
{
|
{
|
||||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||||
@@ -942,7 +916,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
refreshToken.Should().NotBeNullOrWhiteSpace();
|
refreshToken.Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
if (scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
|
if (Scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
|
||||||
refreshToken.Should().NotBe(previousRefreshToken);
|
refreshToken.Should().NotBe(previousRefreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,10 +924,10 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task GivenIHaveConfirmedMyAccount()
|
public async Task GivenIHaveConfirmedMyAccount()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
|
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: throw new InvalidOperationException("confirmation token not found");
|
: throw new InvalidOperationException("confirmation token not found");
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -972,10 +946,10 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
|
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||||
? id
|
? id
|
||||||
: throw new InvalidOperationException("registered user ID not found");
|
: throw new InvalidOperationException("registered user ID not found");
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -988,8 +962,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a resend confirmation request for a non-existent user")]
|
[When("I submit a resend confirmation request for a non-existent user")]
|
||||||
@@ -997,7 +971,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
Guid fakeUserId = Guid.NewGuid();
|
Guid fakeUserId = Guid.NewGuid();
|
||||||
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
|
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
|
||||||
? at
|
? at
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
@@ -1010,15 +984,15 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I submit a resend confirmation request without an access token")]
|
[When("I submit a resend confirmation request without an access token")]
|
||||||
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
|
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
|
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(
|
HttpRequestMessage requestMessage = new(
|
||||||
HttpMethod.Post,
|
HttpMethod.Post,
|
||||||
@@ -1027,7 +1001,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
scenario[ResponseKey] = response;
|
Scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
329
web/backend/API/API.Specs/Steps/BrewerySteps.cs
Normal file
329
web/backend/API/API.Specs/Steps/BrewerySteps.cs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Database.Connection;
|
||||||
|
using Features.Locations.Dtos;
|
||||||
|
using Features.Locations.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Reqnroll;
|
||||||
|
|
||||||
|
namespace API.Specs.Steps;
|
||||||
|
|
||||||
|
[Binding]
|
||||||
|
public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||||
|
{
|
||||||
|
private const string AccessTokenKey = "accessToken";
|
||||||
|
private const string CityIdKey = "cityId";
|
||||||
|
private const string BreweryPostIdKey = "breweryPostId";
|
||||||
|
private const string BreweryRowVersionKey = "breweryRowVersion";
|
||||||
|
|
||||||
|
private const string FixtureCountryName = "Fixture Country";
|
||||||
|
private const string FixtureCountryIsoCode = "FX";
|
||||||
|
private const string FixtureStateProvinceName = "Fixture State";
|
||||||
|
private const string FixtureStateProvinceIsoCode = "FX-TS";
|
||||||
|
private const string FixtureCityName = "Fixture City";
|
||||||
|
|
||||||
|
// GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
|
||||||
|
// city (see its remarks); scenarios run in parallel across feature classes, so provisioning
|
||||||
|
// the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
|
||||||
|
private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
|
||||||
|
|
||||||
|
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = new(method, url);
|
||||||
|
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
return requestMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendAsync(HttpRequestMessage requestMessage)
|
||||||
|
{
|
||||||
|
HttpClient client = GetClient();
|
||||||
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Scenario[ResponseKey] = response;
|
||||||
|
Scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("a city exists")]
|
||||||
|
public async Task GivenACityExists()
|
||||||
|
{
|
||||||
|
ISqlConnectionFactory connectionFactory = GetFactory()
|
||||||
|
.Services.GetRequiredService<ISqlConnectionFactory>();
|
||||||
|
LocationRepository repository = new(connectionFactory);
|
||||||
|
|
||||||
|
CityLocation fixtureCity = new(
|
||||||
|
FixtureCityName,
|
||||||
|
FixtureStateProvinceName,
|
||||||
|
FixtureStateProvinceIsoCode,
|
||||||
|
FixtureCountryName,
|
||||||
|
FixtureCountryIsoCode
|
||||||
|
);
|
||||||
|
|
||||||
|
await CityProvisioningLock.WaitAsync();
|
||||||
|
Guid cityId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CityProvisioningLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
Scenario[CityIdKey] = cityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateBreweryAsync(
|
||||||
|
string breweryName,
|
||||||
|
string description,
|
||||||
|
string addressLine1,
|
||||||
|
string postalCode,
|
||||||
|
Guid cityId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var createData = new
|
||||||
|
{
|
||||||
|
breweryName,
|
||||||
|
description,
|
||||||
|
location = new
|
||||||
|
{
|
||||||
|
cityId,
|
||||||
|
addressLine1,
|
||||||
|
addressLine2 = (string?)null,
|
||||||
|
postalCode,
|
||||||
|
coordinates = (object?)null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/brewery"
|
||||||
|
);
|
||||||
|
requestMessage.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(createData),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I create a brewery with values:")]
|
||||||
|
public async Task WhenICreateABreweryWithValues(Table table)
|
||||||
|
{
|
||||||
|
DataTableRow row = table.Rows[0];
|
||||||
|
Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id) ? id : Guid.NewGuid();
|
||||||
|
|
||||||
|
await CreateBreweryAsync(
|
||||||
|
row["BreweryName"] ?? "",
|
||||||
|
row["Description"] ?? "",
|
||||||
|
row["AddressLine1"] ?? "",
|
||||||
|
row["PostalCode"] ?? "",
|
||||||
|
cityId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I create a brewery with a non-existent city")]
|
||||||
|
public async Task WhenICreateABreweryWithANonExistentCity()
|
||||||
|
{
|
||||||
|
await CreateBreweryAsync(
|
||||||
|
"Ghost City Brewery",
|
||||||
|
"A brewery in a city that does not exist",
|
||||||
|
"1 Nowhere St",
|
||||||
|
"00000",
|
||||||
|
Guid.NewGuid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have created a brewery")]
|
||||||
|
public async Task GivenIHaveCreatedABrewery()
|
||||||
|
{
|
||||||
|
Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException("city ID not found in scenario");
|
||||||
|
|
||||||
|
await CreateBreweryAsync(
|
||||||
|
"Test Brewery",
|
||||||
|
"A test brewery",
|
||||||
|
"123 Test St",
|
||||||
|
"00000",
|
||||||
|
cityId
|
||||||
|
);
|
||||||
|
|
||||||
|
Scenario
|
||||||
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
((int)response!.StatusCode).Should().Be(201, "fixture brewery creation must succeed");
|
||||||
|
|
||||||
|
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
|
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||||
|
|
||||||
|
Scenario[BreweryPostIdKey] = payload.GetProperty("breweryPostId").GetGuid();
|
||||||
|
Scenario[BreweryRowVersionKey] = payload.GetProperty("rowVersion").GetString()
|
||||||
|
?? throw new InvalidOperationException("rowVersion missing from created brewery");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Guid GetCreatedBreweryPostId()
|
||||||
|
{
|
||||||
|
return Scenario.TryGetValue(BreweryPostIdKey, out Guid id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException("brewery post ID not found in scenario");
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I retrieve the brewery by ID")]
|
||||||
|
public async Task WhenIRetrieveTheBreweryById()
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"/api/brewery/{GetCreatedBreweryPostId()}"
|
||||||
|
);
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I retrieve a brewery by a non-existent ID")]
|
||||||
|
public async Task WhenIRetrieveABreweryByANonExistentId()
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"/api/brewery/{Guid.NewGuid()}"
|
||||||
|
);
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I update the brewery with values:")]
|
||||||
|
public async Task WhenIUpdateTheBreweryWithValues(Table table)
|
||||||
|
{
|
||||||
|
DataTableRow row = table.Rows[0];
|
||||||
|
Guid breweryPostId = GetCreatedBreweryPostId();
|
||||||
|
string rowVersion = Scenario.TryGetValue<string>(BreweryRowVersionKey, out string? rv)
|
||||||
|
? rv
|
||||||
|
: throw new InvalidOperationException("row version not found in scenario");
|
||||||
|
|
||||||
|
var updateData = new
|
||||||
|
{
|
||||||
|
breweryPostId,
|
||||||
|
rowVersion,
|
||||||
|
breweryName = row["BreweryName"] ?? "",
|
||||||
|
description = row["Description"] ?? "",
|
||||||
|
location = (object?)null,
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Put,
|
||||||
|
$"/api/brewery/{breweryPostId}"
|
||||||
|
);
|
||||||
|
requestMessage.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(updateData),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I update a non-existent brewery")]
|
||||||
|
public async Task WhenIUpdateANonExistentBrewery()
|
||||||
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
var updateData = new
|
||||||
|
{
|
||||||
|
breweryPostId,
|
||||||
|
rowVersion = Convert.ToBase64String([0x00, 0x00, 0x00, 0x00]),
|
||||||
|
breweryName = "Nonexistent",
|
||||||
|
description = "Nonexistent",
|
||||||
|
location = (object?)null,
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Put,
|
||||||
|
$"/api/brewery/{breweryPostId}"
|
||||||
|
);
|
||||||
|
requestMessage.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(updateData),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I delete the brewery")]
|
||||||
|
public async Task WhenIDeleteTheBrewery()
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"/api/brewery/{GetCreatedBreweryPostId()}"
|
||||||
|
);
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I delete a non-existent brewery")]
|
||||||
|
public async Task WhenIDeleteANonExistentBrewery()
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"/api/brewery/{Guid.NewGuid()}"
|
||||||
|
);
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I am logged in as a different user")]
|
||||||
|
public async Task GivenIAmLoggedInAsADifferentUser()
|
||||||
|
{
|
||||||
|
HttpClient client = GetClient();
|
||||||
|
string suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var registrationData = new
|
||||||
|
{
|
||||||
|
username = $"otheruser-{suffix}",
|
||||||
|
firstName = "Other",
|
||||||
|
lastName = "User",
|
||||||
|
email = $"otheruser-{suffix}@example.com",
|
||||||
|
dateOfBirth = "1990-01-01",
|
||||||
|
password = "Password1!",
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(registrationData),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||||
|
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||||
|
string accessToken =
|
||||||
|
(
|
||||||
|
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||||
|
? tokenElem.GetString()
|
||||||
|
: null
|
||||||
|
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
|
||||||
|
|
||||||
|
Scenario[AccessTokenKey] = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("retrieving the brewery by ID should now return HTTP status {int}")]
|
||||||
|
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"/api/brewery/{GetCreatedBreweryPostId()}"
|
||||||
|
);
|
||||||
|
await SendAsync(requestMessage);
|
||||||
|
|
||||||
|
Scenario
|
||||||
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ public sealed class BreweryPost
|
|||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
// FK References
|
// FK References
|
||||||
|
// Note: An empty GUID signifies that this object is incomplete
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
public string PostedBy { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
// Attributes
|
// Attributes
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
@@ -21,6 +21,7 @@ public sealed class BreweryPost
|
|||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
public List<BeerPost> BeerPosts { get; set; } = [];
|
public List<BeerPost> BeerPosts { get; set; } = [];
|
||||||
|
public string PostedBy { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Query-computed
|
// Query-computed
|
||||||
public DistanceInformation? Distance { get; set; }
|
public DistanceInformation? Distance { get; set; }
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class CreateBreweryHandlerTests
|
|||||||
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() => persisted);
|
||||||
|
|
||||||
DateTime before = DateTime.UtcNow;
|
DateTime before = DateTime.UtcNow;
|
||||||
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
|
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -53,8 +56,27 @@ public class CreateBreweryHandlerTests
|
|||||||
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
|
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
|
||||||
|
|
||||||
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
|
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
|
||||||
|
_repoMock.Verify(
|
||||||
|
r => r.GetByIdAsync(persisted.BreweryPostId, It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
|
||||||
result.BreweryName.Should().Be("MyBrew");
|
result.BreweryName.Should().Be("MyBrew");
|
||||||
result.Location.Should().NotBeNull();
|
result.Location.Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ThrowsInvalidOperationException_WhenBreweryNotFoundAfterCreation()
|
||||||
|
{
|
||||||
|
CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>())).Returns(Task.CompletedTask);
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((BreweryPost?)null);
|
||||||
|
|
||||||
|
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,66 @@
|
|||||||
|
using Domain.Exceptions;
|
||||||
using Features.Breweries.Commands.DeleteBrewery;
|
using Features.Breweries.Commands.DeleteBrewery;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Breweries.Tests.Commands;
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
public class DeleteBreweryHandlerTests
|
public class DeleteBreweryHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
private readonly DeleteBreweryHandler _handler;
|
||||||
public async Task Handle_DelegatesToRepository()
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
|
public DeleteBreweryHandlerTests()
|
||||||
{
|
{
|
||||||
Mock<IBreweryRepository> repoMock = new();
|
_handler = new DeleteBreweryHandler(_repoMock.Object);
|
||||||
DeleteBreweryHandler handler = new(repoMock.Object);
|
}
|
||||||
Guid id = Guid.NewGuid();
|
|
||||||
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
|
||||||
|
|
||||||
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
|
||||||
|
{
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(postedById);
|
||||||
|
}
|
||||||
|
|
||||||
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
[Fact]
|
||||||
|
public async Task Handle_DelegatesToRepository_WhenRequestingUserIsThePoster()
|
||||||
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
Guid requestingUserId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, requestingUserId);
|
||||||
|
_repoMock.Setup(r => r.DeleteAsync(breweryPostId)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _handler.Handle(
|
||||||
|
new DeleteBreweryCommand(breweryPostId, requestingUserId),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
_repoMock.Verify(r => r.DeleteAsync(breweryPostId), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
|
||||||
|
{
|
||||||
|
DeleteBreweryCommand command = new(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
|
||||||
|
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
|
_repoMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
|
||||||
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
|
||||||
|
|
||||||
|
DeleteBreweryCommand command = new(breweryPostId, Guid.NewGuid());
|
||||||
|
|
||||||
|
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>();
|
||||||
|
_repoMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>()), Times.Never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Features.Breweries.Commands.UpdateBrewery;
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -16,13 +17,24 @@ public class UpdateBreweryHandlerTests
|
|||||||
_handler = new UpdateBreweryHandler(_repoMock.Object);
|
_handler = new UpdateBreweryHandler(_repoMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
|
||||||
|
{
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(postedById);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||||
{
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
Guid requestingUserId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, requestingUserId);
|
||||||
|
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
breweryPostId,
|
||||||
|
requestingUserId,
|
||||||
[0x01, 0x02],
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
|
||||||
"Renamed",
|
"Renamed",
|
||||||
"New description",
|
"New description",
|
||||||
null
|
null
|
||||||
@@ -30,8 +42,8 @@ public class UpdateBreweryHandlerTests
|
|||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
|
||||||
.ReturnsAsync(() => persisted!);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
DateTime before = DateTime.UtcNow;
|
DateTime before = DateTime.UtcNow;
|
||||||
@@ -40,7 +52,6 @@ public class UpdateBreweryHandlerTests
|
|||||||
|
|
||||||
persisted.Should().NotBeNull();
|
persisted.Should().NotBeNull();
|
||||||
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||||
persisted.PostedById.Should().Be(command.PostedById);
|
|
||||||
persisted.BreweryName.Should().Be("Renamed");
|
persisted.BreweryName.Should().Be("Renamed");
|
||||||
persisted.Description.Should().Be("New description");
|
persisted.Description.Should().Be("New description");
|
||||||
persisted.RowVersion.Should().Equal(command.RowVersion);
|
persisted.RowVersion.Should().Equal(command.RowVersion);
|
||||||
@@ -51,10 +62,14 @@ public class UpdateBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
||||||
{
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
Guid requestingUserId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, requestingUserId);
|
||||||
|
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
breweryPostId,
|
||||||
|
requestingUserId,
|
||||||
[0x01, 0x02],
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
|
||||||
"Name",
|
"Name",
|
||||||
"Description",
|
"Description",
|
||||||
null
|
null
|
||||||
@@ -62,8 +77,8 @@ public class UpdateBreweryHandlerTests
|
|||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
|
||||||
.ReturnsAsync(() => persisted!);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -74,6 +89,10 @@ public class UpdateBreweryHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
||||||
{
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
Guid requestingUserId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, requestingUserId);
|
||||||
|
|
||||||
UpdateBreweryLocation locationCommand = new(
|
UpdateBreweryLocation locationCommand = new(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
@@ -83,9 +102,9 @@ public class UpdateBreweryHandlerTests
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
UpdateBreweryCommand command = new(
|
UpdateBreweryCommand command = new(
|
||||||
Guid.NewGuid(),
|
breweryPostId,
|
||||||
|
requestingUserId,
|
||||||
[0x01, 0x02],
|
[0x01, 0x02],
|
||||||
Guid.NewGuid(),
|
|
||||||
"Name",
|
"Name",
|
||||||
"Description",
|
"Description",
|
||||||
locationCommand
|
locationCommand
|
||||||
@@ -93,8 +112,8 @@ public class UpdateBreweryHandlerTests
|
|||||||
|
|
||||||
BreweryPost? persisted = null;
|
BreweryPost? persisted = null;
|
||||||
_repoMock
|
_repoMock
|
||||||
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<BreweryPost>(b => persisted = b)
|
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
|
||||||
.ReturnsAsync(() => persisted!);
|
.ReturnsAsync(() => persisted!);
|
||||||
|
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -109,4 +128,41 @@ public class UpdateBreweryHandlerTests
|
|||||||
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
||||||
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
|
||||||
|
{
|
||||||
|
UpdateBreweryCommand command = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
Guid.NewGuid(),
|
||||||
|
[0x01, 0x02],
|
||||||
|
"Name",
|
||||||
|
"Description",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<NotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
|
||||||
|
{
|
||||||
|
Guid breweryPostId = Guid.NewGuid();
|
||||||
|
SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
|
||||||
|
|
||||||
|
UpdateBreweryCommand command = new(
|
||||||
|
breweryPostId,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
[0x01, 0x02],
|
||||||
|
"Name",
|
||||||
|
"Description",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class GetAllBreweryLocationsHandlerTests
|
|||||||
{
|
{
|
||||||
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
|
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
|
||||||
new GetAllBreweryLocationsQuery(),
|
new GetAllBreweryLocationsQuery(),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,7 @@ public class GetAllBreweryLocationsHandlerTests
|
|||||||
];
|
];
|
||||||
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
|
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
|
||||||
|
|
||||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
|
||||||
new GetAllBreweryLocationsQuery(),
|
new GetAllBreweryLocationsQuery(),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
|
|||||||
)
|
)
|
||||||
.ReturnsAsync(Array.Empty<BreweryPost>());
|
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
|
||||||
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -57,7 +57,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
|
|||||||
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))
|
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))
|
||||||
.ReturnsAsync(breweries);
|
.ReturnsAsync(breweries);
|
||||||
|
|
||||||
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
|
||||||
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,3 +23,10 @@ public record CreateBreweryCommand(
|
|||||||
string Description,
|
string Description,
|
||||||
CreateBreweryLocation Location
|
CreateBreweryLocation Location
|
||||||
) : IRequest<BreweryDto>;
|
) : IRequest<BreweryDto>;
|
||||||
|
|
||||||
|
/// <summary>Request body for <c>POST /api/brewery</c>. <c>PostedById</c> comes from the caller's JWT.</summary>
|
||||||
|
public record CreateBreweryRequest(
|
||||||
|
string BreweryName,
|
||||||
|
string Description,
|
||||||
|
CreateBreweryLocation Location
|
||||||
|
);
|
||||||
@@ -37,6 +37,17 @@ public class CreateBreweryHandler(IBreweryRepository repository)
|
|||||||
};
|
};
|
||||||
|
|
||||||
await repository.CreateAsync(entity);
|
await repository.CreateAsync(entity);
|
||||||
return entity.ToDto();
|
|
||||||
|
// RowVersion is a DB-computed column, unset on the in-memory entity; re-fetch so callers
|
||||||
|
// get back the value needed for a subsequent optimistic-concurrency update. Uses
|
||||||
|
// CancellationToken.None since the write already committed: cancelling the caller's request
|
||||||
|
// at this point must not turn a successful creation into a thrown exception.
|
||||||
|
BreweryPost created =
|
||||||
|
await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Brewery '{entity.BreweryPostId}' was not found after a successful creation."
|
||||||
|
);
|
||||||
|
|
||||||
|
return created.ToDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
|
/// <summary>Deletes a brewery post. <see cref="BreweryPostId" /> is bound from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
|
||||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ public class DeleteBreweryHandler(IBreweryRepository repository)
|
|||||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||||
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
|
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
/// <exception cref="Domain.Exceptions.ForbiddenException">
|
||||||
|
/// Thrown when <paramref name="request" />'s <c>RequestingUserId</c> did not post the brewery.
|
||||||
|
/// </exception>
|
||||||
|
public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return repository.DeleteAsync(request.BreweryPostId);
|
await repository.EnsureCallerOwnsBreweryAsync(
|
||||||
|
request.BreweryPostId,
|
||||||
|
request.RequestingUserId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.DeleteAsync(request.BreweryPostId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,25 @@ public record UpdateBreweryLocation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
|
/// Updates an existing brewery post. A <c>null</c> <see cref="Location" /> clears the brewery's location.
|
||||||
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the
|
/// <see cref="RowVersion" /> must be the row-version last read for this brewery (e.g. from a prior
|
||||||
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a
|
/// <c>GET</c>); the update is rejected with a <c>409 Conflict</c> if the brewery was modified since then.
|
||||||
/// <c>409 Conflict</c> if the brewery was modified since then.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record UpdateBreweryCommand(
|
public record UpdateBreweryCommand(
|
||||||
Guid BreweryPostId,
|
Guid BreweryPostId,
|
||||||
|
Guid RequestingUserId,
|
||||||
byte[] RowVersion,
|
byte[] RowVersion,
|
||||||
Guid PostedById,
|
|
||||||
string BreweryName,
|
string BreweryName,
|
||||||
string Description,
|
string Description,
|
||||||
UpdateBreweryLocation? Location
|
UpdateBreweryLocation? Location
|
||||||
) : IRequest<BreweryDto>;
|
) : IRequest<BreweryDto>;
|
||||||
|
|
||||||
|
/// <summary>Request body for <c>PUT /api/brewery/{id}</c>.</summary>
|
||||||
|
public record UpdateBreweryRequest(
|
||||||
|
Guid BreweryPostId,
|
||||||
|
byte[] RowVersion,
|
||||||
|
string BreweryName,
|
||||||
|
string Description,
|
||||||
|
UpdateBreweryLocation? Location
|
||||||
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,18 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
await repository.EnsureCallerOwnsBreweryAsync(
|
||||||
|
request.BreweryPostId,
|
||||||
|
request.RequestingUserId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
BreweryPost entity = new()
|
BreweryPost entity = new()
|
||||||
{
|
{
|
||||||
BreweryPostId = request.BreweryPostId,
|
BreweryPostId = request.BreweryPostId,
|
||||||
PostedById = request.PostedById,
|
RowVersion = request.RowVersion,
|
||||||
BreweryName = request.BreweryName,
|
BreweryName = request.BreweryName,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
RowVersion = request.RowVersion,
|
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
Location = request.Location is null
|
Location = request.Location is null
|
||||||
? null
|
? null
|
||||||
@@ -48,7 +53,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
BreweryPost updated = await repository.UpdateAsync(entity);
|
BreweryPost updated = await repository.UpdateAsync(entity, cancellationToken);
|
||||||
return updated.ToDto();
|
return updated.ToDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("locations")]
|
[HttpGet("locations")]
|
||||||
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetAllLocations()
|
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetAllLocations()
|
||||||
{
|
{
|
||||||
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
|
||||||
new GetAllBreweryLocationsQuery()
|
new GetAllBreweryLocationsQuery()
|
||||||
);
|
);
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
new ResponseBody<IEnumerable<BreweryWithLocationDto>>
|
||||||
{
|
{
|
||||||
Message = "Brewery locations retrieved successfully.",
|
Message = "Brewery locations retrieved successfully.",
|
||||||
Payload = breweries,
|
Payload = breweries,
|
||||||
@@ -91,17 +91,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("locations/nearby")]
|
[HttpGet("locations/nearby")]
|
||||||
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange(
|
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetLocationsWithinRange(
|
||||||
[FromQuery] double latitude,
|
[FromQuery] double latitude,
|
||||||
[FromQuery] double longitude,
|
[FromQuery] double longitude,
|
||||||
[FromQuery] double rangeInMetres
|
[FromQuery] double rangeInMetres
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
|
||||||
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
|
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
|
||||||
);
|
);
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
new ResponseBody<IEnumerable<BreweryWithLocationDto>>
|
||||||
{
|
{
|
||||||
Message = "Brewery locations retrieved successfully.",
|
Message = "Brewery locations retrieved successfully.",
|
||||||
Payload = breweries,
|
Payload = breweries,
|
||||||
@@ -113,10 +113,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||||
[FromBody] CreateBreweryCommand command
|
[FromBody] CreateBreweryRequest request
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
BreweryDto brewery = await mediator.Send(command);
|
BreweryDto brewery = await mediator.Send(
|
||||||
|
new CreateBreweryCommand(
|
||||||
|
User.GetAuthenticatedUserId(),
|
||||||
|
request.BreweryName,
|
||||||
|
request.Description,
|
||||||
|
request.Location
|
||||||
|
)
|
||||||
|
);
|
||||||
return Created(
|
return Created(
|
||||||
$"/api/brewery/{brewery.BreweryPostId}",
|
$"/api/brewery/{brewery.BreweryPostId}",
|
||||||
new ResponseBody<BreweryDto>
|
new ResponseBody<BreweryDto>
|
||||||
@@ -128,27 +135,37 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Updates an existing brewery post.</summary>
|
/// <summary>Updates an existing brewery post.</summary>
|
||||||
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
|
/// <param name="id">Must match <paramref name="request" />'s <c>BreweryPostId</c>.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
|
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
|
||||||
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
|
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
|
||||||
/// <c>409 Conflict</c> if the brewery was modified since <c>command.RowVersion</c> was read.
|
/// <c>409 Conflict</c> if the brewery was modified since <c>request.RowVersion</c> was read.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
||||||
Guid id,
|
Guid id,
|
||||||
[FromBody] UpdateBreweryCommand command
|
[FromBody] UpdateBreweryRequest request
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (command.BreweryPostId != id)
|
if (request.BreweryPostId != id)
|
||||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||||
|
|
||||||
BreweryDto brewery = await mediator.Send(command);
|
BreweryDto breweryUpdated = await mediator.Send(
|
||||||
|
new UpdateBreweryCommand(
|
||||||
|
request.BreweryPostId,
|
||||||
|
User.GetAuthenticatedUserId(),
|
||||||
|
request.RowVersion,
|
||||||
|
request.BreweryName,
|
||||||
|
request.Description,
|
||||||
|
request.Location
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<BreweryDto>
|
new ResponseBody<BreweryDto>
|
||||||
{
|
{
|
||||||
Message = "Brewery updated successfully.",
|
Message = "Brewery updated successfully.",
|
||||||
Payload = brewery,
|
Payload = breweryUpdated,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,7 +175,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteBreweryCommand(id));
|
await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId()));
|
||||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ public record BreweryDto(
|
|||||||
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
|
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
|
||||||
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
|
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
|
||||||
/// </param>
|
/// </param>
|
||||||
public record SimplifiedBreweryDto(
|
public record BreweryWithLocationDto(
|
||||||
Guid BreweryPostId,
|
Guid BreweryPostId,
|
||||||
string BreweryName,
|
string BreweryName,
|
||||||
BreweryLocationDto? Location,
|
BreweryLocationDto? Location,
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ public static class BreweryDtoMapper
|
|||||||
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
|
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
|
||||||
/// location-based queries that only need identifying and location details.
|
/// location-based queries that only need identifying and location details.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery)
|
public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery)
|
||||||
{
|
{
|
||||||
return new SimplifiedBreweryDto(
|
return new BreweryWithLocationDto(
|
||||||
BreweryPostId: brewery.BreweryPostId,
|
BreweryPostId: brewery.BreweryPostId,
|
||||||
BreweryName: brewery.BreweryName,
|
BreweryName: brewery.BreweryName,
|
||||||
Location: brewery.Location is null
|
Location: brewery.Location is null
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
|||||||
|
|
||||||
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
|
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
|
||||||
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
|
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>>
|
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>>
|
||||||
{
|
{
|
||||||
/// <summary>Retrieves every brewery post that has a set location.</summary>
|
/// <summary>Retrieves every brewery post that has a set location.</summary>
|
||||||
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
|
||||||
GetAllBreweryLocationsQuery request,
|
GetAllBreweryLocationsQuery request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
|||||||
/// Retrieves all brewery posts that have a set location. Bound directly from
|
/// Retrieves all brewery posts that have a set location. Bound directly from
|
||||||
/// <c>GET /api/brewery/locations</c>.
|
/// <c>GET /api/brewery/locations</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class GetBreweryByIdHandler(IBreweryRepository repository)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
|
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken);
|
||||||
return brewery?.ToDto();
|
return brewery?.ToDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
|||||||
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
|
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
|
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<SimplifiedBreweryDto>>
|
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>>
|
||||||
{
|
{
|
||||||
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
|
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
|
||||||
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
|
||||||
GetBreweryLocationsWithinRangeQuery request,
|
GetBreweryLocationsWithinRangeQuery request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
|||||||
/// <param name="Longitude">The origin longitude, in decimal degrees.</param>
|
/// <param name="Longitude">The origin longitude, in decimal degrees.</param>
|
||||||
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
||||||
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
|
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
|
||||||
: IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
: IRequest<IEnumerable<BreweryWithLocationDto>>;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Repository;
|
||||||
|
|
||||||
|
/// <summary>Shared ownership check for commands that mutate a specific brewery post.</summary>
|
||||||
|
public static class BreweryOwnershipExtensions
|
||||||
|
{
|
||||||
|
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given ID.</exception>
|
||||||
|
/// <exception cref="Domain.Exceptions.ForbiddenException">Thrown when <paramref name="requestingUserId" /> did not post the brewery.</exception>
|
||||||
|
public static async Task EnsureCallerOwnsBreweryAsync(
|
||||||
|
this IBreweryRepository repository,
|
||||||
|
Guid breweryPostId,
|
||||||
|
Guid requestingUserId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Guid postedById =
|
||||||
|
await repository.GetPostedByIdAsync(breweryPostId, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"Brewery with ID {breweryPostId} not found.");
|
||||||
|
|
||||||
|
if (postedById != requestingUserId)
|
||||||
|
throw new ForbiddenException("You are not authorized to modify this brewery.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
IEnumerable<BreweryPost> results = await connection.QueryAsync<
|
IEnumerable<BreweryPost> results = await connection.QueryAsync<
|
||||||
@@ -39,43 +39,59 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
Country,
|
Country,
|
||||||
BreweryPost
|
BreweryPost
|
||||||
>(
|
>(
|
||||||
"""
|
new CommandDefinition(
|
||||||
SELECT
|
"""
|
||||||
bp.BreweryPostID,
|
SELECT
|
||||||
bp.PostedByID,
|
bp.BreweryPostID,
|
||||||
bp.BreweryName,
|
bp.PostedByID,
|
||||||
bp.Description,
|
bp.BreweryName,
|
||||||
bp.CreatedAt,
|
bp.Description,
|
||||||
bp.UpdatedAt,
|
bp.CreatedAt,
|
||||||
bp.RowVersion,
|
bp.UpdatedAt,
|
||||||
bpl.BreweryPostLocationID,
|
bp.RowVersion,
|
||||||
bpl.AddressLine1,
|
bpl.BreweryPostLocationID,
|
||||||
bpl.AddressLine2,
|
bpl.AddressLine1,
|
||||||
bpl.PostalCode,
|
bpl.AddressLine2,
|
||||||
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
bpl.PostalCode,
|
||||||
c.CityID,
|
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
|
||||||
c.CityName,
|
c.CityID,
|
||||||
sp.StateProvinceID,
|
c.CityName,
|
||||||
sp.StateProvinceName,
|
sp.StateProvinceID,
|
||||||
sp.ISO3166_2 AS Iso31662,
|
sp.StateProvinceName,
|
||||||
co.CountryID,
|
sp.ISO3166_2 AS Iso31662,
|
||||||
co.CountryName,
|
co.CountryID,
|
||||||
co.ISO3166_1 AS Iso31661
|
co.CountryName,
|
||||||
FROM dbo.BreweryPost bp
|
co.ISO3166_1 AS Iso31661
|
||||||
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
FROM dbo.BreweryPost bp
|
||||||
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
|
||||||
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
|
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
|
||||||
WHERE bp.BreweryPostID = @BreweryPostId
|
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
|
||||||
""",
|
WHERE bp.BreweryPostID = @BreweryPostId
|
||||||
|
""",
|
||||||
|
new { BreweryPostId = id },
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
),
|
||||||
MapBreweryRow,
|
MapBreweryRow,
|
||||||
new { BreweryPostId = id },
|
|
||||||
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
|
||||||
);
|
);
|
||||||
|
|
||||||
return results.SingleOrDefault();
|
return results.SingleOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using DbConnection connection = await CreateConnection();
|
||||||
|
return await connection.ExecuteScalarAsync<Guid?>(
|
||||||
|
new CommandDefinition(
|
||||||
|
"SELECT PostedByID FROM dbo.BreweryPost WHERE BreweryPostID = @BreweryPostId",
|
||||||
|
new { BreweryPostId = id },
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
@@ -221,10 +237,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -238,7 +254,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
BreweryPostID = @BreweryPostId
|
BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
is not null;
|
is not null;
|
||||||
@@ -253,7 +270,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
new CommandDefinition(
|
new CommandDefinition(
|
||||||
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
|
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
|
||||||
new { brewery.Location.CityId },
|
new { brewery.Location.CityId },
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
is not null;
|
is not null;
|
||||||
@@ -280,7 +298,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
brewery.Description,
|
brewery.Description,
|
||||||
brewery.RowVersion,
|
brewery.RowVersion,
|
||||||
},
|
},
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -300,7 +319,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
WHERE BreweryPostID = @BreweryPostId
|
WHERE BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,7 +335,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
WHERE BreweryPostID = @BreweryPostId
|
WHERE BreweryPostID = @BreweryPostId
|
||||||
""",
|
""",
|
||||||
new { brewery.BreweryPostId },
|
new { brewery.BreweryPostId },
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
is not null;
|
is not null;
|
||||||
@@ -346,7 +367,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
Longitude = brewery.Location.Coordinates?.Longitude,
|
Longitude = brewery.Location.Coordinates?.Longitude,
|
||||||
Srid = GeographySrid,
|
Srid = GeographySrid,
|
||||||
},
|
},
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
@@ -385,12 +407,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
Longitude = brewery.Location.Coordinates?.Longitude,
|
Longitude = brewery.Location.Coordinates?.Longitude,
|
||||||
Srid = GeographySrid,
|
Srid = GeographySrid,
|
||||||
},
|
},
|
||||||
transaction
|
transaction,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -398,7 +421,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await GetByIdAsync(brewery.BreweryPostId)
|
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
|
||||||
);
|
);
|
||||||
@@ -542,25 +565,27 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
||||||
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
|
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
|
||||||
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
|
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
|
||||||
/// brewery with no address on file), <paramref name="location" /> is a blank instance produced
|
/// brewery with no address on file), Dapper passes <see langword="null" /> for
|
||||||
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined
|
/// <paramref name="location" /> rather than a blank instance, since its identity column
|
||||||
/// in, its City/StateProvince/Country chain was too.
|
/// (<c>BreweryPostLocationID</c>) is <c>NULL</c>; the schema guarantees that whenever a location
|
||||||
|
/// row was joined in, its City/StateProvince/Country chain was too.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static BreweryPost MapBreweryRow(
|
private static BreweryPost MapBreweryRow(
|
||||||
BreweryPost post,
|
BreweryPost post,
|
||||||
BreweryPostLocation location,
|
BreweryPostLocation? location,
|
||||||
City city,
|
City? city,
|
||||||
StateProvince stateProvince,
|
StateProvince? stateProvince,
|
||||||
Country country
|
Country? country
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (location.BreweryPostLocationId == Guid.Empty)
|
if (location is null)
|
||||||
return post;
|
return post;
|
||||||
|
|
||||||
stateProvince.CountryId = country.CountryId;
|
// The schema guarantees that whenever a location row was joined in, its City/StateProvince/
|
||||||
|
// Country chain was too, so these are never null alongside a non-null location.
|
||||||
|
city!.StateProvinceId = stateProvince!.StateProvinceId;
|
||||||
|
stateProvince.CountryId = country!.CountryId;
|
||||||
stateProvince.Country = country;
|
stateProvince.Country = country;
|
||||||
|
|
||||||
city.StateProvinceId = stateProvince.StateProvinceId;
|
|
||||||
city.StateProvince = stateProvince;
|
city.StateProvince = stateProvince;
|
||||||
|
|
||||||
location.BreweryPostId = post.BreweryPostId;
|
location.BreweryPostId = post.BreweryPostId;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ public interface IBreweryRepository
|
|||||||
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post
|
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post
|
||||||
/// exists with the given ID, or if it has no associated location.
|
/// exists with the given ID, or if it has no associated location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the <c>PostedById</c> of the brewery post with the given ID, without joining its
|
||||||
|
/// location. Returns <c>null</c> if no brewery post exists with the given ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
|
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
|
||||||
@@ -50,7 +56,7 @@ public interface IBreweryRepository
|
|||||||
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was
|
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was
|
||||||
/// modified by another request since it was last read).
|
/// modified by another request since it was last read).
|
||||||
/// </exception>
|
/// </exception>
|
||||||
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
|
Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary>
|
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary>
|
||||||
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
|
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using Domain.Exceptions;
|
|
||||||
using Features.Auth.Commands.Account.DeleteAccount;
|
using Features.Auth.Commands.Account.DeleteAccount;
|
||||||
using Features.Auth.Commands.Account.UpdateEmail;
|
using Features.Auth.Commands.Account.UpdateEmail;
|
||||||
using Features.Auth.Commands.Account.UpdatePassword;
|
using Features.Auth.Commands.Account.UpdatePassword;
|
||||||
@@ -31,18 +29,6 @@ namespace Features.Auth.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
public class AuthController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
|
|
||||||
/// </summary>
|
|
||||||
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
|
|
||||||
private Guid GetAuthenticatedUserId()
|
|
||||||
{
|
|
||||||
string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
|
||||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
|
||||||
throw new UnauthorizedException("Access token is missing a valid user ID claim");
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Registers a new user account.</summary>
|
/// <summary>Registers a new user account.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
||||||
@@ -150,7 +136,7 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
UpdateUsernamePayload payload = await mediator.Send(
|
UpdateUsernamePayload payload = await mediator.Send(
|
||||||
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
|
new UpdateUsernameCommand(User.GetAuthenticatedUserId(), request.NewUsername)
|
||||||
);
|
);
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<UpdateUsernamePayload>
|
new ResponseBody<UpdateUsernamePayload>
|
||||||
@@ -172,7 +158,7 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
UpdateEmailPayload payload = await mediator.Send(
|
UpdateEmailPayload payload = await mediator.Send(
|
||||||
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
|
new UpdateEmailCommand(User.GetAuthenticatedUserId(), request.NewEmail)
|
||||||
);
|
);
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<UpdateEmailPayload>
|
new ResponseBody<UpdateEmailPayload>
|
||||||
@@ -195,7 +181,7 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
{
|
{
|
||||||
UpdatePasswordPayload payload = await mediator.Send(
|
UpdatePasswordPayload payload = await mediator.Send(
|
||||||
new UpdatePasswordCommand(
|
new UpdatePasswordCommand(
|
||||||
GetAuthenticatedUserId(),
|
User.GetAuthenticatedUserId(),
|
||||||
request.CurrentPassword,
|
request.CurrentPassword,
|
||||||
request.NewPassword
|
request.NewPassword
|
||||||
)
|
)
|
||||||
@@ -218,7 +204,7 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
{
|
{
|
||||||
UpdateProfilePayload payload = await mediator.Send(
|
UpdateProfilePayload payload = await mediator.Send(
|
||||||
new UpdateProfileCommand(
|
new UpdateProfileCommand(
|
||||||
GetAuthenticatedUserId(),
|
User.GetAuthenticatedUserId(),
|
||||||
request.FirstName,
|
request.FirstName,
|
||||||
request.LastName,
|
request.LastName,
|
||||||
request.DateOfBirth
|
request.DateOfBirth
|
||||||
@@ -241,7 +227,7 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
[HttpDelete("account")]
|
[HttpDelete("account")]
|
||||||
public async Task<ActionResult<ResponseBody>> DeleteAccount()
|
public async Task<ActionResult<ResponseBody>> DeleteAccount()
|
||||||
{
|
{
|
||||||
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
|
await mediator.Send(new DeleteAccountCommand(User.GetAuthenticatedUserId()));
|
||||||
return Ok(new ResponseBody { Message = "Account deleted successfully." });
|
return Ok(new ResponseBody { Message = "Account deleted successfully." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Extension methods for reading well-known claims off an authenticated <see cref="ClaimsPrincipal" />.</summary>
|
||||||
|
public static class ClaimsPrincipalExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
|
||||||
|
public static Guid GetAuthenticatedUserId(this ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
string? userIdClaim = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||||
|
throw new UnauthorizedException("Access token is missing a valid user ID claim");
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,12 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Shared.Contracts</RootNamespace>
|
<RootNamespace>Shared.Contracts</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -119,6 +119,27 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nuget-cache-dev:/root/.nuget/packages
|
- nuget-cache-dev:/root/.nuget/packages
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: frontend
|
||||||
|
container_name: dev-env-frontend
|
||||||
|
depends_on:
|
||||||
|
api.core:
|
||||||
|
condition: service_started
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: "development"
|
||||||
|
PORT: "3000"
|
||||||
|
API_BASE_URL: "http://api.core:8080"
|
||||||
|
SESSION_SECRET: "${SESSION_SECRET}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
env_file: ".env.dev"
|
env_file: ".env.dev"
|
||||||
image: chrislusf/seaweedfs
|
image: chrislusf/seaweedfs
|
||||||
|
|||||||
@@ -59,9 +59,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
BUILD_CONFIGURATION: Release
|
BUILD_CONFIGURATION: Release
|
||||||
APP_UID: 1000
|
APP_UID: 1000
|
||||||
ports:
|
# No published ports: only the frontend service is reachable from outside
|
||||||
- "8080:8080"
|
# the Docker network in production. api.core stays internal to prodnet.
|
||||||
- "8081:8081"
|
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "Production"
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||||
@@ -80,6 +79,27 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- nuget-cache-prod:/root/.nuget/packages
|
- nuget-cache-prod:/root/.nuget/packages
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: frontend
|
||||||
|
container_name: prod-env-frontend
|
||||||
|
depends_on:
|
||||||
|
api.core:
|
||||||
|
condition: service_started
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: "production"
|
||||||
|
PORT: "3000"
|
||||||
|
API_BASE_URL: "http://api.core:8080"
|
||||||
|
SESSION_SECRET: "${SESSION_SECRET}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sqlserverdata-prod:
|
sqlserverdata-prod:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- testnet
|
- testnet
|
||||||
|
|
||||||
|
frontend.tests:
|
||||||
|
image: frontend.tests
|
||||||
|
container_name: test-env-frontend-tests
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.tests
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sqlserverdata-test:
|
sqlserverdata-test:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
15
web/frontend/.dockerignore
Normal file
15
web/frontend/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/node_modules
|
||||||
|
**/build
|
||||||
|
**/.react-router
|
||||||
|
**/storybook-static
|
||||||
|
**/coverage
|
||||||
|
**/test-results
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
29
web/frontend/Dockerfile
Normal file
29
web/frontend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Stage 1: install every dependency (including dev deps needed to build)
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Stage 2: build the client and server bundles (react-router build)
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: production-only dependencies for the runtime image
|
||||||
|
FROM node:22-alpine AS prod-deps
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Stage 4: runtime
|
||||||
|
FROM node:22-alpine AS final
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY --from=prod-deps /src/node_modules ./node_modules
|
||||||
|
COPY --from=build /src/build ./build
|
||||||
|
COPY --from=build /src/server.js ./server.js
|
||||||
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
|
CMD ["node", "./server.js"]
|
||||||
10
web/frontend/Dockerfile.tests
Normal file
10
web/frontend/Dockerfile.tests
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Ships Playwright's own browsers preinstalled, matching the @playwright/test
|
||||||
|
# version pinned in package.json, so the Storybook Vitest and Playwright
|
||||||
|
# suites (both browser-driven) don't need a separate browser-install step.
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.62.1-jammy AS test
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p /app/test-results/frontend
|
||||||
|
ENTRYPOINT ["sh", "-c", "set -e; npx vitest run --project storybook --reporter=junit --outputFile=/app/test-results/frontend/vitest-storybook.xml; PLAYWRIGHT_JUNIT_OUTPUT_NAME=/app/test-results/frontend/playwright-storybook.xml npx playwright test -c playwright.storybook.config.ts --reporter=junit"]
|
||||||
129
web/frontend/package-lock.json
generated
129
web/frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@react-router/dev": "^8.3.0",
|
"@react-router/dev": "^8.3.0",
|
||||||
"@react-router/express": "^8.3.0",
|
"@react-router/express": "^8.3.0",
|
||||||
"@react-router/node": "^8.3.0",
|
"@react-router/node": "^8.3.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -3296,6 +3297,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
@@ -4618,7 +4685,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"negotiator": "^1.0.0"
|
"negotiator": "^1.0.0"
|
||||||
@@ -5004,7 +5070,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.0.0",
|
||||||
@@ -5029,7 +5094,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -5103,7 +5167,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -5326,7 +5389,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -5340,7 +5402,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -5356,7 +5417,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -5372,7 +5432,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
@@ -5601,7 +5660,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -5664,8 +5722,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.412",
|
"version": "1.5.412",
|
||||||
@@ -5688,7 +5745,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -5963,8 +6019,7 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
@@ -6314,7 +6369,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -6346,7 +6400,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -6447,7 +6500,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -6523,7 +6575,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -6547,7 +6598,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -6932,7 +6982,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"inherits": "~2.0.4",
|
"inherits": "~2.0.4",
|
||||||
@@ -6966,7 +7015,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
@@ -7029,8 +7077,7 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -7052,7 +7099,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
@@ -7349,8 +7395,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -8106,7 +8151,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||||
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
},
|
},
|
||||||
@@ -8120,7 +8164,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -8133,7 +8176,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -8143,7 +8185,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
@@ -8244,7 +8285,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
|
||||||
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
|
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^2.1.0"
|
"content-type": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -8261,7 +8301,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -8434,7 +8473,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ee-first": "1.1.1"
|
"ee-first": "1.1.1"
|
||||||
},
|
},
|
||||||
@@ -8447,7 +8485,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
@@ -8639,7 +8676,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -8703,7 +8739,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
@@ -8918,7 +8953,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
"ipaddr.js": "1.9.1"
|
"ipaddr.js": "1.9.1"
|
||||||
@@ -8942,7 +8976,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
"side-channel": "^1.1.1"
|
"side-channel": "^1.1.1"
|
||||||
@@ -8959,7 +8992,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
},
|
},
|
||||||
@@ -8973,7 +9005,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
@@ -9342,7 +9373,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"depd": "^2.0.0",
|
"depd": "^2.0.0",
|
||||||
@@ -9426,8 +9456,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
@@ -9452,7 +9481,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
@@ -9479,7 +9507,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
@@ -9547,8 +9574,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -9698,7 +9724,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -10068,7 +10093,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
@@ -10145,7 +10169,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.0.0",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
@@ -10164,7 +10187,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -10330,7 +10352,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -10419,7 +10440,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
@@ -10773,8 +10793,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"build": "react-router build",
|
"build": "react-router build",
|
||||||
"start": "NODE_ENV=production node ./build/server/index.js",
|
"start": "NODE_ENV=production node ./server.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@react-router/dev": "^8.3.0",
|
"@react-router/dev": "^8.3.0",
|
||||||
"@react-router/express": "^8.3.0",
|
"@react-router/express": "^8.3.0",
|
||||||
"@react-router/node": "^8.3.0",
|
"@react-router/node": "^8.3.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|||||||
18
web/frontend/server.js
Normal file
18
web/frontend/server.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createRequestHandler } from '@react-router/express';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
|
||||||
|
app.use(express.static('build/client', { maxAge: '1h' }));
|
||||||
|
|
||||||
|
const requestHandler = createRequestHandler({
|
||||||
|
build: () => import('./build/server/index.js'),
|
||||||
|
});
|
||||||
|
app.all('/', requestHandler);
|
||||||
|
app.all('/*splat', requestHandler);
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server listening on port ${port}`);
|
||||||
|
});
|
||||||
@@ -61,6 +61,7 @@ DB_CONNECTION_STRING_VAL="Server=${DB_SERVER_VAL};Database=${DB_NAME_VAL};User I
|
|||||||
ACCESS_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
ACCESS_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
|
SESSION_SECRET_VAL=$(gen_jwt_secret)
|
||||||
SMTP_USERNAME_VAL="smtp_$(gen_username)"
|
SMTP_USERNAME_VAL="smtp_$(gen_username)"
|
||||||
SMTP_PASSWORD_VAL=$(gen_password)
|
SMTP_PASSWORD_VAL=$(gen_password)
|
||||||
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
|
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
|
||||||
@@ -75,6 +76,7 @@ sed \
|
|||||||
-e "s|^ACCESS_TOKEN_SECRET=.*|ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET_VAL}|" \
|
-e "s|^ACCESS_TOKEN_SECRET=.*|ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET_VAL}|" \
|
||||||
-e "s|^REFRESH_TOKEN_SECRET=.*|REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET_VAL}|" \
|
-e "s|^REFRESH_TOKEN_SECRET=.*|REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET_VAL}|" \
|
||||||
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
|
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
|
||||||
|
-e "s|^SESSION_SECRET=.*|SESSION_SECRET=${SESSION_SECRET_VAL}|" \
|
||||||
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
|
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
|
||||||
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
|
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
|
||||||
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \
|
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \
|
||||||
|
|||||||
Reference in New Issue
Block a user