A REST API for tennis player statistics with a Blazor WebAssembly frontend, built as a technical test for L'Atelier.
| Layer | Technology |
|---|---|
| API | ASP.NET Core 10, Vertical Slice, MediatR |
| Database | PostgreSQL 16, EF Core 10, Npgsql |
| Frontend | Blazor WebAssembly (hosted), Radzen UI |
| Auth | JWT Bearer, role-based (Admin / Reader / Writer) |
| Tests | xUnit, bUnit, ReqnRoll, Testcontainers |
| Observability | Serilog, Swagger, health check |
| Deployment | Docker Compose (local), Render (cloud) |
# Clone
git clone https://github.com/vinvir/latelier.git
cd latelier
# Start API + database + Blazor frontend
docker compose up --build| URL | Description |
|---|---|
http://localhost:8080 |
Blazor UI |
http://localhost:8080/swagger |
Swagger / OpenAPI |
http://localhost:8080/health |
Health check |
docker compose down -v && docker compose up --buildStart PostgreSQL separately (or use docker compose up db -d), then:
# Apply migrations and run the API
cd src/Tennis.Api
dotnet runThe API reads appsettings.Development.json for the connection string and seed credentials. Create that file locally (it is gitignored):
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=tennis;Username=tennis;Password=tennis"
},
"Jwt": {
"Key": "dev-secret-key-must-be-at-least-32-chars!!",
"Issuer": "tennis-api",
"Audience": "tennis-client",
"ExpiryMinutes": 60
},
"Seed": {
"Users": [
{ "Username": "vviroleau", "Password": "isagoodchoice", "Role": "Admin" },
{ "Username": "readuser", "Password": "letmeread", "Role": "Reader" },
{ "Username": "writeuser", "Password": "writeisright", "Role": "Writer" }
]
}
}| Username | Password | Role | Permissions |
|---|---|---|---|
vviroleau |
isagoodchoice |
Admin | All endpoints |
readuser |
letmeread |
Reader | GET only |
writeuser |
writeisright |
Writer | GET + POST /players |
All endpoints except /api/auth/login and /health require a Bearer token.
POST /api/auth/login Authenticate, receive JWT
GET /api/players List players (sorted, see below)
GET /api/players/{id} Get player by ID
POST /api/players Add a new player [Writer, Admin]
GET /api/stats Aggregated statistics
POST /api/admin/reset Reset players to the original seed [Admin]
GET /health Health check
GET /api/players accepts optional query parameters:
| Parameter | Values | Default |
|---|---|---|
sortBy |
Rank, Points, Age, Name |
Rank |
order |
Asc, Desc |
Asc |
Example: GET /api/players?sortBy=Points&order=Desc
# 1. Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"vviroleau","password":"isagoodchoice"}'
# 2. Use the returned token
curl http://localhost:8080/api/players \
-H "Authorization: Bearer <token>"# All tests
dotnet test Tennis.sln
# Unit tests only (no Docker required)
dotnet test tests/Tennis.Api.Tests/Tennis.Api.Tests.csproj
dotnet test tests/Tennis.Client.Tests/Tennis.Client.Tests.csproj
# Integration tests (requires Docker for Testcontainers)
dotnet test tests/Tennis.IntegrationTests/Tennis.IntegrationTests.csproj| Project | Type | Count |
|---|---|---|
tests/Tennis.Api.Tests |
xUnit unit tests — handlers, mappers, stats calculators, validators | 29 |
tests/Tennis.Client.Tests |
bUnit Blazor component tests | 5 |
tests/Tennis.IntegrationTests |
ReqnRoll BDD + Testcontainers (real PostgreSQL) | 9 |
Rider's dotCover reports 0% for Blazor WebAssembly components. Use the coverlet collector (via the provided runsettings) instead.
./coverage.sh # runs all tests with coverage and renders coveragereport/index.htmlOr manually:
dotnet test Tennis.sln --settings coverlet.runsettings --results-directory coverage-tmp
dotnet tool restore
dotnet reportgenerator -reports:"coverage-tmp/**/coverage.cobertura.xml" -targetdir:coveragereportlatelier/
├── src/
│ ├── Tennis.Api/ ASP.NET Core — vertical slices
│ │ ├── Domain/ Exceptions (no anemic entities — slices read through EF models)
│ │ ├── Features/ One folder per use case (Query/Command + Handler [+ Validator])
│ │ ├── Infrastructure/ EF Core, mappers, seeder, security, caching,
│ │ │ MediatR behaviors (logging / validation / cache invalidation),
│ │ │ error handling (IExceptionHandler + ProblemDetails)
│ │ └── Extensions/ Composition-root helpers (auth, OpenAPI, migrate & seed)
│ ├── Tennis.Client/ Blazor WebAssembly (Radzen) — hosted by Tennis.Api
│ └── Tennis.Shared/ DTOs shared between API and client
└── tests/
├── Tennis.Api.Tests/ Unit tests
├── Tennis.Client.Tests/ Component tests
└── Tennis.IntegrationTests/ BDD integration tests
Cross-cutting concerns (request logging with secret redaction, validation, and
output-cache invalidation) are MediatR pipeline behaviors, so feature handlers
stay focused on their use case. Key decisions are documented in docs/adr/.
Deployed on Render: https://latelier-1btt.onrender.com
| URL | |
|---|---|
/ |
Blazor UI |
/health |
Health check |
Sign in with any of the test users above — e.g. readuser / letmeread.
Note: The free tier sleeps after ~15 minutes of inactivity, so the first request may take ~30 seconds to wake.