Compare commits

69 Commits

Author SHA1 Message Date
5727707cce Add simply example for retrieving data from the api
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 20:13:06 +02:00
f00e3cdb6a Add npm app to Aspire resources 2025-06-13 20:12:43 +02:00
20ba638b64 Fix server info endpoint not being accessibly without authentication 2025-06-13 20:12:26 +02:00
d80a53761d Generate Angular app 2025-06-13 19:48:07 +02:00
e29c5b2458 Return DateTimeOffset
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 19:39:46 +02:00
7aa8599535 Update request localization 2025-06-13 19:39:37 +02:00
16bc250789 Fix project name spelling
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-12 19:20:22 +02:00
9847b6e6f7 Change assembly name constant spelling
Some checks are pending
continuous-integration/drone/push Build is running
2025-06-12 19:18:17 +02:00
ada0e2f665 Use custom activity source 2025-06-12 19:12:38 +02:00
b28bd2826b Keep db container after apps shutdown 2025-06-12 19:12:26 +02:00
a1999bfe41 Rename WebApi project to Vegasco.Server.Api
All checks were successful
continuous-integration/drone/push Build is passing
And update all references including comments etc.
2025-06-12 18:23:09 +02:00
9d71c86474 Fix broken swagger route 2025-06-12 17:58:50 +02:00
d91b837e44 Update packages, use explicit type, use Microsoft OpenApi package 2025-06-12 17:43:22 +02:00
b3ca1ba703 Remove experimental own test step
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 17:42:12 +01:00
108960d074 Add debug
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:24:06 +01:00
05686c4cdd Add own test step
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:20:10 +01:00
cb440e7c6d Use latest postgres version in integration tests
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 17:11:09 +01:00
4ea0978cf6 Fix integration test database connection string
Some checks are pending
continuous-integration/drone/push Build is running
2024-12-28 17:10:36 +01:00
ff2707a0e8 Add Aspire documentation to README
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 17:08:01 +01:00
6d23494fd3 Add Aspire orchestration
Some checks failed
continuous-integration/drone/push Build is failing
Therefore remove previous OpenTelemetry configuration and use the one
provided in service defaults
2024-12-28 17:01:18 +01:00
bbac953660 Update legacy sln file
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 15:59:31 +01:00
854be19fd5 Remove system tests
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 15:55:34 +01:00
cf1a086e31 Recover sln file and revert pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-28 14:32:34 +01:00
918477fb3a Use prerelease docker images which work with slnx file
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 14:29:23 +01:00
857863a4d8 Replace sln file with slnx
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-28 14:23:27 +01:00
7a2c50cb9a Update vulnerable packages
Some checks are pending
continuous-integration/drone/push Build is running
2024-12-28 14:22:21 +01:00
5d0a49632a Update .NET version in pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-27 19:25:19 +01:00
0e065b58b7 Update packages
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-27 19:22:13 +01:00
22f47f4461 Upgrade to .NET 9 and update nuget packages
Some checks failed
continuous-integration/drone/push Build is failing
2024-12-01 19:26:54 +01:00
d6c75654b0 Use wrapper class for get all api endpoints
All checks were successful
continuous-integration/drone/push Build is passing
To enable e.g. pagination in the future
2024-08-25 13:39:00 +02:00
136dd2311d Prevent local test db to disappear on docker restart 2024-08-25 13:14:54 +02:00
351a1a4635 Fix nested classes in open api document 2024-08-25 13:14:32 +02:00
4db35dbdb5 Remove unnecessary migrations in integration tests
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 14:26:38 +02:00
d0704aea12 Fix permissions in Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 14:24:18 +02:00
92e91de9c2 Include healthcheck in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2024-08-24 13:50:03 +02:00
de7e9a7131 Tweak log levels for non-dev environments
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 13:44:23 +02:00
6b422545d9 Remove migrations sql script 2024-08-24 13:43:58 +02:00
4a1f1a5a67 Apply migrations on startup 2024-08-24 13:43:43 +02:00
d3d3675e3d Fix onfiguration documentation in README
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 13:20:29 +02:00
88090878ee Include docker job in notification
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 12:55:25 +02:00
f410f69e9d Only report failures
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-24 12:46:33 +02:00
036f4d1dfc Fix Dockerfile for nbgv 2024-08-24 12:46:27 +02:00
ea689bb7a1 Only build docker image on main
Some checks failed
continuous-integration/drone/push Build is failing
For now
2024-08-24 12:39:30 +02:00
4855336c33 Add docker build and push to pipeline 2024-08-24 12:36:15 +02:00
ad9391093d Update nbgv version config 2024-08-24 12:36:15 +02:00
89afc435fc Remove broken dependencies
All checks were successful
continuous-integration/drone Build is passing
2024-08-23 19:02:01 +02:00
2d79b5a0bf Revert "Replace with gitea actions"
This reverts commit 70f47b0dd1.

# Conflicts:
#	.gitea/workflows/build.yaml
#	vegasco-server.sln
2024-08-23 18:56:01 +02:00
dcb82414b9 Add nbgv and add server info endpoint
Some checks failed
Build Vegasco Server / build (push) Failing after 59s
2024-08-23 18:55:05 +02:00
d19d68f5a2 Setup docker in pipeline
Some checks failed
Build Vegasco Server / build (push) Failing after 2m24s
2024-08-23 18:35:51 +02:00
1c88d2b2c6 Exclude empty test project
Some checks failed
Build Vegasco Server / build (push) Failing after 2m22s
2024-08-23 18:32:01 +02:00
155ed22fb0 Explicitly specify package 2024-08-23 18:31:54 +02:00
4a46c46222 Fix setting up pipeline
Some checks failed
Build Vegasco Server / build (push) Failing after 2m34s
2024-08-23 18:20:35 +02:00
f4846bc66a Update on
Some checks failed
Build Vegasco Server / build (push) Failing after 1m22s
2024-08-23 18:17:27 +02:00
70f47b0dd1 Replace with gitea actions 2024-08-23 18:15:28 +02:00
e20f713fdb Add initial cicd yaml 2024-08-23 18:05:34 +02:00
2463c11be3 Add consumption logic and endpoints 2024-08-23 18:02:18 +02:00
d47e4c1971 Add consumption entity and use strongly typed ids 2024-08-17 18:00:23 +02:00
4bfc57ef9f Remove unused launch configs 2024-08-17 16:38:41 +02:00
5c532a6bb5 Experiment with setting up system test docker services 2024-08-17 16:38:41 +02:00
4f287d85dd Use full keycloak realm export from cli instead of partial export from web interface 2024-08-17 16:38:41 +02:00
7f734aa2a2 Initial setup work for system tests 2024-08-17 16:38:41 +02:00
81b5c89a25 Fix Dockerfile 2024-08-17 16:38:40 +02:00
1d6ecfee6e Require oidc metadata url instead of individual values 2024-08-17 16:38:40 +02:00
4be9fd2043 Update convenience scripts 2024-08-17 16:38:40 +02:00
19b105b0e8 Add integration tests 2024-08-17 16:38:40 +02:00
ff2da66a22 Add unit tests 2024-08-17 16:38:40 +02:00
877e7989cd Implement all car endpoints 2024-08-17 16:38:40 +02:00
a708ed25e7 Initial endpoint configuration with authentication 2024-08-17 16:38:40 +02:00
e579d76560 Create api project 2024-08-17 16:38:40 +02:00
105 changed files with 10243 additions and 1 deletions

30
.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
#**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

87
.drone.yml Normal file
View File

@@ -0,0 +1,87 @@
kind: pipeline
type: docker
name: Build and test
trigger:
event:
include:
- push
- pull_request
- custom
steps:
- name: compile
image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
environment:
CI_WORKSPACE: "/drone/src"
commands:
- dotnet build
volumes:
- name: dockersock
path: /var/run
- name: test
image: quay.io/testcontainers/dind-drone-plugin
environment:
CI_WORKSPACE: "/drone/src"
settings:
build_image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
cmd:
- dotnet test --no-build
volumes:
- name: dockersock
path: /var/run
depends_on:
- compile
- name: docker build and push
image: docker:24.0.7
commands:
- docker build . -t $docker_registry$docker_repo:$DRONE_BRANCH
- echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry
- docker push $docker_registry$docker_repo:$DRONE_BRANCH
environment:
docker_username:
from_secret: docker_username
docker_password:
from_secret: docker_password
docker_repo:
from_secret: docker_repo
docker_registry:
from_secret: docker_registry
volumes:
- name: dockersock
path: /var/run
when:
branch:
- main
depends_on:
- compile
- test
- name: Telegram notification
image: appleboy/drone-telegram
settings:
token:
from_secret: telegram_token
to:
from_secret: telegram_user_id
when:
status:
- failure
depends_on:
- compile
- test
- docker build and push
services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run
volumes:
- name: dockersock
temp: { }

484
.gitignore vendored Normal file
View File

@@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

2
Create-Migration.ps1 Normal file
View File

@@ -0,0 +1,2 @@
dotnet ef migrations add $args[0] --project .\src\WebApi\WebApi.csproj --output-dir Persistence/Migrations
dotnet ef migrations script --idempotent --project .\src\WebApi\WebApi.csproj --output migrations/migration.sql

9
Directory.Build.props Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
<PrivateAssets>all</PrivateAssets>
<Version>3.6.141</Version>
</PackageReference>
</ItemGroup>
</Project>

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
RUN apt-get update && apt-get install -y curl
USER app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/Vegasco.Server.Api/Vegasco.Server.Api.csproj", "src/Vegasco.Server.Api/"]
RUN dotnet restore "./src/Vegasco.Server.Api/Vegasco.Server.Api.csproj"
COPY . .
WORKDIR "/src/src/Vegasco.Server.Api"
RUN dotnet build "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
HEALTHCHECK --interval=20s --timeout=1s --start-period=10s --retries=3 CMD curl --fail http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Vegasco.Server.Api.dll"]

View File

@@ -1 +1,67 @@
# vegasco-server # Vegasco Server
Backend for the vegasco (**VE**hicle **GAS** **CO**nsumption) application.
## Getting Started
### Configuration
| Configuration | Description | Default | Required |
|--------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
| JWT:MetadataUrl | The oidc meta data url | - | true |
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
The application uses the prefix `Vegasco_` for environment variable names. The prefix is removed when the application reads the environment variables and duplicate entries are overwritten by the environment variables.
Example:
- `foo=bar1`
- `Vegasco_foo=bar2`
Results in:
- `foo=bar2`
- `Vegasco_foo=bar2`
Configuration hierarchy in environment variables is usually denoted using a colon (`:`). But because on some systems the colon character is a reserved character, you can use a double underscore (`__`) as an alternative. The application will replace the double underscore with a colon when reading the environment variables.
Example:
The environment variable `foo__bar=value` (as well as `Vegasco_foo__bar=value`) will be converted to `foo:bar=value` in the application.
### Configuration examples
As environment variables:
```env
Vegasco_JWT__Authority=https://example.authority.com
Vegasco_JWT__Audience=example-audience
Vegasco_JWT__Issuer=https://example.authority.com/realms/example-realm/
Vegasco_JWT__NameClaimType=preferred_username
```
As appsettings.json (or a environment specific appsettings.*.json):
**Note: the `Vegasco_` prefix is only for environment variables**
```json
{
"JWT": {
"Authority": "https://example.authority.com/realms/example-realm",
"Audience": "example-audience",
"Issuer": "https://example.authority.com/realms/example-realm/",
"NameClaimType": "preferred_username"
}
}
```
### Running the application
The solution uses Aspire to orchestrate the application. Specifically, it introduces sensible service defaults, including but not limited to OpenTelemetry,
creates a Postgres database as a docker container, and starts the Api with the correct configuration to communicate with the database.
Ensure you have an identity provider set up, for example Keycloak, and configured the relevant options described above.
Then, to run the application, ensure you have Docker running, then run the `Vegasco.Server.AppHost` launch profile.

1
Run-PostgresDb.ps1 Normal file
View File

@@ -0,0 +1 @@
docker run -d -p 5432:5432 --restart always --name vegasco-test-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres postgres:16.3-alpine

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
src/Vegasco-Web/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
src/Vegasco-Web/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
src/Vegasco-Web/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
src/Vegasco-Web/README.md Normal file
View File

@@ -0,0 +1,59 @@
# VegascoWeb
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,101 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"Vegasco-Web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.config.js"
},
"configurations": {
"production": {
"buildTarget": "Vegasco-Web:build:production"
},
"development": {
"buildTarget": "Vegasco-Web:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
{
"name": "vegasco-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^20.0.3",
"@angular/compiler": "^20.0.3",
"@angular/core": "^20.0.3",
"@angular/forms": "^20.0.3",
"@angular/platform-browser": "^20.0.3",
"@angular/router": "^20.0.3",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular/build": "^20.0.2",
"@angular/cli": "^20.0.2",
"@angular/compiler-cli": "^20.0.3",
"@types/jasmine": "~5.1.8",
"jasmine-core": "~5.7.1",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.8.3"
}
}

5108
src/Vegasco-Web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
module.exports = {
"/api": {
target:
process.env["services__Vegasco-Server-Api__https__0"] ||
process.env["services__Vegasco-Server-Api__http__0"],
secure: process.env["NODE_ENV"] !== "development",
pathRewrite: {
"^/api": "",
},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import {provideHttpClient} from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
]
};

View File

@@ -0,0 +1,366 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 3),
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
td, th {
padding: .5rem;
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8"/>
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5"/>
<stop offset="1" stop-color="#FF41F8" stop-opacity="0"/>
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B"/>
<stop offset="0" stop-color="#F0070C"/>
<stop offset=".526" stop-color="#CC26D5"/>
<stop offset="1" stop-color="#7702FF"/>
</linearGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h982v239H0z"/>
</clipPath>
</defs>
</svg>
<h1>Hello, {{ title }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
<div>
@if (serverInfo$ | async; as serverInfo) {
<table>
<thead>
<tr>
<th>Version</th>
<th>Commit ID</th>
<th>Commit Date</th>
<th>Environment</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ serverInfo.fullVersion }}</td>
<td>{{ serverInfo.commitId }}</td>
<td>{{ serverInfo.commitDate | date:"dd.MM.yyyy HH:mm:ss" }}</td>
<td>{{ serverInfo.environment }}</td>
</tr>
</tbody>
</table>
}
</div>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{title: 'Explore the Docs', link: 'https://angular.dev'},
{title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials'},
{title: 'CLI Docs', link: 'https://angular.dev/tools/cli'},
{title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service'},
{title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools'},
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet/>

View File

@@ -0,0 +1,3 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];

View File

View File

@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Vegasco-Web');
});
});

View File

@@ -0,0 +1,33 @@
import {Component, inject} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {HttpClient} from '@angular/common/http';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {AsyncPipe, DatePipe} from '@angular/common';
import {tap} from 'rxjs';
@Component({
selector: 'app-root',
imports: [AsyncPipe, DatePipe, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected title = 'Vegasco-Web';
private readonly http = inject(HttpClient);
protected readonly serverInfo$;
constructor() {
this.serverInfo$ = this.http.get<ServerInfo>('/api/v1/info/server')
.pipe(takeUntilDestroyed());
}
}
interface ServerInfo {
fullVersion: string;
commitId: string;
commitDate: string;
environment: string;
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VegascoWeb</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,34 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -0,0 +1,3 @@
using StronglyTypedIds;
[assembly: StronglyTypedIdDefaults(Template.Guid, "guid-efcore")]

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Vegasco.Server.Api.Authentication;
public class JwtOptions
{
public const string SectionName = "JWT";
public string ValidAudience { get; set; } = "";
public string MetadataUrl { get; set; } = "";
public string? NameClaimType { get; set; }
public bool AllowHttpMetadataUrl { get; set; }
}
public class JwtOptionsValidator : AbstractValidator<JwtOptions>
{
public JwtOptionsValidator()
{
RuleFor(x => x.ValidAudience)
.NotEmpty();
RuleFor(x => x.MetadataUrl)
.NotEmpty();
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Options;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
namespace Vegasco.Server.Api.Authentication;
public sealed class UserAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<JwtOptions> _jwtOptions;
/// <summary>
/// Stores the username upon first retrieval
/// </summary>
private string? _cachedUsername;
/// <summary>
/// Stores the id upon first retrieval
/// </summary>
private string? _cachedId;
public UserAccessor(IHttpContextAccessor httpContextAccessor, IOptions<JwtOptions> jwtOptions)
{
_httpContextAccessor = httpContextAccessor;
_jwtOptions = jwtOptions;
}
public string GetUsername()
{
if (string.IsNullOrEmpty(_cachedUsername))
{
_cachedUsername = GetClaimValue(_jwtOptions.Value.NameClaimType ?? ClaimTypes.Name);
}
return _cachedUsername;
}
public string GetUserId()
{
if (string.IsNullOrEmpty(_cachedId))
{
_cachedId = GetClaimValue(ClaimTypes.NameIdentifier);
}
return _cachedId;
}
private string GetClaimValue(string claimType)
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
{
ThrowForMissingHttpContext();
}
string? claimValue = httpContext.User.FindFirstValue(claimType);
if (string.IsNullOrWhiteSpace(claimValue))
{
ThrowForMissingClaim(claimType);
}
return claimValue;
}
[DoesNotReturn]
private static void ThrowForMissingHttpContext()
{
throw new InvalidOperationException("No HttpContext available.");
}
[DoesNotReturn]
private static void ThrowForMissingClaim(string claimType)
{
throw new InvalidOperationException($"No claim of type '{claimType}' found on the current user.");
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public class Car
{
public CarId Id { get; set; } = CarId.New();
public string Name { get; set; } = "";
public string UserId { get; set; } = "";
public virtual User User { get; set; } = null!;
public virtual ICollection<Consumption> Consumptions { get; set; } = [];
}
public class CarTableConfiguration : IEntityTypeConfiguration<Car>
{
public const int NameMaxLength = 50;
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<CarId.EfCoreValueConverter>();
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(NameMaxLength);
builder.Property(x => x.UserId)
.IsRequired();
builder.HasOne(x => x.User)
.WithMany(x => x.Cars);
}
}

View File

@@ -0,0 +1,6 @@
using StronglyTypedIds;
namespace Vegasco.Server.Api.Cars;
[StronglyTypedId]
public partial struct CarId;

View File

@@ -0,0 +1,69 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public static class CreateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("cars", Endpoint)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Endpoint(
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
string userId = userAccessor.GetUserId();
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
if (user is null)
{
user = new User
{
Id = userId
};
await dbContext.Users.AddAsync(user, cancellationToken);
}
Car car = new()
{
Name = request.Name,
UserId = userId
};
await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response);
}
}

View File

@@ -0,0 +1,31 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class DeleteCar
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
dbContext.Cars.Remove(car);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}
}

View File

@@ -0,0 +1,31 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCar
{
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
private static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var response = new Response(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCars
{
public class ApiResponse
{
public IEnumerable<ResponseDto> Cars { get; set; } = [];
}
public record ResponseDto(Guid Id, string Name);
public class Request
{
[FromQuery(Name = "page")] public int? Page { get; set; }
[FromQuery(Name = "pageSize")] public int? PageSize { get; set; }
}
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars", Endpoint)
.WithDescription("Returns all cars")
.WithTags("Cars");
}
private static async Task<Ok<ApiResponse>> Endpoint(
[AsParameters] Request request,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
List<ResponseDto> cars = await dbContext.Cars
.Select(x => new ResponseDto(x.Id.Value, x.Name))
.ToListAsync(cancellationToken);
var response = new ApiResponse
{
Cars = cars
};
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,58 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class UpdateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Endpoint(
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
car.Name = request.Name;
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,9 @@
namespace Vegasco.Server.Api.Common;
public static class Constants
{
public static class Authorization
{
public const string RequireAuthenticatedUserPolicy = "RequireAuthenticatedUser";
}
}

View File

@@ -0,0 +1,159 @@
using Asp.Versioning;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using Microsoft.AspNetCore.Localization;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Common;
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds all the Api related services to the Dependency Injection container.
/// </summary>
/// <param name="builder"></param>
public static void AddApiServices(this IHostApplicationBuilder builder)
{
builder.Services
.AddMiscellaneousServices()
.AddCustomOpenApi()
.AddApiVersioning()
.AddAuthenticationAndAuthorization(builder.Environment);
builder.AddDbContext();
}
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
{
services.AddSingleton(() =>
{
string assemblyName = Assembly.GetExecutingAssembly()
.GetName()
.Name ?? "Vegasco.Server.Api";
return new ActivitySource(assemblyName);
});
services.AddResponseCompression();
services.AddValidatorsFromAssemblies(
[
typeof(IApiMarker).Assembly
], ServiceLifetime.Singleton);
services.AddHealthChecks();
services.AddHttpContextAccessor();
services.AddHostedService<ApplyMigrationsService>();
services.AddRequestLocalization(o =>
{
string[] cultures =
[
"en-US",
"en",
"de-DE",
"de"
];
o.SetDefaultCulture(cultures[0])
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
});
return services;
}
private static IServiceCollection AddCustomOpenApi(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddOpenApi(o =>
{
o.CreateSchemaReferenceId = jsonTypeInfo =>
{
if (string.IsNullOrEmpty(jsonTypeInfo.Type.FullName))
{
return jsonTypeInfo.Type.Name;
}
string? fullClassName = jsonTypeInfo.Type.FullName;
if (!string.IsNullOrEmpty(jsonTypeInfo.Type.Namespace))
{
fullClassName = fullClassName
.Replace(jsonTypeInfo.Type.Namespace, "")
.TrimStart('.');
}
fullClassName = fullClassName.Replace('+', '_');
return fullClassName;
};
});
return services;
}
private static IServiceCollection AddApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1);
o.ApiVersionReader = new UrlSegmentApiVersionReader();
o.ReportApiVersions = true;
})
.AddApiExplorer(o =>
{
o.GroupNameFormat = "'v'V";
o.SubstituteApiVersionInUrl = true;
});
return services;
}
private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services, IHostEnvironment environment)
{
services.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateFluently()
.ValidateOnStart();
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
{
o.MetadataAddress = jwtOptions.Value.MetadataUrl;
o.TokenValidationParameters.ValidAudience = jwtOptions.Value.ValidAudience;
o.TokenValidationParameters.ValidateAudience = true;
if (!string.IsNullOrWhiteSpace(jwtOptions.Value.NameClaimType))
{
o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType;
}
o.RequireHttpsMetadata = !jwtOptions.Value.AllowHttpMetadataUrl && !environment.IsDevelopment();
});
services.AddAuthorizationBuilder()
.AddPolicy(Constants.Authorization.RequireAuthenticatedUserPolicy, p => p
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
services.AddScoped<UserAccessor>();
return services;
}
private static IHostApplicationBuilder AddDbContext(this IHostApplicationBuilder builder)
{
builder.AddNpgsqlDbContext<ApplicationDbContext>(AppHost.Shared.Constants.Database.Name);
return builder;
}
}

View File

@@ -0,0 +1,37 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.Server.Api.Common;
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
List<ValidationResult> failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -0,0 +1,3 @@
namespace Vegasco.Server.Api.Common;
public interface IApiMarker;

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Localization;
using System.Globalization;
using Vegasco.Server.Api.Endpoints;
using Vegasco.Server.ServiceDefaults;
namespace Vegasco.Server.Api.Common;
internal static class StartupExtensions
{
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.AddServiceDefaults();
builder.Configuration.AddEnvironmentVariables("Vegasco_");
builder.AddApiServices();
WebApplication app = builder.Build();
return app;
}
internal static WebApplication ConfigureRequestPipeline(this WebApplication app)
{
app.UseRequestLocalization(o =>
{
o.ApplyCurrentCultureToResponseHeaders = true;
});
app.UseHttpsRedirection();
app.MapHealthChecks("/health");
app.UseAuthentication();
app.UseAuthorization();
app.MapEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi("/swagger/{documentName}/swagger.json");
}
return app;
}
}

View File

@@ -0,0 +1,62 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.Server.Api.Common;
public static class ValidatorExtensions
{
/// <summary>
/// Asynchronously validates an instance of <typeparamref name="T"/> against all <see cref="IValidator{T}"/> instances in <paramref name="validators"/>.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="validators"></param>
/// <param name="instance"></param>
/// <returns>The failed validation results.</returns>
public static async Task<List<ValidationResult>> ValidateAllAsync<T>(this IEnumerable<IValidator<T>> validators, T instance, CancellationToken cancellationToken = default)
{
List<Task<ValidationResult>> validationTasks = validators
.Select(validator => validator.ValidateAsync(instance, cancellationToken))
.ToList();
await Task.WhenAll(validationTasks);
List<ValidationResult> failedValidations = validationTasks
.Select(x => x.Result)
.Where(x => !x.IsValid)
.ToList();
return failedValidations;
}
public static Dictionary<string, string[]> ToCombinedDictionary(this IEnumerable<ValidationResult> validationResults)
{
// Use a hash set to avoid duplicate error messages.
Dictionary<string, HashSet<string>> combinedErrors = [];
foreach (ValidationFailure? error in validationResults.SelectMany(x => x.Errors))
{
if (!combinedErrors.TryGetValue(error.PropertyName, out HashSet<string>? value))
{
value = [error.ErrorMessage];
combinedErrors[error.PropertyName] = value;
continue;
}
value.Add(error.ErrorMessage);
}
return combinedErrors.ToDictionary(x => x.Key, x => x.Value.ToArray());
}
public static OptionsBuilder<T> ValidateFluently<T>(this OptionsBuilder<T> builder)
where T : class
{
builder.Services.AddTransient<IValidateOptions<T>>(serviceProvider =>
{
IEnumerable<IValidator<T>> validators = serviceProvider.GetServices<IValidator<T>>() ?? [];
return new FluentValidationOptions<T>(builder.Name, validators);
});
return builder;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Consumptions;
public class Consumption
{
public ConsumptionId Id { get; set; } = ConsumptionId.New();
public DateTimeOffset DateTime { get; set; }
public double Distance { get; set; }
public double Amount { get; set; }
public bool IgnoreInCalculation { get; set; }
public CarId CarId { get; set; }
public virtual Car Car { get; set; } = null!;
}
public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumption>
{
public void Configure(EntityTypeBuilder<Consumption> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<ConsumptionId.EfCoreValueConverter>();
builder.Property(x => x.DateTime)
.IsRequired();
builder.Property(x => x.Distance)
.IsRequired();
builder.Property(x => x.Amount)
.IsRequired();
builder.Property(x => x.IgnoreInCalculation)
.IsRequired();
builder.Property(x => x.CarId)
.IsRequired()
.HasConversion<CarId.EfCoreValueConverter>();
builder.HasOne(x => x.Car)
.WithMany(x => x.Consumptions);
}
}

View File

@@ -0,0 +1,7 @@
using StronglyTypedIds;
namespace Vegasco.Server.Api.Consumptions;
[StronglyTypedId]
public partial struct ConsumptionId;

View File

@@ -0,0 +1,74 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class CreateConsumption
{
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("consumptions", Endpoint)
.WithTags("Consumptions");
}
public class Validator : AbstractValidator<Request>
{
public Validator(TimeProvider timeProvider)
{
RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow())
.WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance)
.GreaterThan(0);
RuleFor(x => x.Amount)
.GreaterThan(0);
RuleFor(x => x.CarId)
.NotEmpty();
}
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Request request,
IEnumerable<IValidator<Request>> validators,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Car? car = await dbContext.Cars.FindAsync([new CarId(request.CarId)], cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var consumption = new Consumption
{
DateTime = request.DateTime.ToUniversalTime(),
Distance = request.Distance,
Amount = request.Amount,
IgnoreInCalculation = request.IgnoreInCalculation,
CarId = new CarId(request.CarId)
};
dbContext.Consumptions.Add(consumption);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.Created($"consumptions/{consumption.Id.Value}",
new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
}
}

View File

@@ -0,0 +1,30 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class DeleteConsumption
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
CancellationToken cancellationToken)
{
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
dbContext.Consumptions.Remove(consumption);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}
}

View File

@@ -0,0 +1,32 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class GetConsumption
{
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
CancellationToken cancellationToken)
{
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
var response = new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class GetConsumptions
{
public class ApiResponse
{
public IEnumerable<ResponseDto> Consumptions { get; set; } = [];
}
public record ResponseDto(
Guid Id,
DateTimeOffset DateTime,
double Distance,
double Amount,
bool IgnoreInCalculation,
Guid CarId);
public class Request
{
[FromQuery(Name = "page")] public int? Page { get; set; }
[FromQuery(Name = "pageSize")] public int? PageSize { get; set; }
}
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("consumptions", Endpoint)
.WithDescription("Returns all consumption entries")
.WithTags("Consumptions");
}
private static async Task<Ok<ApiResponse>> Endpoint(
[AsParameters] Request request,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
List<ResponseDto> consumptions = await dbContext.Consumptions
.Select(x =>
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
.ToListAsync(cancellationToken);
var apiResponse = new ApiResponse
{
Consumptions = consumptions
};
return TypedResults.Ok(apiResponse);
}
}

View File

@@ -0,0 +1,65 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class UpdateConsumption
{
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
public class Validator : AbstractValidator<Request>
{
public Validator(TimeProvider timeProvider)
{
RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow())
.WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance)
.GreaterThan(0);
RuleFor(x => x.Amount)
.GreaterThan(0);
}
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
consumption.DateTime = request.DateTime.ToUniversalTime();
consumption.Distance = request.Distance;
consumption.Amount = request.Amount;
consumption.IgnoreInCalculation = request.IgnoreInCalculation;
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
}
}

View File

@@ -0,0 +1,45 @@
using Asp.Versioning.Builder;
using Asp.Versioning.Conventions;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Info;
namespace Vegasco.Server.Api.Endpoints;
public static class EndpointExtensions
{
public static void MapEndpoints(this IEndpointRouteBuilder builder)
{
ApiVersionSet apiVersionSet = builder.NewApiVersionSet()
.HasApiVersion(1.0)
.Build();
RouteGroupBuilder versionedApis = builder.MapGroup("/v{apiVersion:apiVersion}")
.WithApiVersionSet(apiVersionSet);
GetCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetCars.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
CreateCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
UpdateCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
DeleteCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetConsumptions.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
CreateConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
UpdateConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
DeleteConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetServerInfo.MapEndpoint(versionedApis);
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http.HttpResults;
namespace Vegasco.Server.Api.Info;
public class GetServerInfo
{
public record Response(
string FullVersion,
string CommitId,
DateTimeOffset CommitDate,
string Environment);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("info/server", Endpoint)
.WithTags("Info");
}
private static Ok<Response> Endpoint(
IHostEnvironment environment)
{
return TypedResults.Ok(new Response(
ThisAssembly.AssemblyInformationalVersion,
ThisAssembly.GitCommitId,
new DateTimeOffset(ThisAssembly.GitCommitDate, TimeSpan.Zero),
environment.EnvironmentName));
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Persistence;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
public DbSet<Car> Cars { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Consumption> Consumptions { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IApiMarker).Assembly);
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
namespace Vegasco.Server.Api.Persistence;
public class ApplyMigrationsService(
ILogger<ApplyMigrationsService> logger,
IServiceScopeFactory scopeFactory,
ActivitySource activitySource)
: IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
using var activity = activitySource.StartActivity("ApplyMigrations");
logger.LogInformation("Starting migrations");
using IServiceScope scope = scopeFactory.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,121 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240818105918_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.HasOne("Vegasco.Server.Api.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Cars",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cars", x => x.Id);
table.ForeignKey(
name: "FK_Cars_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Consumptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DateTime = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Distance = table.Column<double>(type: "double precision", nullable: false),
Amount = table.Column<double>(type: "double precision", nullable: false),
IgnoreInCalculation = table.Column<bool>(type: "boolean", nullable: false),
CarId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Consumptions", x => x.Id);
table.ForeignKey(
name: "FK_Consumptions_Cars_CarId",
column: x => x.CarId,
principalTable: "Cars",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cars_UserId",
table: "Cars",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Consumptions_CarId",
table: "Consumptions",
column: "CarId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Consumptions");
migrationBuilder.DropTable(
name: "Cars");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -0,0 +1,118 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.HasOne("Vegasco.Server.Api.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,6 @@
using Vegasco.Server.Api.Common;
WebApplication.CreateBuilder(args)
.ConfigureServices()
.ConfigureRequestPipeline()
.Run();

View File

@@ -0,0 +1,15 @@
{
"profiles": {
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7098;http://localhost:5076"
}
},
"$schema": "http://json.schemastore.org/launchsettings.json"
}

View File

@@ -0,0 +1,10 @@
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Users;
public class User
{
public string Id { get; set; } = "";
public virtual IList<Car> Cars { get; set; } = [];
}

View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Vegasco.Server.Api.Users;
public class UserTableConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(user => user.Id);
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4bf893d3-0c16-41ec-8b46-2768d841215d</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<RootNamespace>Vegasco.Server.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.3.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" />
<PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Vegasco.Server.AppHost.Shared\Vegasco.Server.AppHost.Shared.csproj" />
<ProjectReference Include="..\Vegasco.Server.ServiceDefaults\Vegasco.Server.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Vegasco": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,16 @@
namespace Vegasco.Server.AppHost.Shared;
public static class Constants
{
public static class Projects
{
public const string Api = "Vegasco-Server-Api";
}
public static class Database
{
public const string ServiceName = "postgres";
public const string Name = "vegasco-database";
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using Vegasco.Server.AppHost.Shared;
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
IResourceBuilder<PostgresDatabaseResource> postgres = builder.AddPostgres(Constants.Database.ServiceName)
.WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume()
.AddDatabase(Constants.Database.Name);
IResourceBuilder<ProjectResource> api = builder
.AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api)
.WithReference(postgres)
.WaitFor(postgres);
builder
.AddNpmApp("Vegasco-Web", "../Vegasco-Web")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(targetPort: 4200);
builder.Build().Run();

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17055;http://localhost:15102",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21122",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22235"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15102",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19222",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20257"
}
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>bb714834-9872-4af6-b154-0b98b14fcca2</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.3.1" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.3.0" />
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Vegasco.Server.AppHost.Shared\Vegasco.Server.AppHost.Shared.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}

View File

@@ -0,0 +1,119 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Vegasco.Server.ServiceDefaults;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks("/health");
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Bogus;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration;
internal class CarFaker
{
private readonly Faker _faker = new();
internal CreateCar.Request CreateCarRequest()
{
return new CreateCar.Request(_faker.Vehicle.Model());
}
internal UpdateCar.Request UpdateCarRequest()
{
return new UpdateCar.Request(_faker.Vehicle.Model());
}
}

View File

@@ -0,0 +1,71 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class CreateCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public CreateCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task CreateCar_ShouldCreateCar_WhenRequestIsValid()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
// Act
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id))
.Which.Id.Value.Should().Be(createdCar!.Id);
}
[Fact]
public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{
// Arrange
var createCarRequest = new CreateCar.Request("");
// Act
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().NotContainEquivalentOf(createCarRequest, o => o.ExcludingMissingMembers());
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,64 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class DeleteCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public DeleteCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteCar_ShouldDeleteCar_WhenCarExists()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
_dbContext.Cars.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,57 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetCar_ShouldReturnCar_WhenCarExists()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
car.Should().BeEquivalentTo(createdCar);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,66 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarsTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarsTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCars_ShouldReturnEmptyList_WhenNoEntriesExist()
{
// Arrange
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEmpty();
}
[Fact]
public async Task GetCars_ShouldReturnEntries_WhenEntriesExist()
{
// Arrange
List<CreateCar.Response> createdCars = [];
const int numberOfCars = 5;
for (var i = 0; i < numberOfCars; i++)
{
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCars.Add(createdCar!);
}
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEquivalentTo(createdCars);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,102 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class UpdateCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public UpdateCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task UpdateCar_ShouldUpdateCar_WhenCarExistsAndRequestIsValid()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
// Act
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
updatedCar!.Id.Should().Be(createdCar.Id);
updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(updatedCar, o =>
o.ExcludingMissingMembers()
.Excluding(x => x.Id))
.Which.Id.Value.Should().Be(updatedCar.Id);
}
[Fact]
public async Task UpdateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
var updateCarRequest = new UpdateCar.Request("");
// Act
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().ContainSingle(x => x.Id.Value == createdCar.Id)
.Which
.Should().NotBeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
}
[Fact]
public async Task UpdateCar_ShouldReturnNotFound_WhenNoCarWithIdExists()
{
// Arrange
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
var randomCarId = Guid.NewGuid();
// Act
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
_dbContext.Cars.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,29 @@
using Bogus;
using Vegasco.Server.Api.Consumptions;
namespace Vegasco.Server.Api.Tests.Integration;
internal class ConsumptionFaker
{
private readonly Faker _faker = new();
internal CreateConsumption.Request CreateConsumptionRequest(Guid carId)
{
return new CreateConsumption.Request(
_faker.Date.RecentOffset(),
_faker.Random.Int(1, 1_000),
_faker.Random.Int(20, 70),
_faker.Random.Bool(),
carId);
}
internal UpdateConsumption.Request UpdateConsumptionRequest()
{
CreateConsumption.Request createRequest = CreateConsumptionRequest(default);
return new UpdateConsumption.Request(
createRequest.DateTime,
createRequest.Distance,
createRequest.Amount,
createRequest.IgnoreInCalculation);
}
}

View File

@@ -0,0 +1,95 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Consumptions;
[Collection(SharedTestCollection.Name)]
public class CreateConsumptionTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
private readonly ConsumptionFaker _consumptionFaker = new();
public CreateConsumptionTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task CreateConsumption_ShouldCreateConsumption_WhenRequestIsValid()
{
// Arrange
CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
// Act
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
createdConsumption.Should().BeEquivalentTo(createConsumptionRequest, o => o.ExcludingMissingMembers());
_dbContext.Consumptions.Should().HaveCount(1)
.And.ContainEquivalentOf(createdConsumption, o =>
o.ExcludingMissingMembers()
.Excluding(x => x!.Id)
.Excluding(x => x!.CarId));
Consumption singleConsumption = _dbContext.Consumptions.Single();
singleConsumption.Id.Value.Should().Be(createdConsumption!.Id);
singleConsumption.CarId.Value.Should().Be(createdConsumption.CarId);
}
[Fact]
public async Task CreateConsumption_ShouldReturnValidationProblems_WhenRequestIsInvalid()
{
// Arrange
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(Guid.Empty);
// Act
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase));
_dbContext.Consumptions.Should().NotContainEquivalentOf(createConsumptionRequest, o => o.ExcludingMissingMembers());
}
private async Task<CreateCar.Response> CreateCarAsync()
{
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!;
}
public Task InitializeAsync()
{
FluentAssertionConfiguration.SetupGlobalConfig();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,82 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Consumptions;
[Collection(SharedTestCollection.Name)]
public class DeleteConsumptionTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
private readonly ConsumptionFaker _consumptionFaker = new();
public DeleteConsumptionTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task DeleteConsumption_ShouldDeleteConsumption_WhenConsumptionExists()
{
// Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
// Act
using HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/consumptions/{createdConsumption.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
_dbContext.Consumptions.Should().NotContain(x => x.Id.Value == createdConsumption.Id);
}
[Fact]
public async Task DeleteConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
{
// Arrange
var consumptionId = Guid.NewGuid();
// Act
using HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/consumptions/{consumptionId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{
CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!;
}
private async Task<CreateCar.Response> CreateCarAsync()
{
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!;
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,88 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Consumptions;
[Collection(SharedTestCollection.Name)]
public class GetConsumptionTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
private readonly ConsumptionFaker _consumptionFaker = new();
public GetConsumptionTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task GetConsumption_ShouldReturnConsumption_WhenConsumptionExist()
{
// Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/consumptions/{createdConsumption.Id}");
// Assert
string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.OK);
var consumption = await response.Content.ReadFromJsonAsync<GetConsumption.Response>();
consumption.Should().BeEquivalentTo(createdConsumption);
}
[Fact]
public async Task GetConsumptions_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
{
// Arrange
var consumptionId = Guid.NewGuid();
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/consumptions{consumptionId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{
CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!;
}
private async Task<CreateCar.Response> CreateCarAsync()
{
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!;
}
public Task InitializeAsync()
{
FluentAssertionConfiguration.SetupGlobalConfig();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,94 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Consumptions;
[Collection(SharedTestCollection.Name)]
public class GetConsumptionsTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
private readonly ConsumptionFaker _consumptionFaker = new();
public GetConsumptionsTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task GetConsumptions_ShouldReturnConsumptions_WhenConsumptionsExist()
{
// Arrange
List<CreateConsumption.Response> createdConsumptions = [];
const int numberOfConsumptions = 3;
for (var i = 0; i < numberOfConsumptions; i++)
{
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
createdConsumptions.Add(createdConsumption);
}
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/consumptions");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse!.Consumptions.Should().BeEquivalentTo(createdConsumptions);
}
[Fact]
public async Task GetConsumptions_ShouldReturnEmptyList_WhenNoConsumptionsExist()
{
// Arrange
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/consumptions");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetConsumptions.ApiResponse>();
apiResponse!.Consumptions.Should().BeEmpty();
}
private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{
CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!;
}
private async Task<CreateCar.Response> CreateCarAsync()
{
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!;
}
public Task InitializeAsync()
{
FluentAssertionConfiguration.SetupGlobalConfig();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,126 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Consumptions;
[Collection(SharedTestCollection.Name)]
public class UpdateConsumptionTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
private readonly ConsumptionFaker _consumptionFaker = new();
public UpdateConsumptionTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task UpdateConsumption_ShouldCreateConsumption_WhenRequestIsValid()
{
// Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest();
// Act
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{createdConsumption.Id}", updateConsumptionRequest);
// Assert
string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedConsumption = await response.Content.ReadFromJsonAsync<UpdateConsumption.Response>();
updatedConsumption.Should().BeEquivalentTo(updateConsumptionRequest, o => o.ExcludingMissingMembers());
_dbContext.Consumptions.Should().HaveCount(1)
.And.ContainEquivalentOf(updatedConsumption, o =>
o.ExcludingMissingMembers()
.Excluding(x => x!.Id)
.Excluding(x => x!.CarId));
Consumption singleConsumption = _dbContext.Consumptions.Single();
singleConsumption.Id.Value.Should().Be(updatedConsumption!.Id);
singleConsumption.CarId.Value.Should().Be(updatedConsumption.CarId);
}
[Fact]
public async Task UpdateConsumption_ShouldReturnValidationProblems_WhenRequestIsInvalid()
{
// Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest() with { Distance = -42 };
var randomGuid = Guid.NewGuid();
// Act
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest);
// Assert
string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(updateConsumptionRequest.Distance), StringComparison.OrdinalIgnoreCase));
_dbContext.Consumptions.Should().NotContainEquivalentOf(updateConsumptionRequest);
}
[Fact]
public async Task UpdateConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
{
// Arrange
CreateConsumption.Response createdConsumption = await CreateConsumptionAsync();
UpdateConsumption.Request updateConsumptionRequest = _consumptionFaker.UpdateConsumptionRequest();
var randomGuid = Guid.NewGuid();
// Act
using HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/consumptions/{randomGuid}", updateConsumptionRequest);
// Assert
string content = await response.Content.ReadAsStringAsync();
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
_dbContext.Consumptions.Should().NotContainEquivalentOf(updateConsumptionRequest);
}
private async Task<CreateConsumption.Response> CreateConsumptionAsync()
{
CreateCar.Response createdCarResponse = await CreateCarAsync();
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
response.EnsureSuccessStatusCode();
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
return createdConsumption!;
}
private async Task<CreateCar.Response> CreateCarAsync()
{
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
return createdCarResponse!;
}
public Task InitializeAsync()
{
FluentAssertionConfiguration.SetupGlobalConfig();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,19 @@
using FluentAssertions;
namespace Vegasco.Server.Api.Tests.Integration;
internal static class FluentAssertionConfiguration
{
private const int DateTimeComparisonPrecision = 100;
internal static void SetupGlobalConfig()
{
AssertionOptions.AssertEquivalencyUsing(options => options
.Using<DateTime>(ctx => ctx.Subject.ToUniversalTime().Should().BeCloseTo(ctx.Expectation.ToUniversalTime(), TimeSpan.FromMilliseconds(DateTimeComparisonPrecision)))
.WhenTypeIs<DateTime>());
AssertionOptions.AssertEquivalencyUsing(options => options
.Using<DateTimeOffset>(ctx => ctx.Subject.ToUniversalTime().Should().BeCloseTo(ctx.Expectation.ToUniversalTime(), TimeSpan.FromMilliseconds(DateTimeComparisonPrecision)))
.WhenTypeIs<DateTimeOffset>());
}
}

View File

@@ -0,0 +1,35 @@
using System.Net.Http.Json;
using FluentAssertions;
using FluentAssertions.Extensions;
using Vegasco.Server.Api.Info;
namespace Vegasco.Server.Api.Tests.Integration.Info;
[Collection(SharedTestCollection.Name)]
public class GetServerInfoTests
{
private readonly WebAppFactory _factory;
public GetServerInfoTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetServerInfo_ShouldReturnServerInfo_WhenCalled()
{
// Arrange
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("/v1/info/server");
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var serverInfo = await response.Content.ReadFromJsonAsync<GetServerInfo.Response>();
serverInfo!.Environment.Should().NotBeEmpty();
serverInfo.CommitDate.Should().BeAfter(23.August(2024))
.And.NotBeAfter(DateTime.Now);
serverInfo.CommitId.Should().MatchRegex(@"[0-9a-f]{40}");
serverInfo.FullVersion.Should().MatchRegex(@"\d\.\d\.\d(-[0-9a-zA-Z]+)?(\+g?[0-9a-f]{10})?");
}
}

View File

@@ -0,0 +1,40 @@
using Npgsql;
using Respawn;
using System.Data.Common;
namespace Vegasco.Server.Api.Tests.Integration;
internal sealed class PostgresRespawner : IDisposable
{
private readonly DbConnection _connection;
private readonly Respawner _respawner;
private PostgresRespawner(Respawner respawner, DbConnection connection)
{
_respawner = respawner;
_connection = connection;
}
public static async Task<PostgresRespawner> CreateAsync(string connectionString)
{
DbConnection connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection,
new RespawnerOptions
{
SchemasToInclude = ["public"],
DbAdapter = DbAdapter.Postgres
});
return new PostgresRespawner(respawner, connection);
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_connection);
}
public void Dispose()
{
_connection.Dispose();
}
}

View File

@@ -0,0 +1,7 @@
namespace Vegasco.Server.Api.Tests.Integration;
[CollectionDefinition(Name)]
public class SharedTestCollection : ICollectionFixture<WebAppFactory>
{
public const string Name = nameof(SharedTestCollection);
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace Vegasco.Server.Api.Tests.Integration;
public sealed class TestUserAlwaysAuthorizedPolicyEvaluator : IPolicyEvaluator
{
public const string Username = "Test user";
public static readonly string UserId = Guid.NewGuid().ToString();
public Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context)
{
Claim[] claims =
[
new Claim(ClaimTypes.Name, Username),
new Claim("name", Username),
new Claim(ClaimTypes.NameIdentifier, UserId),
new Claim("aud", "https://localhost")
];
ClaimsIdentity identity = new(claims, JwtBearerDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new(identity);
AuthenticationTicket ticket = new(principal, JwtBearerDefaults.AuthenticationScheme);
AuthenticateResult result = AuthenticateResult.Success(ticket);
return Task.FromResult(result); ;
}
public Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context,
object? resource)
{
return Task.FromResult(PolicyAuthorizationResult.Success());
}
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.0" />
<PackageReference Include="Bogus" Version="35.6.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="[7.2.0,8.0.0)" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="System.Formats.Asn1" Version="9.0.5" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Vegasco.Server.AppHost.Shared\Vegasco.Server.AppHost.Shared.csproj" />
<ProjectReference Include="..\..\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,72 @@
using DotNet.Testcontainers.Images;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using Vegasco.Server.Api.Common;
namespace Vegasco.Server.Api.Tests.Integration;
public sealed class WebAppFactory : WebApplicationFactory<IApiMarker>, IAsyncLifetime
{
private readonly PostgreSqlContainer _database = new PostgreSqlBuilder()
.WithImage(DockerImage)
.WithImagePullPolicy(PullPolicy.Always)
.Build();
private const string DockerImage = "postgres:alpine";
public HttpClient HttpClient => CreateClient();
private PostgresRespawner? _postgresRespawner;
public async Task InitializeAsync()
{
await _database.StartAsync();
// Force application startup (i.e. initialization and validation)
_ = CreateClient();
_postgresRespawner = await PostgresRespawner.CreateAsync(_database.GetConnectionString());
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
IEnumerable<KeyValuePair<string, string?>> customConfig =
[
new KeyValuePair<string, string?>($"ConnectionStrings:{AppHost.Shared.Constants.Database.Name}", _database.GetConnectionString()),
new KeyValuePair<string, string?>("JWT:ValidAudience", "https://localhost"),
new KeyValuePair<string, string?>("JWT:MetadataUrl", "https://localhost"),
new KeyValuePair<string, string?>("JWT:NameClaimType", null),
];
builder.UseConfiguration(new ConfigurationBuilder()
.AddInMemoryCollection(customConfig)
.Build());
builder.ConfigureServices(services =>
{
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IPolicyEvaluator>();
services.AddSingleton<IPolicyEvaluator, TestUserAlwaysAuthorizedPolicyEvaluator>();
});
}
public async Task ResetDatabaseAsync()
{
await _postgresRespawner!.ResetDatabaseAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
_postgresRespawner!.Dispose();
await _database.DisposeAsync();
}
}

View File

@@ -0,0 +1,179 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using NSubstitute;
using System.Security.Claims;
using Vegasco.Server.Api.Authentication;
namespace Vegasco.Server.Api.Tests.Unit.Authentication;
public sealed class UserAccessorTests
{
private readonly UserAccessor _sut;
private readonly IHttpContextAccessor _httpContextAccessor;
private static readonly string _nameClaimType = "name";
private readonly JwtOptions _jwtOptions = new()
{
NameClaimType = _nameClaimType
};
private readonly IOptions<JwtOptions> _options = Substitute.For<IOptions<JwtOptions>>();
private static readonly string _defaultUsername = "username";
private static readonly string _defaultId = "id";
private readonly ClaimsPrincipal _defaultUser = new(new ClaimsIdentity(
[
new Claim(_nameClaimType, _defaultUsername),
new Claim(ClaimTypes.NameIdentifier, _defaultId)
]));
public UserAccessorTests()
{
_httpContextAccessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext()
{
User = _defaultUser
}
};
_options.Value.Returns(_jwtOptions);
_sut = new UserAccessor(_httpContextAccessor, _options);
}
#region GetUsername
[Fact]
public void GetUsername_ShouldReturnUsername_WhenOptionsNameClaimTypeMatches()
{
// Arrange
// Act
string result = _sut.GetUsername();
// Assert
result.Should().Be(_defaultUsername);
}
[Fact]
public void GetUsername_ShouldReturnUsername_WhenNameClaimTypeIsNotSetAndUsernameIsInUriNameClaimType()
{
// Arrange
_jwtOptions.NameClaimType = null;
_httpContextAccessor.HttpContext!.User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.Name, _defaultUsername)
]));
// Act
string result = _sut.GetUsername();
// Assert
result.Should().Be(_defaultUsername);
}
[Fact]
public void GetUsername_ShouldCacheUsername_WhenFirstCalled()
{
// Arrange
_ = _sut.GetUsername();
_options.ClearReceivedCalls();
// Act
string result = _sut.GetUsername();
// Assert
result.Should().Be(_defaultUsername);
_ = _options.Received(0).Value;
}
[Fact]
public void GetUsername_ShouldThrowInvalidOperationException_WhenHttpContextIsNull()
{
// Arrange
_httpContextAccessor.HttpContext = null;
// Act
Func<string> action = () => _sut.GetUsername();
// Assert
action.Should().ThrowExactly<InvalidOperationException>()
.Which.Message.Should().Be("No HttpContext available.");
}
[Fact]
public void GetUsername_ShouldThrowInvalidOperationException_WhenNameClaimIsNotFound()
{
// Arrange
_httpContextAccessor.HttpContext!.User = new ClaimsPrincipal();
// Act
Func<string> action = () => _sut.GetUsername();
// Assert
action.Should().ThrowExactly<InvalidOperationException>()
.Which.Message.Should().Be($"No claim of type '{_nameClaimType}' found on the current user.");
}
#endregion
#region GetUserId
[Fact]
public void GetUserId_ShouldReturnUserId_WhenUserIdClaimExists()
{
// Arrange
// Act
string result = _sut.GetUserId();
// Assert
result.Should().Be(_defaultId);
}
[Fact]
public void GetUserId_ShouldCacheUserId_WhenFirstCalled()
{
// Arrange
_ = _sut.GetUserId();
_options.ClearReceivedCalls();
// Act
string result = _sut.GetUserId();
// Assert
result.Should().Be(_defaultId);
_ = _options.Received(0).Value;
}
[Fact]
public void GetUserId_ShouldThrowInvalidOperationException_WhenHttpContextIsNull()
{
// Arrange
_httpContextAccessor.HttpContext = null;
// Act
Func<string> action = () => _sut.GetUserId();
// Assert
action.Should().ThrowExactly<InvalidOperationException>()
.Which.Message.Should().Be("No HttpContext available.");
}
[Fact]
public void GetUserId_ShouldThrowInvalidOperationException_WhenIdClaimIsNotFound()
{
// Arrange
_httpContextAccessor.HttpContext!.User = new ClaimsPrincipal();
// Act
Func<string> action = () => _sut.GetUserId();
// Assert
action.Should().ThrowExactly<InvalidOperationException>()
.Which.Message.Should().Be($"No claim of type '{ClaimTypes.NameIdentifier}' found on the current user.");
}
#endregion
}

View File

@@ -0,0 +1,72 @@
using FluentAssertions;
using FluentValidation.Results;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Unit.Cars;
public sealed class CreateCarRequestValidatorTests
{
private readonly CreateCar.Validator _sut = new();
private readonly CreateCar.Request _validRequest = new("Ford Focus");
[Fact]
public async Task ValidateAsync_ShouldBeValid_WhenRequestIsValid()
{
// Arrange
// Act
ValidationResult? result = await _sut.ValidateAsync(_validRequest);
// Assert
result.IsValid.Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(50)]
public async Task ValidateAsync_ShouldBeValid_WhenNameIsJustWithinTheLimits(int nameLength)
{
// Arrange
CreateCar.Request request = _validRequest with { Name = new string('s', nameLength) };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task ValidateAsync_ShouldNotBeValid_WhenNameIsEmpty()
{
// Arrange
CreateCar.Request request = _validRequest with { Name = "" };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle()
.Which
.PropertyName.Should().Be(nameof(CreateCar.Request.Name));
}
[Fact]
public async Task ValidateAsync_ShouldNotBeValid_WhenNameIsTooLong()
{
// Arrange
const int nameMaxLength = 50;
CreateCar.Request request = _validRequest with { Name = new string('s', nameMaxLength + 1) };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle()
.Which
.PropertyName.Should().Be(nameof(CreateCar.Request.Name));
}
}

View File

@@ -0,0 +1,72 @@
using FluentAssertions;
using FluentValidation.Results;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Unit.Cars;
public sealed class UpdateCarRequestValidatorTests
{
private readonly UpdateCar.Validator _sut = new();
private readonly UpdateCar.Request _validRequest = new("Ford Focus");
[Fact]
public async Task ValidateAsync_ShouldBeValid_WhenRequestIsValid()
{
// Arrange
// Act
ValidationResult? result = await _sut.ValidateAsync(_validRequest);
// Assert
result.IsValid.Should().BeTrue();
}
[Theory]
[InlineData(1)]
[InlineData(50)]
public async Task ValidateAsync_ShouldBeValid_WhenNameIsJustWithinTheLimits(int nameLength)
{
// Arrange
UpdateCar.Request request = _validRequest with { Name = new string('s', nameLength) };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task ValidateAsync_ShouldNotBeValid_WhenNameIsEmpty()
{
// Arrange
UpdateCar.Request request = _validRequest with { Name = "" };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle()
.Which
.PropertyName.Should().Be(nameof(UpdateCar.Request.Name));
}
[Fact]
public async Task ValidateAsync_ShouldNotBeValid_WhenNameIsTooLong()
{
// Arrange
const int nameMaxLength = 50;
UpdateCar.Request request = _validRequest with { Name = new string('s', nameMaxLength + 1) };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle()
.Which
.PropertyName.Should().Be(nameof(UpdateCar.Request.Name));
}
}

View File

@@ -0,0 +1,100 @@
using FluentAssertions;
using FluentValidation.Results;
using NSubstitute;
using Vegasco.Server.Api.Consumptions;
namespace Vegasco.Server.Api.Tests.Unit.Consumptions;
public class CreateConsumptionRequestValidatorTests
{
private readonly CreateConsumption.Validator _sut;
private readonly TimeProvider _timeProvider = Substitute.For<TimeProvider>();
private readonly DateTimeOffset _utcNow = new DateTimeOffset(2024, 8, 18, 13, 2, 53, TimeSpan.Zero);
private readonly CreateConsumption.Request _validRequest;
public CreateConsumptionRequestValidatorTests()
{
_timeProvider.GetUtcNow().Returns(_utcNow);
_sut = new CreateConsumption.Validator(_timeProvider);
_validRequest = new CreateConsumption.Request(
_utcNow.AddDays(-1),
1,
1,
false,
Guid.NewGuid());
}
[Fact]
public async Task ValidateAsync_ShouldBeValid_WhenRequestIsValid()
{
// Arrange
// Act
ValidationResult? result = await _sut.ValidateAsync(_validRequest);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task ValidateAsync_ShouldBeInvalid_WhenDateTimeIsGreaterThanUtcNow()
{
// Arrange
CreateConsumption.Request request = _validRequest with { DateTime = _utcNow.AddDays(1) };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(x => x.PropertyName == nameof(CreateConsumption.Request.DateTime));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task ValidateAsync_ShouldBeInvalid_WhenDistanceIsLessThanOrEqualToZero(double distance)
{
// Arrange
CreateConsumption.Request request = _validRequest with { Distance = distance };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(x => x.PropertyName == nameof(CreateConsumption.Request.Distance));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task ValidateAsync_ShouldBeInvalid_WhenAmountIsLessThanOrEqualToZero(double amount)
{
// Arrange
CreateConsumption.Request request = _validRequest with { Amount = amount };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(x => x.PropertyName == nameof(CreateConsumption.Request.Amount));
}
[Fact]
public async Task ValidateAsync_ShouldBeInvalid_WhenCarIdIsEmpty()
{
// Arrange
CreateConsumption.Request request = _validRequest with { CarId = Guid.Empty };
// Act
ValidationResult? result = await _sut.ValidateAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(x => x.PropertyName == nameof(CreateConsumption.Request.CarId));
}
}

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