commit 018d8e184eb371d7a80a3503ac82bf15bd9d9b20 Author: Chris Dill Date: Sat Nov 8 14:26:53 2025 +0000 Initial commit 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/.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/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100755 index 0000000..e2774c1 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,26 @@ +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@v5 + + - name: Install dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Build project + run: dotnet test test-aspnetcore -c Release 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..ea532ba --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# test-web + +Test web development. + +test-aspnetcore: +```shell +dotnet run --project test-aspnetcore/src +``` diff --git a/test-aspnetcore/TestAspNetCore.sln b/test-aspnetcore/TestAspNetCore.sln new file mode 100644 index 0000000..cc6933e --- /dev/null +++ b/test-aspnetcore/TestAspNetCore.sln @@ -0,0 +1,55 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestAspNetCore", "src\TestAspNetCore.csproj", "{8CF6AB2D-B0D9-438E-A232-249A76751D52}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestAspNetCore.Tests", "tests\TestAspNetCore.Tests.csproj", "{CC8317AE-E119-4E16-88E2-467F3ECA21A6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|x64.Build.0 = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Debug|x86.Build.0 = Debug|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|Any CPU.Build.0 = Release|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|x64.ActiveCfg = Release|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|x64.Build.0 = Release|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|x86.ActiveCfg = Release|Any CPU + {8CF6AB2D-B0D9-438E-A232-249A76751D52}.Release|x86.Build.0 = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|x64.Build.0 = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Debug|x86.Build.0 = Debug|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|Any CPU.Build.0 = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|x64.ActiveCfg = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|x64.Build.0 = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|x86.ActiveCfg = Release|Any CPU + {CC8317AE-E119-4E16-88E2-467F3ECA21A6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8CF6AB2D-B0D9-438E-A232-249A76751D52} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CC8317AE-E119-4E16-88E2-467F3ECA21A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/test-aspnetcore/src/Core/Jobs/JobApi.Test.cs b/test-aspnetcore/src/Core/Jobs/JobApi.Test.cs new file mode 100644 index 0000000..bafbd49 --- /dev/null +++ b/test-aspnetcore/src/Core/Jobs/JobApi.Test.cs @@ -0,0 +1,82 @@ +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 TestAspNetCore.Infra; + +namespace TestAspNetCore.Core.Jobs; + +public class JobApiTests : IClassFixture +{ + private readonly HttpClient client; + + public JobApiTests(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( + JobApi.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( + JobApi.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{JobApi.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( + $"{JobApi.Prefix}/{createResponse.Id}", updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{JobApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{JobApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{JobApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/test-aspnetcore/src/Core/Jobs/JobApi.cs b/test-aspnetcore/src/Core/Jobs/JobApi.cs new file mode 100755 index 0000000..fe9bf7a --- /dev/null +++ b/test-aspnetcore/src/Core/Jobs/JobApi.cs @@ -0,0 +1,150 @@ +using TestAspNetCore.Infra; +using TestAspNetCore.Utils; + +namespace TestAspNetCore.Core.Jobs; + +public static class JobApi +{ + 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.MapGet("/", GetJobListEndpoint.Handle) + .WithSummary("Get job list"); + group.MapGet("/{id}", GetJobEndpoint.Handle) + .WithSummary("Get job by id"); + group.MapPut("/{id}", UpdateJobEndpoint.Handle) + .WithSummary("Update job by id") + .WithRequestValidation(); + group.MapDelete("/{id}", DeleteJobEndpoint.Handle) + .WithSummary("Delete job by id"); + } +} + +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); + } +} + +public static class GetJobListEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Jobs.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} + +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); + } +} + +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.LastUpdatedAtUtc = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} + +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/test-aspnetcore/src/Core/Users/UserApi.Test.cs b/test-aspnetcore/src/Core/Users/UserApi.Test.cs new file mode 100644 index 0000000..8f1ed77 --- /dev/null +++ b/test-aspnetcore/src/Core/Users/UserApi.Test.cs @@ -0,0 +1,78 @@ +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 TestAspNetCore.Infra; + +namespace TestAspNetCore.Core.Users; + +public class UserApiTests : IClassFixture +{ + private readonly HttpClient client; + + public UserApiTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateUserEndpoint.Request("Test"); + var createCall = await client.PostAsJsonAsync( + UserApi.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( + UserApi.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{UserApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateUserEndpoint.Request("Test"); + var updateCall = await client.PutAsJsonAsync( + $"{UserApi.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{UserApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{UserApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{UserApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/test-aspnetcore/src/Core/Users/UserApi.cs b/test-aspnetcore/src/Core/Users/UserApi.cs new file mode 100755 index 0000000..4881945 --- /dev/null +++ b/test-aspnetcore/src/Core/Users/UserApi.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Options; +using TestAspNetCore.Infra; +using TestAspNetCore.Utils; + +namespace TestAspNetCore.Core.Users; + +public static class UserApi +{ + 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.MapGet("/", GetUserListEndpoint.Handle) + .WithSummary("Get user list"); + group.MapGet("/{id}", GetUserEndpoint.Handle) + .WithSummary("Get user by id"); + group.MapPut("/{id}", UpdateUserEndpoint.Handle) + .WithSummary("Update user by id") + .WithRequestValidation(); + group.MapDelete("/{id}", DeleteUserEndpoint.Handle) + .WithSummary("Delete user by id"); + } +} + +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); + } +} + +public static class GetUserListEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Users.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} + +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); + } +} + +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; + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} + +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/test-aspnetcore/src/Core/Vehicles/VehicleApi.Test.cs b/test-aspnetcore/src/Core/Vehicles/VehicleApi.Test.cs new file mode 100644 index 0000000..f646550 --- /dev/null +++ b/test-aspnetcore/src/Core/Vehicles/VehicleApi.Test.cs @@ -0,0 +1,80 @@ +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 TestAspNetCore.Infra; + +namespace TestAspNetCore.Core.Vehicles; + +public class VehicleApiTests : IClassFixture +{ + private readonly HttpClient client; + + public VehicleApiTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateVehicleEndpoint.Request("Test"); + var createCall = await client.PostAsJsonAsync( + VehicleApi.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( + VehicleApi.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{VehicleApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateVehicleEndpoint.Request("Test"); + var updateCall = await client.PutAsJsonAsync( + $"{VehicleApi.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{VehicleApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{VehicleApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{VehicleApi.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/test-aspnetcore/src/Core/Vehicles/VehicleApi.cs b/test-aspnetcore/src/Core/Vehicles/VehicleApi.cs new file mode 100755 index 0000000..55bf9a2 --- /dev/null +++ b/test-aspnetcore/src/Core/Vehicles/VehicleApi.cs @@ -0,0 +1,136 @@ +using TestAspNetCore.Infra; +using TestAspNetCore.Utils; + +namespace TestAspNetCore.Core.Vehicles; + +public static class VehicleApi +{ + 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.MapGet("/", GetVehicleListEndpoint.Handle) + .WithSummary("Get vehicle list"); + group.MapGet("/{id}", GetVehicleEndpoint.Handle) + .WithSummary("Get vehicle by id"); + group.MapPut("/{id}", UpdateVehicleEndpoint.Handle) + .WithSummary("Update vehicle by id") + .WithRequestValidation(); + group.MapDelete("/{id}", DeleteVehicleEndpoint.Handle) + .WithSummary("Delete vehicle by id"); + } +} + +public static class CreateVehicleEndpoint +{ + public record Request(string Make); + + public record Response(int Id); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.Make).NotEmpty().MaximumLength(250); + } + } + + public static async Task> Handle(Request request, AppDbContext db, CancellationToken ct) + { + var row = new Vehicle + { + Make = request.Make, + 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); + } +} + +public static class GetVehicleListEndpoint +{ + public static async Task> Handle(AppDbContext db, CancellationToken ct) + { + var results = await db.Vehicles.ToArrayAsync(ct); + return TypedResults.Ok(results); + } +} + +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); + } +} + +public static class UpdateVehicleEndpoint +{ + public record Request(string Make); + + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.Make).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.LastUpdatedAtUtc = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + return TypedResults.Ok(); + } +} + +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/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.Test.cs b/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.Test.cs new file mode 100644 index 0000000..5b08204 --- /dev/null +++ b/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.Test.cs @@ -0,0 +1,30 @@ +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 TestAspNetCore.Infra; + +namespace TestAspNetCore.Core.WeatherForecasts; + +public class WeatherForecastApiTests : IClassFixture +{ + private readonly HttpClient client; + + public WeatherForecastApiTests(TestFixture fixture) + { + client = fixture.Client; + } + + [Fact] + public async Task Base() + { + // Get + var getCall = await client.GetAsync( + WeatherForecastApi.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + } +} diff --git a/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.cs b/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.cs new file mode 100755 index 0000000..c6662a1 --- /dev/null +++ b/test-aspnetcore/src/Core/WeatherForecasts/WeatherForecastApi.cs @@ -0,0 +1,39 @@ +namespace TestAspNetCore.Core.WeatherForecasts; + +public static class WeatherForecastApi +{ + public const string Prefix = "/api/weatherforecasts"; + + public static void MapEndpoints(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/weatherforecasts") + .WithTags("WeatherForecasts") + .AllowAnonymous(); + group.MapGet("/", GetWeatherForecastListEndpoint.Handle) + .WithSummary("Get weather forecast list"); + } +} + +public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} + +public static class GetWeatherForecastListEndpoint +{ + 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/test-aspnetcore/src/Infra/AppDbContext.cs b/test-aspnetcore/src/Infra/AppDbContext.cs new file mode 100755 index 0000000..8a03d88 --- /dev/null +++ b/test-aspnetcore/src/Infra/AppDbContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace TestAspNetCore.Infra; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Jobs + { + get; set; + } + public DbSet Users + { + get; set; + } + public DbSet Vehicles + { + get; set; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + } +} diff --git a/test-aspnetcore/src/Infra/Job.cs b/test-aspnetcore/src/Infra/Job.cs new file mode 100755 index 0000000..b573b4b --- /dev/null +++ b/test-aspnetcore/src/Infra/Job.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestAspNetCore.Infra; + +public class Job +{ + [Key] + public int Id + { + get; set; + } + public required string ClientName + { + get; set; + } + public required string Pickup + { + get; set; + } + public string? Dropoff + { + get; set; + } + public int VehicleId + { + get; set; + } + public DateTime CreatedAtUtc { get; private init; } = DateTime.UtcNow; + public DateTime? LastUpdatedAtUtc + { + get; set; + } +} diff --git a/test-aspnetcore/src/Infra/User.cs b/test-aspnetcore/src/Infra/User.cs new file mode 100755 index 0000000..50ad2b5 --- /dev/null +++ b/test-aspnetcore/src/Infra/User.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestAspNetCore.Infra; + +public class User +{ + [Key] + public int Id + { + get; private init; + } + public required string Username + { + get; set; + } + public required string Password + { + get; set; + } + public required string DisplayName + { + get; set; + } + public DateTime CreatedAtUtc { get; private init; } = DateTime.UtcNow; +} diff --git a/test-aspnetcore/src/Infra/Vehicle.cs b/test-aspnetcore/src/Infra/Vehicle.cs new file mode 100755 index 0000000..a0f2e4c --- /dev/null +++ b/test-aspnetcore/src/Infra/Vehicle.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace TestAspNetCore.Infra; + +public class Vehicle +{ + [Key] + public int Id + { + get; set; + } + public required string Make + { + get; set; + } + public required string Model + { + get; set; + } + public required int Year + { + get; set; + } + public DateTime CreatedAtUtc { get; private init; } = DateTime.UtcNow; + public DateTime? LastUpdatedAtUtc + { + get; set; + } +} diff --git a/test-aspnetcore/src/Program.cs b/test-aspnetcore/src/Program.cs new file mode 100755 index 0000000..2e022b3 --- /dev/null +++ b/test-aspnetcore/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 TestAspNetCore.Infra; +using TestAspNetCore.Utils; +using TestAspNetCore.Core.Jobs; +using TestAspNetCore.Core.Users; +using TestAspNetCore.Core.Vehicles; +using TestAspNetCore.Core.WeatherForecasts; + +namespace TestAspNetCore; + +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 apis = app.MapGroup("") + .AddEndpointFilter(); + + var securityScheme = new OpenApiSecurityScheme() + { + Type = SecuritySchemeType.Http, + Name = JwtBearerDefaults.AuthenticationScheme, + Scheme = JwtBearerDefaults.AuthenticationScheme, + Reference = new() + { + Type = ReferenceType.SecurityScheme, + Id = JwtBearerDefaults.AuthenticationScheme + } + }; + apis.WithOpenApi(x => new() + { + Security = [new() { [securityScheme] = [] }] + }); + + JobApi.MapEndpoints(apis); + UserApi.MapEndpoints(apis); + VehicleApi.MapEndpoints(apis); + WeatherForecastApi.MapEndpoints(apis); + + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } +} diff --git a/test-aspnetcore/src/TestAspNetCore.csproj b/test-aspnetcore/src/TestAspNetCore.csproj new file mode 100755 index 0000000..82b904f --- /dev/null +++ b/test-aspnetcore/src/TestAspNetCore.csproj @@ -0,0 +1,20 @@ + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/test-aspnetcore/src/Utils/RequestLoggingFilter.cs b/test-aspnetcore/src/Utils/RequestLoggingFilter.cs new file mode 100755 index 0000000..ccab47c --- /dev/null +++ b/test-aspnetcore/src/Utils/RequestLoggingFilter.cs @@ -0,0 +1,15 @@ +namespace TestAspNetCore.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/test-aspnetcore/src/Utils/RequestValidationFilter.cs b/test-aspnetcore/src/Utils/RequestValidationFilter.cs new file mode 100755 index 0000000..a576981 --- /dev/null +++ b/test-aspnetcore/src/Utils/RequestValidationFilter.cs @@ -0,0 +1,34 @@ +namespace TestAspNetCore.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/test-aspnetcore/src/Utils/ValidationExtensions.cs b/test-aspnetcore/src/Utils/ValidationExtensions.cs new file mode 100755 index 0000000..3eb8bff --- /dev/null +++ b/test-aspnetcore/src/Utils/ValidationExtensions.cs @@ -0,0 +1,12 @@ +namespace TestAspNetCore.Utils; + +public static class ValidationExtensions +{ + public static RouteHandlerBuilder WithRequestValidation( + this RouteHandlerBuilder builder) + { + return builder + .AddEndpointFilter>() + .ProducesValidationProblem(); + } +} diff --git a/test-aspnetcore/src/appsettings.json b/test-aspnetcore/src/appsettings.json new file mode 100755 index 0000000..e784156 --- /dev/null +++ b/test-aspnetcore/src/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System": "Information", + "Microsoft": "Information" + }, + "Console": { + "IncludeScopes": true + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Server=.;Database=TestAspNetCore;Trusted_Connection=True;Encrypt=False;" + }, + "Jwt": { + "Key": "superdupersecretkeythatsreallyreallylong" + } +} diff --git a/test-aspnetcore/tests/TestAspNetCore.Tests.csproj b/test-aspnetcore/tests/TestAspNetCore.Tests.csproj new file mode 100644 index 0000000..9ff8eab --- /dev/null +++ b/test-aspnetcore/tests/TestAspNetCore.Tests.csproj @@ -0,0 +1,27 @@ + + + Exe + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/test-aspnetcore/tests/TestFixture.cs b/test-aspnetcore/tests/TestFixture.cs new file mode 100644 index 0000000..e36311e --- /dev/null +++ b/test-aspnetcore/tests/TestFixture.cs @@ -0,0 +1,33 @@ +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 TestAspNetCore.Infra; + +[assembly: AssemblyFixture(typeof(TestAspNetCore.TestFixture))] + +namespace TestAspNetCore; + +public class TestFixture : IAsyncLifetime +{ + public HttpClient Client { get; private set; } = null!; + private readonly WebApplicationFactory factory; + + public TestFixture() + { + factory = new WebApplicationFactory(); + } + + public async ValueTask InitializeAsync() + { + Client = factory.CreateClient(); + } + + public ValueTask DisposeAsync() + { + Client.Dispose(); + return ValueTask.CompletedTask; + } +}