From 0e79a8eb5e58733b73bc24bfb77f7a2848ea4593 Mon Sep 17 00:00:00 2001 From: Chris Dill Date: Sat, 30 May 2026 12:26:50 +0100 Subject: [PATCH] Initial commit --- .editorconfig | 51 ++++++++++ .forgejo/workflows/build.yml | 34 +++++++ .gitattributes | 2 + .gitignore | 6 ++ README.md | 7 ++ TestWeb.slnx | 8 ++ src/Core/Jobs/CreateJobEndpoint.cs | 41 ++++++++ src/Core/Jobs/DeleteJobEndpoint.cs | 21 ++++ src/Core/Jobs/GetJobEndpoint.cs | 21 ++++ src/Core/Jobs/GetJobsEndpoint.cs | 13 +++ src/Core/Jobs/JobModule.cs | 27 +++++ src/Core/Jobs/UpdateJobEndpoint.cs | 48 +++++++++ src/Core/Users/CreateUserEndpoint.cs | 35 +++++++ src/Core/Users/DeleteUserEndpoint.cs | 21 ++++ src/Core/Users/GetUserEndpoint.cs | 21 ++++ src/Core/Users/GetUsersEndpoint.cs | 13 +++ src/Core/Users/UpdateUserEndpoint.cs | 40 ++++++++ src/Core/Users/UserModule.cs | 28 ++++++ src/Core/Vehicles/CreateVehicleEndpoint.cs | 36 +++++++ src/Core/Vehicles/DeleteVehicleEndpoint.cs | 21 ++++ src/Core/Vehicles/GetVehicleEndpoint.cs | 21 ++++ src/Core/Vehicles/GetVehiclesEndpoint.cs | 13 +++ src/Core/Vehicles/UpdateVehicleEndpoint.cs | 42 ++++++++ src/Core/Vehicles/VehicleModule.cs | 27 +++++ .../GetWeatherForecastsEndpoint.cs | 25 +++++ .../WeatherForecasts/WeatherForecastModule.cs | 15 +++ src/Infra/AppDbContext.cs | 19 ++++ src/Infra/Job.cs | 34 +++++++ src/Infra/User.cs | 30 ++++++ src/Infra/Vehicle.cs | 30 ++++++ src/Program.cs | 98 +++++++++++++++++++ src/TestWeb.csproj | 16 +++ src/Utils/RequestLoggingFilter.cs | 15 +++ src/Utils/RequestValidationFilter.cs | 34 +++++++ src/Utils/ValidationExtensions.cs | 12 +++ src/appsettings.json | 19 ++++ tests/Core/Jobs/JobModuleTests.cs | 83 ++++++++++++++++ tests/Core/Users/UserModuleTests.cs | 79 +++++++++++++++ tests/Core/Vehicles/VehicleModuleTests.cs | 81 +++++++++++++++ .../WeatherForecastModuleTests.cs | 31 ++++++ tests/TestFixture.cs | 34 +++++++ tests/TestWeb.Tests.csproj | 27 +++++ tests/xunit.runner.json | 3 + 43 files changed, 1282 insertions(+) create mode 100755 .editorconfig create mode 100755 .forgejo/workflows/build.yml create mode 100755 .gitattributes create mode 100755 .gitignore create mode 100755 README.md create mode 100644 TestWeb.slnx create mode 100644 src/Core/Jobs/CreateJobEndpoint.cs create mode 100644 src/Core/Jobs/DeleteJobEndpoint.cs create mode 100644 src/Core/Jobs/GetJobEndpoint.cs create mode 100644 src/Core/Jobs/GetJobsEndpoint.cs create mode 100755 src/Core/Jobs/JobModule.cs create mode 100644 src/Core/Jobs/UpdateJobEndpoint.cs create mode 100644 src/Core/Users/CreateUserEndpoint.cs create mode 100644 src/Core/Users/DeleteUserEndpoint.cs create mode 100644 src/Core/Users/GetUserEndpoint.cs create mode 100644 src/Core/Users/GetUsersEndpoint.cs create mode 100644 src/Core/Users/UpdateUserEndpoint.cs create mode 100755 src/Core/Users/UserModule.cs create mode 100644 src/Core/Vehicles/CreateVehicleEndpoint.cs create mode 100644 src/Core/Vehicles/DeleteVehicleEndpoint.cs create mode 100644 src/Core/Vehicles/GetVehicleEndpoint.cs create mode 100644 src/Core/Vehicles/GetVehiclesEndpoint.cs create mode 100644 src/Core/Vehicles/UpdateVehicleEndpoint.cs create mode 100755 src/Core/Vehicles/VehicleModule.cs create mode 100755 src/Core/WeatherForecasts/GetWeatherForecastsEndpoint.cs create mode 100755 src/Core/WeatherForecasts/WeatherForecastModule.cs create mode 100755 src/Infra/AppDbContext.cs create mode 100755 src/Infra/Job.cs create mode 100755 src/Infra/User.cs create mode 100755 src/Infra/Vehicle.cs create mode 100755 src/Program.cs create mode 100755 src/TestWeb.csproj create mode 100755 src/Utils/RequestLoggingFilter.cs create mode 100755 src/Utils/RequestValidationFilter.cs create mode 100755 src/Utils/ValidationExtensions.cs create mode 100755 src/appsettings.json create mode 100644 tests/Core/Jobs/JobModuleTests.cs create mode 100644 tests/Core/Users/UserModuleTests.cs create mode 100644 tests/Core/Vehicles/VehicleModuleTests.cs create mode 100644 tests/Core/WeatherForecasts/WeatherForecastModuleTests.cs create mode 100644 tests/TestFixture.cs create mode 100644 tests/TestWeb.Tests.csproj create mode 100644 tests/xunit.runner.json diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..7a88c35 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,51 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +indent_style = space + +# General +[*.{cs,csx,vb,vbx}] +indent_size = 4 +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +# C# styles +[*.cs] +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_style_namespace_declarations = file_scoped:warning + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Spacing +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_around_binary_operators = before_and_after +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false + +# Blocks are allowed +csharp_prefer_braces = true:error +csharp_preserve_single_line_blocks = false +csharp_preserve_single_line_statements = false diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100755 index 0000000..e1b1fa1 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up dotnet + uses: https://github.com/actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Build server + run: dotnet build -c Release + + - name: Test server + run: dotnet test -c Release + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..684a3c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.nuget/ +bin/ +obj/ +app.db +app.db-shm +app.db-wal diff --git a/README.md b/README.md new file mode 100755 index 0000000..9f35953 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# test-web + +Test web dev. + +```shell +dotnet run --project src +``` diff --git a/TestWeb.slnx b/TestWeb.slnx new file mode 100644 index 0000000..f085229 --- /dev/null +++ b/TestWeb.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Core/Jobs/CreateJobEndpoint.cs b/src/Core/Jobs/CreateJobEndpoint.cs new file mode 100644 index 0000000..06c4fc9 --- /dev/null +++ b/src/Core/Jobs/CreateJobEndpoint.cs @@ -0,0 +1,41 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class CreateJobEndpoint +{ + public record Request( + string ClientName, + string Pickup, + string Dropoff + ); + + public record Response(int Id); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.ClientName).NotEmpty().MaximumLength(250); + RuleFor(x => x.Pickup).NotEmpty().MaximumLength(120); + RuleFor(x => x.Dropoff).NotEmpty().MaximumLength(120); + } + } + + public static async Task> Handle(Request request, AppDbContext db, CancellationToken ct) + { + var row = new Job + { + ClientName = request.ClientName, + Pickup = request.Pickup, + Dropoff = request.Dropoff, + }; + + await db.Jobs.AddAsync(row, ct); + await db.SaveChangesAsync(ct); + + var response = new Response(row.Id); + return TypedResults.Ok(response); + } +} diff --git a/src/Core/Jobs/DeleteJobEndpoint.cs b/src/Core/Jobs/DeleteJobEndpoint.cs new file mode 100644 index 0000000..fcf3e27 --- /dev/null +++ b/src/Core/Jobs/DeleteJobEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class DeleteJobEndpoint +{ + public static async Task> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var rowsDeleted = await db.Jobs + .Where(x => x.Id == id) + .ExecuteDeleteAsync(ct); + + return rowsDeleted == 1 + ? TypedResults.Ok() + : TypedResults.NotFound(); + } +} diff --git a/src/Core/Jobs/GetJobEndpoint.cs b/src/Core/Jobs/GetJobEndpoint.cs new file mode 100644 index 0000000..7a3c899 --- /dev/null +++ b/src/Core/Jobs/GetJobEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class GetJobEndpoint +{ + public static async Task, NotFound>> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Jobs + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + return result is null + ? TypedResults.NotFound() + : TypedResults.Ok(result); + } +} diff --git a/src/Core/Jobs/GetJobsEndpoint.cs b/src/Core/Jobs/GetJobsEndpoint.cs new file mode 100644 index 0000000..2852d5b --- /dev/null +++ b/src/Core/Jobs/GetJobsEndpoint.cs @@ -0,0 +1,13 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class GetJobsEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Jobs.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} diff --git a/src/Core/Jobs/JobModule.cs b/src/Core/Jobs/JobModule.cs new file mode 100755 index 0000000..3589bf9 --- /dev/null +++ b/src/Core/Jobs/JobModule.cs @@ -0,0 +1,27 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class JobModule +{ + public const string Prefix = "/api/jobs"; + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(Prefix) + .WithTags("Jobs"); + group.MapPost("/", CreateJobEndpoint.Handle) + .WithSummary("Create a job") + .WithRequestValidation(); + group.MapDelete("/{id:int}", DeleteJobEndpoint.Handle) + .WithSummary("Delete job by id"); + group.MapGet("/{id:int}", GetJobEndpoint.Handle) + .WithSummary("Get job by id"); + group.MapGet("/", GetJobsEndpoint.Handle) + .WithSummary("Get job list"); + group.MapPut("/{id:int}", UpdateJobEndpoint.Handle) + .WithSummary("Update job by id") + .WithRequestValidation(); + } +} diff --git a/src/Core/Jobs/UpdateJobEndpoint.cs b/src/Core/Jobs/UpdateJobEndpoint.cs new file mode 100644 index 0000000..e4fa388 --- /dev/null +++ b/src/Core/Jobs/UpdateJobEndpoint.cs @@ -0,0 +1,48 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Jobs; + +public static class UpdateJobEndpoint +{ + public record Request( + string ClientName, + string Pickup, + string Dropoff + ); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.ClientName).NotEmpty().MaximumLength(250); + RuleFor(x => x.Pickup).NotEmpty().MaximumLength(120); + RuleFor(x => x.Dropoff).NotEmpty().MaximumLength(120); + } + } + + public static async Task> Handle( + int id, + Request request, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Jobs + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + if (result == null) + { + return TypedResults.NotFound(); + } + + result.ClientName = request.ClientName; + result.Pickup = request.Pickup; + result.Dropoff = request.Dropoff; + result.LastUpdated = DateTime.UtcNow; + + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} diff --git a/src/Core/Users/CreateUserEndpoint.cs b/src/Core/Users/CreateUserEndpoint.cs new file mode 100644 index 0000000..b3a2d09 --- /dev/null +++ b/src/Core/Users/CreateUserEndpoint.cs @@ -0,0 +1,35 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class CreateUserEndpoint +{ + public record Request(string UserName); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.UserName).NotEmpty().MaximumLength(250); + } + } + + public record Response(int Id); + + public static async Task> Handle(Request request, AppDbContext db, CancellationToken ct) + { + var row = new User + { + Username = request.UserName, + DisplayName = request.UserName, + Password = string.Empty + }; + + await db.Users.AddAsync(row, ct); + await db.SaveChangesAsync(ct); + + var response = new Response(row.Id); + return TypedResults.Ok(response); + } +} diff --git a/src/Core/Users/DeleteUserEndpoint.cs b/src/Core/Users/DeleteUserEndpoint.cs new file mode 100644 index 0000000..aff49ef --- /dev/null +++ b/src/Core/Users/DeleteUserEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class DeleteUserEndpoint +{ + public static async Task> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var rowsDeleted = await db.Users + .Where(x => x.Id == id) + .ExecuteDeleteAsync(ct); + + return rowsDeleted == 1 + ? TypedResults.Ok() + : TypedResults.NotFound(); + } +} diff --git a/src/Core/Users/GetUserEndpoint.cs b/src/Core/Users/GetUserEndpoint.cs new file mode 100644 index 0000000..d0e7011 --- /dev/null +++ b/src/Core/Users/GetUserEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class GetUserEndpoint +{ + public static async Task, NotFound>> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Users + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + return result is null + ? TypedResults.NotFound() + : TypedResults.Ok(result); + } +} diff --git a/src/Core/Users/GetUsersEndpoint.cs b/src/Core/Users/GetUsersEndpoint.cs new file mode 100644 index 0000000..b3f91af --- /dev/null +++ b/src/Core/Users/GetUsersEndpoint.cs @@ -0,0 +1,13 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class GetUsersEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Users.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} diff --git a/src/Core/Users/UpdateUserEndpoint.cs b/src/Core/Users/UpdateUserEndpoint.cs new file mode 100644 index 0000000..f138258 --- /dev/null +++ b/src/Core/Users/UpdateUserEndpoint.cs @@ -0,0 +1,40 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class UpdateUserEndpoint +{ + public record Request(string DisplayName); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(250); + } + } + + public static async Task> Handle( + int id, + Request request, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Users + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + if (result == null) + { + return TypedResults.NotFound(); + } + + result.DisplayName = request.DisplayName; + result.LastUpdated = DateTime.UtcNow; + + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} diff --git a/src/Core/Users/UserModule.cs b/src/Core/Users/UserModule.cs new file mode 100755 index 0000000..2614b56 --- /dev/null +++ b/src/Core/Users/UserModule.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Options; +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Users; + +public static class UserModule +{ + public const string Prefix = "/api/users"; + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(Prefix) + .WithTags("Users"); + group.MapPost("/", CreateUserEndpoint.Handle) + .WithSummary("Create a user") + .WithRequestValidation(); + group.MapDelete("/{id}", DeleteUserEndpoint.Handle) + .WithSummary("Delete user by id"); + group.MapGet("/{id}", GetUserEndpoint.Handle) + .WithSummary("Get user by id"); + group.MapGet("/", GetUsersEndpoint.Handle) + .WithSummary("Get user list"); + group.MapPut("/{id}", UpdateUserEndpoint.Handle) + .WithSummary("Update user by id") + .WithRequestValidation(); + } +} diff --git a/src/Core/Vehicles/CreateVehicleEndpoint.cs b/src/Core/Vehicles/CreateVehicleEndpoint.cs new file mode 100644 index 0000000..d59aec0 --- /dev/null +++ b/src/Core/Vehicles/CreateVehicleEndpoint.cs @@ -0,0 +1,36 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class CreateVehicleEndpoint +{ + public record Request(string Make, string Model); + + public record Response(int Id); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.Make).NotEmpty().MaximumLength(250); + RuleFor(x => x.Model).NotEmpty().MaximumLength(250); + } + } + + public static async Task> Handle(Request request, AppDbContext db, CancellationToken ct) + { + var row = new Vehicle + { + Make = request.Make, + Model = request.Model, + Year = DateTime.Now.Year + }; + + await db.Vehicles.AddAsync(row, ct); + await db.SaveChangesAsync(ct); + + var response = new Response(row.Id); + return TypedResults.Ok(response); + } +} diff --git a/src/Core/Vehicles/DeleteVehicleEndpoint.cs b/src/Core/Vehicles/DeleteVehicleEndpoint.cs new file mode 100644 index 0000000..10f7e0a --- /dev/null +++ b/src/Core/Vehicles/DeleteVehicleEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class DeleteVehicleEndpoint +{ + public static async Task> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var rowsDeleted = await db.Vehicles + .Where(x => x.Id == id) + .ExecuteDeleteAsync(ct); + + return rowsDeleted == 1 + ? TypedResults.Ok() + : TypedResults.NotFound(); + } +} diff --git a/src/Core/Vehicles/GetVehicleEndpoint.cs b/src/Core/Vehicles/GetVehicleEndpoint.cs new file mode 100644 index 0000000..619b147 --- /dev/null +++ b/src/Core/Vehicles/GetVehicleEndpoint.cs @@ -0,0 +1,21 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class GetVehicleEndpoint +{ + public static async Task, NotFound>> Handle( + int id, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Vehicles + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + return result is null + ? TypedResults.NotFound() + : TypedResults.Ok(result); + } +} diff --git a/src/Core/Vehicles/GetVehiclesEndpoint.cs b/src/Core/Vehicles/GetVehiclesEndpoint.cs new file mode 100644 index 0000000..3e1eecf --- /dev/null +++ b/src/Core/Vehicles/GetVehiclesEndpoint.cs @@ -0,0 +1,13 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class GetVehiclesEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Vehicles.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} diff --git a/src/Core/Vehicles/UpdateVehicleEndpoint.cs b/src/Core/Vehicles/UpdateVehicleEndpoint.cs new file mode 100644 index 0000000..9a015f6 --- /dev/null +++ b/src/Core/Vehicles/UpdateVehicleEndpoint.cs @@ -0,0 +1,42 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class UpdateVehicleEndpoint +{ + public record Request(string Make, string Model); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.Make).NotEmpty().MaximumLength(250); + RuleFor(x => x.Model).NotEmpty().MaximumLength(250); + } + } + + public static async Task> Handle( + int id, + Request request, + AppDbContext db, + CancellationToken ct) + { + var result = await db.Vehicles + .Where(x => x.Id == id) + .SingleOrDefaultAsync(ct); + + if (result == null) + { + return TypedResults.NotFound(); + } + + result.Make = request.Make; + result.Model = request.Model; + result.LastUpdated = DateTime.UtcNow; + + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} diff --git a/src/Core/Vehicles/VehicleModule.cs b/src/Core/Vehicles/VehicleModule.cs new file mode 100755 index 0000000..480a12b --- /dev/null +++ b/src/Core/Vehicles/VehicleModule.cs @@ -0,0 +1,27 @@ +using TestWeb.Infra; +using TestWeb.Utils; + +namespace TestWeb.Core.Vehicles; + +public static class VehicleModule +{ + public const string Prefix = "/api/vehicles"; + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(Prefix) + .WithTags("Vehicles"); + group.MapPost("/", CreateVehicleEndpoint.Handle) + .WithSummary("Create a vehicle") + .WithRequestValidation(); + group.MapDelete("/{id}", DeleteVehicleEndpoint.Handle) + .WithSummary("Delete vehicle by id"); + group.MapGet("/{id}", GetVehicleEndpoint.Handle) + .WithSummary("Get vehicle by id"); + group.MapGet("/", GetVehiclesEndpoint.Handle) + .WithSummary("Get vehicle list"); + group.MapPut("/{id}", UpdateVehicleEndpoint.Handle) + .WithSummary("Update vehicle by id") + .WithRequestValidation(); + } +} diff --git a/src/Core/WeatherForecasts/GetWeatherForecastsEndpoint.cs b/src/Core/WeatherForecasts/GetWeatherForecastsEndpoint.cs new file mode 100755 index 0000000..2150ee7 --- /dev/null +++ b/src/Core/WeatherForecasts/GetWeatherForecastsEndpoint.cs @@ -0,0 +1,25 @@ +namespace TestWeb.Core.WeatherForecasts; + +public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} + +public static class GetWeatherForecastsEndpoint +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + public static WeatherForecast[] Handle() + { + var random = new Random(); + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + random.Next(-20, 55), + Summaries[random.Next(Summaries.Length)] + )).ToArray(); + } +} diff --git a/src/Core/WeatherForecasts/WeatherForecastModule.cs b/src/Core/WeatherForecasts/WeatherForecastModule.cs new file mode 100755 index 0000000..44575e4 --- /dev/null +++ b/src/Core/WeatherForecasts/WeatherForecastModule.cs @@ -0,0 +1,15 @@ +namespace TestWeb.Core.WeatherForecasts; + +public static class WeatherForecastModule +{ + public const string Prefix = "/api/weatherforecasts"; + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/weatherforecasts") + .WithTags("WeatherForecasts") + .AllowAnonymous(); + group.MapGet("/", GetWeatherForecastsEndpoint.Handle) + .WithSummary("Get weather forecast list"); + } +} diff --git a/src/Infra/AppDbContext.cs b/src/Infra/AppDbContext.cs new file mode 100755 index 0000000..09e9a74 --- /dev/null +++ b/src/Infra/AppDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace TestWeb.Infra; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Jobs + { + get; set; + } + public DbSet Users + { + get; set; + } + public DbSet Vehicles + { + get; set; + } +} diff --git a/src/Infra/Job.cs b/src/Infra/Job.cs new file mode 100755 index 0000000..20473c0 --- /dev/null +++ b/src/Infra/Job.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestWeb.Infra; + +public class Job +{ + [Key] + public int Id + { + get; init; + } + public required string ClientName + { + get; set; + } + public required string Pickup + { + get; set; + } + public string? Dropoff + { + get; set; + } + public int VehicleId + { + get; set; + } + public DateTime DateCreated { get; private init; } = DateTime.UtcNow; + public DateTime? LastUpdated + { + get; set; + } +} diff --git a/src/Infra/User.cs b/src/Infra/User.cs new file mode 100755 index 0000000..159d477 --- /dev/null +++ b/src/Infra/User.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestWeb.Infra; + +public class User +{ + [Key] + public int Id + { + get; init; + } + public required string Username + { + get; set; + } + public required string Password + { + get; set; + } + public required string DisplayName + { + get; set; + } + public DateTime DateCreated { get; private init; } = DateTime.UtcNow; + public DateTime? LastUpdated + { + get; set; + } +} diff --git a/src/Infra/Vehicle.cs b/src/Infra/Vehicle.cs new file mode 100755 index 0000000..e9965bc --- /dev/null +++ b/src/Infra/Vehicle.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestWeb.Infra; + +public class Vehicle +{ + [Key] + public int Id + { + get; init; + } + public required string Make + { + get; set; + } + public required string Model + { + get; set; + } + public required int Year + { + get; set; + } + public DateTime DateCreated { get; private init; } = DateTime.UtcNow; + public DateTime? LastUpdated + { + get; set; + } +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100755 index 0000000..3a64453 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,98 @@ +global using Microsoft.EntityFrameworkCore; +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Http.HttpResults; +global using System.Security.Claims; +global using FluentValidation; + +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using TestWeb.Infra; +using TestWeb.Utils; +using TestWeb.Core.Jobs; +using TestWeb.Core.Users; +using TestWeb.Core.Vehicles; +using TestWeb.Core.WeatherForecasts; + +namespace TestWeb; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + AddServices(builder); + + var app = builder.Build(); + Configure(app); + + app.Run(); + } + + public static void AddServices(WebApplicationBuilder builder) + { + builder.Services.AddDbContext( + options => options.UseSqlite("DataSource=app.db")); + + builder.Services.AddOpenApi(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + builder.Services.AddCors(options => + { + options.AddPolicy( + "CorsPolicy", + builder => builder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod() + ); + }); + + builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); + } + + public static void Configure(WebApplication app) + { + app.MapOpenApi(); + + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); + app.UseCors("CorsPolicy"); + app.UseHttpsRedirection(); + app.UseRouting(); + + var endpoints = app.MapGroup("") + .AddEndpointFilter(); + + var securityScheme = new OpenApiSecurityScheme() + { + Type = SecuritySchemeType.Http, + Name = JwtBearerDefaults.AuthenticationScheme, + Scheme = JwtBearerDefaults.AuthenticationScheme, + Reference = new() + { + Type = ReferenceType.SecurityScheme, + Id = JwtBearerDefaults.AuthenticationScheme + } + }; + endpoints.WithOpenApi(x => new() + { + Security = [new() { [securityScheme] = [] }] + }); + + JobModule.MapEndpoints(endpoints); + UserModule.MapEndpoints(endpoints); + VehicleModule.MapEndpoints(endpoints); + WeatherForecastModule.MapEndpoints(endpoints); + + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } +} diff --git a/src/TestWeb.csproj b/src/TestWeb.csproj new file mode 100755 index 0000000..0ec34b6 --- /dev/null +++ b/src/TestWeb.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Utils/RequestLoggingFilter.cs b/src/Utils/RequestLoggingFilter.cs new file mode 100755 index 0000000..21915d7 --- /dev/null +++ b/src/Utils/RequestLoggingFilter.cs @@ -0,0 +1,15 @@ +namespace TestWeb.Utils; + +public class RequestLoggingFilter(ILogger logger) : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + logger.LogInformation( + "HTTP {Method} {Path} recieved", + context.HttpContext.Request.Method, + context.HttpContext.Request.Path); + return await next(context); + } +} diff --git a/src/Utils/RequestValidationFilter.cs b/src/Utils/RequestValidationFilter.cs new file mode 100755 index 0000000..3847c76 --- /dev/null +++ b/src/Utils/RequestValidationFilter.cs @@ -0,0 +1,34 @@ +namespace TestWeb.Utils; + +public class RequestValidationFilter( + ILogger> logger, + IValidator? validator = null) : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var requestName = typeof(TRequest).FullName; + + if (validator is null) + { + logger.LogInformation("{Request}: No validator configured.", requestName); + return await next(context); + } + + logger.LogInformation("{Request}: Validating...", requestName); + var request = context.Arguments.OfType().First(); + var validationResult = await validator.ValidateAsync( + request, + context.HttpContext.RequestAborted + ); + if (!validationResult.IsValid) + { + logger.LogWarning("{Request}: Validation failed.", requestName); + return TypedResults.ValidationProblem(validationResult.ToDictionary()); + } + + logger.LogInformation("{Request}: Validation succeeded.", requestName); + return await next(context); + } +} diff --git a/src/Utils/ValidationExtensions.cs b/src/Utils/ValidationExtensions.cs new file mode 100755 index 0000000..71e0481 --- /dev/null +++ b/src/Utils/ValidationExtensions.cs @@ -0,0 +1,12 @@ +namespace TestWeb.Utils; + +public static class ValidationExtensions +{ + public static RouteHandlerBuilder WithRequestValidation( + this RouteHandlerBuilder builder) + { + return builder + .AddEndpointFilter>() + .ProducesValidationProblem(); + } +} diff --git a/src/appsettings.json b/src/appsettings.json new file mode 100755 index 0000000..70fb08c --- /dev/null +++ b/src/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System": "Information", + "Microsoft": "Information" + }, + "Console": { + "IncludeScopes": true + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Server=.;Database=TestWeb;Trusted_Connection=True;Encrypt=False;" + }, + "Jwt": { + "Key": "superdupersecretkeythatsreallyreallylong" + } +} diff --git a/tests/Core/Jobs/JobModuleTests.cs b/tests/Core/Jobs/JobModuleTests.cs new file mode 100644 index 0000000..ac44cc2 --- /dev/null +++ b/tests/Core/Jobs/JobModuleTests.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using TestWeb.Infra; +using TestWeb.Core.Jobs; + +namespace TestWeb.Tests.Core.Jobs; + +public class JobModuleTests : IClassFixture +{ + private readonly HttpClient client; + + public JobModuleTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateJobEndpoint.Request( + "Client name", "Pickup", "Dropoff" + ); + var createCall = await client.PostAsJsonAsync( + JobModule.Prefix, + createRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, createCall.StatusCode); + + var createResponse = await createCall.Content + .ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.NotNull(createResponse); + + // Get + var getListCall = await client.GetAsync( + JobModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{JobModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateJobEndpoint.Request( + "Client name", "Pickup update", "Dropoff update"); + var updateCall = await client.PutAsJsonAsync( + $"{JobModule.Prefix}/{createResponse.Id}", updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{JobModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{JobModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{JobModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/Core/Users/UserModuleTests.cs b/tests/Core/Users/UserModuleTests.cs new file mode 100644 index 0000000..c523a9d --- /dev/null +++ b/tests/Core/Users/UserModuleTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using TestWeb.Infra; +using TestWeb.Core.Users; + +namespace TestWeb.Tests.Core.Users; + +public class UserModuleTests : IClassFixture +{ + private readonly HttpClient client; + + public UserModuleTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateUserEndpoint.Request("Test"); + var createCall = await client.PostAsJsonAsync( + UserModule.Prefix, + createRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, createCall.StatusCode); + + var createResponse = await createCall.Content + .ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(createResponse); + + // Get + var getListCall = await client.GetAsync( + UserModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{UserModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateUserEndpoint.Request("Test"); + var updateCall = await client.PutAsJsonAsync( + $"{UserModule.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{UserModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{UserModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{UserModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/Core/Vehicles/VehicleModuleTests.cs b/tests/Core/Vehicles/VehicleModuleTests.cs new file mode 100644 index 0000000..e5cad8d --- /dev/null +++ b/tests/Core/Vehicles/VehicleModuleTests.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using TestWeb.Infra; +using TestWeb.Core.Vehicles; + +namespace TestWeb.Tests.Core.Vehicles; + +public class VehicleModuleTests : IClassFixture +{ + private readonly HttpClient client; + + public VehicleModuleTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateVehicleEndpoint.Request("Test make", "Test model"); + var createCall = await client.PostAsJsonAsync( + VehicleModule.Prefix, + createRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, createCall.StatusCode); + + var createResponse = await createCall.Content + .ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.NotNull(createResponse); + + // Get + var getListCall = await client.GetAsync( + VehicleModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{VehicleModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateVehicleEndpoint.Request("Test make update", "Test model update"); + var updateCall = await client.PutAsJsonAsync( + $"{VehicleModule.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{VehicleModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{VehicleModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{VehicleModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/Core/WeatherForecasts/WeatherForecastModuleTests.cs b/tests/Core/WeatherForecasts/WeatherForecastModuleTests.cs new file mode 100644 index 0000000..5902604 --- /dev/null +++ b/tests/Core/WeatherForecasts/WeatherForecastModuleTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using TestWeb.Infra; +using TestWeb.Core.WeatherForecasts; + +namespace TestWeb.Tests.Core.WeatherForecasts; + +public class WeatherForecastModuleTests : IClassFixture +{ + private readonly HttpClient client; + + public WeatherForecastModuleTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Get + var getCall = await client.GetAsync( + WeatherForecastModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + } +} diff --git a/tests/TestFixture.cs b/tests/TestFixture.cs new file mode 100644 index 0000000..18fa681 --- /dev/null +++ b/tests/TestFixture.cs @@ -0,0 +1,34 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using TestWeb.Infra; + +[assembly: AssemblyFixture(typeof(TestWeb.Tests.TestFixture))] + +namespace TestWeb.Tests; + +public class TestFixture : IAsyncLifetime +{ + public HttpClient Client { get; private set; } = null!; + private readonly WebApplicationFactory factory; + + public TestFixture() + { + factory = new WebApplicationFactory(); + } + + public ValueTask InitializeAsync() + { + Client = factory.CreateClient(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Client.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/TestWeb.Tests.csproj b/tests/TestWeb.Tests.csproj new file mode 100644 index 0000000..d3cb225 --- /dev/null +++ b/tests/TestWeb.Tests.csproj @@ -0,0 +1,27 @@ + + + net10.0 + Exe + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/xunit.runner.json b/tests/xunit.runner.json new file mode 100644 index 0000000..86c7ea0 --- /dev/null +++ b/tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +}