commit 1199bef0c81e4008acce741f174dfaff6b13f493 Author: Chris Dill Date: Sat Jun 6 10:39:37 2026 +0100 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/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100755 index 0000000..f7878a1 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,29 @@ +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 project + run: dotnet build -c Release + + - name: Test project + run: dotnet test -c Release 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..cb4e26f --- /dev/null +++ b/src/Core/Jobs/CreateJobEndpoint.cs @@ -0,0 +1,40 @@ +namespace TestWeb.Core.Jobs; + +using Infra; + +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..883fa0b --- /dev/null +++ b/src/Core/Jobs/DeleteJobEndpoint.cs @@ -0,0 +1,20 @@ +namespace TestWeb.Core.Jobs; + +using Infra; + +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..3083a7b --- /dev/null +++ b/src/Core/Jobs/GetJobEndpoint.cs @@ -0,0 +1,20 @@ +namespace TestWeb.Core.Jobs; + +using Infra; + +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..33993f7 --- /dev/null +++ b/src/Core/Jobs/GetJobsEndpoint.cs @@ -0,0 +1,12 @@ +namespace TestWeb.Core.Jobs; + +using Infra; + +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/JobsModule.cs b/src/Core/Jobs/JobsModule.cs new file mode 100755 index 0000000..5b1a213 --- /dev/null +++ b/src/Core/Jobs/JobsModule.cs @@ -0,0 +1,26 @@ +namespace TestWeb.Core.Jobs; + +using Utils; + +public static class JobsModule +{ + 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..66c53da --- /dev/null +++ b/src/Core/Jobs/UpdateJobEndpoint.cs @@ -0,0 +1,47 @@ +namespace TestWeb.Core.Jobs; + +using Infra; + +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..4f00338 --- /dev/null +++ b/src/Core/Users/CreateUserEndpoint.cs @@ -0,0 +1,34 @@ +namespace TestWeb.Core.Users; + +using Infra; + +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..fac810c --- /dev/null +++ b/src/Core/Users/GetUserEndpoint.cs @@ -0,0 +1,20 @@ +namespace TestWeb.Core.Users; + +using Infra; + +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..86cb5b3 --- /dev/null +++ b/src/Core/Users/GetUsersEndpoint.cs @@ -0,0 +1,12 @@ +namespace TestWeb.Core.Users; + +using Infra; + +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..99c47b4 --- /dev/null +++ b/src/Core/Users/UpdateUserEndpoint.cs @@ -0,0 +1,39 @@ +namespace TestWeb.Core.Users; + +using Infra; + +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/UsersModule.cs b/src/Core/Users/UsersModule.cs new file mode 100755 index 0000000..19b539a --- /dev/null +++ b/src/Core/Users/UsersModule.cs @@ -0,0 +1,26 @@ +namespace TestWeb.Core.Users; + +using Utils; + +public static class UsersModule +{ + 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..2c01e01 --- /dev/null +++ b/src/Core/Vehicles/CreateVehicleEndpoint.cs @@ -0,0 +1,35 @@ +namespace TestWeb.Core.Vehicles; + +using Infra; + +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..de1235f --- /dev/null +++ b/src/Core/Vehicles/DeleteVehicleEndpoint.cs @@ -0,0 +1,20 @@ +namespace TestWeb.Core.Vehicles; + +using Infra; + +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..937e6e6 --- /dev/null +++ b/src/Core/Vehicles/GetVehicleEndpoint.cs @@ -0,0 +1,20 @@ +namespace TestWeb.Core.Vehicles; + +using Infra; + +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..d9419b3 --- /dev/null +++ b/src/Core/Vehicles/GetVehiclesEndpoint.cs @@ -0,0 +1,12 @@ +namespace TestWeb.Core.Vehicles; + +using Infra; + +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..485ecf0 --- /dev/null +++ b/src/Core/Vehicles/UpdateVehicleEndpoint.cs @@ -0,0 +1,41 @@ +namespace TestWeb.Core.Vehicles; + +using Infra; + +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/VehiclesModule.cs b/src/Core/Vehicles/VehiclesModule.cs new file mode 100755 index 0000000..8632afe --- /dev/null +++ b/src/Core/Vehicles/VehiclesModule.cs @@ -0,0 +1,26 @@ +namespace TestWeb.Core.Vehicles; + +using Utils; + +public static class VehiclesModule +{ + 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/WeatherForecastsModule.cs b/src/Core/WeatherForecasts/WeatherForecastsModule.cs new file mode 100755 index 0000000..336563f --- /dev/null +++ b/src/Core/WeatherForecasts/WeatherForecastsModule.cs @@ -0,0 +1,15 @@ +namespace TestWeb.Core.WeatherForecasts; + +public static class WeatherForecastsModule +{ + 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/Global.Usings.cs b/src/Global.Usings.cs new file mode 100644 index 0000000..0ccbddf --- /dev/null +++ b/src/Global.Usings.cs @@ -0,0 +1,4 @@ +global using FluentValidation; +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Http.HttpResults; +global using Microsoft.EntityFrameworkCore; diff --git a/src/Infra/AppDbContext.cs b/src/Infra/AppDbContext.cs new file mode 100755 index 0000000..0b5ca4b --- /dev/null +++ b/src/Infra/AppDbContext.cs @@ -0,0 +1,17 @@ +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..429bc65 --- /dev/null +++ b/src/Infra/Job.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +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..9204879 --- /dev/null +++ b/src/Infra/User.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +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..5b35004 --- /dev/null +++ b/src/Infra/Vehicle.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +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..3691e97 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,86 @@ +using Microsoft.OpenApi.Models; +using TestWeb.Core.Jobs; +using TestWeb.Core.Users; +using TestWeb.Core.Vehicles; +using TestWeb.Core.WeatherForecasts; +using TestWeb.Infra; +using TestWeb.Utils; + +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] = [] }] + }); + + JobsModule.MapEndpoints(endpoints); + UsersModule.MapEndpoints(endpoints); + VehiclesModule.MapEndpoints(endpoints); + WeatherForecastsModule.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/Global.Usings.cs b/tests/Global.Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Global.Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/JobsModuleTests.cs b/tests/JobsModuleTests.cs new file mode 100644 index 0000000..30ba74d --- /dev/null +++ b/tests/JobsModuleTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Http.Json; + +namespace TestWeb.Tests; + +using Core.Jobs; + +public class JobsModuleTests(TestFixture fixture) : IClassFixture +{ + private readonly HttpClient client = fixture.Client; + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateJobEndpoint.Request( + "Client name", "Pickup", "Dropoff" + ); + var createCall = await client.PostAsJsonAsync( + JobsModule.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( + JobsModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{JobsModule.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( + $"{JobsModule.Prefix}/{createResponse.Id}", updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{JobsModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{JobsModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{JobsModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/TestFixture.cs b/tests/TestFixture.cs new file mode 100644 index 0000000..e2ae13d --- /dev/null +++ b/tests/TestFixture.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +[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..5bca61c --- /dev/null +++ b/tests/TestWeb.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + Exe + enable + enable + + + + + + + + + + + + + + + + + diff --git a/tests/UsersModuleTests.cs b/tests/UsersModuleTests.cs new file mode 100644 index 0000000..753d8ac --- /dev/null +++ b/tests/UsersModuleTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; + +namespace TestWeb.Tests; + +using Core.Users; + +public class UserModuleTests(TestFixture fixture) : IClassFixture +{ + private readonly HttpClient client = fixture.Client; + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateUserEndpoint.Request("Test"); + var createCall = await client.PostAsJsonAsync( + UsersModule.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( + UsersModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{UsersModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + + // Update + var updateRequest = new UpdateUserEndpoint.Request("Test"); + var updateCall = await client.PutAsJsonAsync( + $"{UsersModule.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{UsersModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{UsersModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{UsersModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/VehiclesModuleTests.cs b/tests/VehiclesModuleTests.cs new file mode 100644 index 0000000..201e0f5 --- /dev/null +++ b/tests/VehiclesModuleTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; + +namespace TestWeb.Tests; + +using Core.Vehicles; + +public class VehiclesModuleTests(TestFixture fixture) : IClassFixture +{ + private readonly HttpClient client = fixture.Client; + + [Fact] + public async Task Base() + { + // Create + var createRequest = new CreateVehicleEndpoint.Request("Test make", "Test model"); + var createCall = await client.PostAsJsonAsync( + VehiclesModule.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( + VehiclesModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getListCall.StatusCode); + + var getCall = await client.GetAsync( + $"{VehiclesModule.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( + $"{VehiclesModule.Prefix}/{createResponse.Id}", + updateRequest, + cancellationToken: TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, updateCall.StatusCode); + + // Delete + var deleteCall = await client.DeleteAsync( + $"{VehiclesModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, deleteCall.StatusCode); + + // Not found + var deleteCallNotFound = await client.DeleteAsync( + $"{VehiclesModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, deleteCallNotFound.StatusCode); + + var getCallNotFound = await client.GetAsync( + $"{VehiclesModule.Prefix}/{createResponse.Id}", + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.NotFound, getCallNotFound.StatusCode); + } +} diff --git a/tests/WeatherForecastsModuleTests.cs b/tests/WeatherForecastsModuleTests.cs new file mode 100644 index 0000000..79f3e1b --- /dev/null +++ b/tests/WeatherForecastsModuleTests.cs @@ -0,0 +1,21 @@ +using System.Net; + +namespace TestWeb.Tests; + +using Core.WeatherForecasts; + +public class WeatherForecastsModuleTests(TestFixture fixture) : IClassFixture +{ + private readonly HttpClient client = fixture.Client; + + [Fact] + public async Task Base() + { + // Get + var getCall = await client.GetAsync( + WeatherForecastsModule.Prefix, + TestContext.Current.CancellationToken + ); + Assert.Equal(HttpStatusCode.OK, getCall.StatusCode); + } +} 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" +}