Initial commit
All checks were successful
Build / build (push) Successful in 57s

This commit is contained in:
2025-11-08 14:26:53 +00:00
commit 018d8e184e
26 changed files with 1251 additions and 0 deletions

51
.editorconfig Executable file
View File

@@ -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

2
.gitattributes vendored Executable file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

26
.gitea/workflows/build.yml Executable file
View File

@@ -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

6
.gitignore vendored Executable file
View File

@@ -0,0 +1,6 @@
.nuget/
bin/
obj/
app.db
app.db-shm
app.db-wal

8
README.md Executable file
View File

@@ -0,0 +1,8 @@
# test-web
Test web development.
test-aspnetcore:
```shell
dotnet run --project test-aspnetcore/src
```

View File

@@ -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

View File

@@ -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<TestFixture>
{
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<CreateJobEndpoint.Response>(
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);
}
}

View File

@@ -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<CreateJobEndpoint.Request>();
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<UpdateJobEndpoint.Request>();
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<Request>
{
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<Ok<Response>> 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<Ok<Job[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Jobs.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}
public static class GetJobEndpoint
{
public static async Task<Results<Ok<Job>, 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<Request>
{
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<Results<Ok, NotFound>> 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<Results<Ok, NotFound>> 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();
}
}

View File

@@ -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<TestFixture>
{
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<CreateUserEndpoint.Response>(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);
}
}

View File

@@ -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<CreateUserEndpoint.Request>();
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<UpdateUserEndpoint.Request>();
group.MapDelete("/{id}", DeleteUserEndpoint.Handle)
.WithSummary("Delete user by id");
}
}
public static class CreateUserEndpoint
{
public record Request(string UserName);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.UserName).NotEmpty().MaximumLength(250);
}
}
public record Response(int Id);
public static async Task<Ok<Response>> 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<Ok<User[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Users.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}
public static class GetUserEndpoint
{
public static async Task<Results<Ok<User>, 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<Request>
{
public RequestValidator()
{
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(250);
}
}
public static async Task<Results<Ok, NotFound>> 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<Results<Ok, NotFound>> 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();
}
}

View File

@@ -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<TestFixture>
{
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<CreateVehicleEndpoint.Response>(
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);
}
}

View File

@@ -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<CreateVehicleEndpoint.Request>();
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<UpdateVehicleEndpoint.Request>();
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<Request>
{
public RequestValidator()
{
RuleFor(x => x.Make).NotEmpty().MaximumLength(250);
}
}
public static async Task<Ok<Response>> 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<Ok<Vehicle[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Vehicles.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}
public static class GetVehicleEndpoint
{
public static async Task<Results<Ok<Vehicle>, 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<Request>
{
public RequestValidator()
{
RuleFor(x => x.Make).NotEmpty().MaximumLength(250);
}
}
public static async Task<Results<Ok, NotFound>> 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<Results<Ok, NotFound>> 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();
}
}

View File

@@ -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<TestFixture>
{
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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
namespace TestAspNetCore.Infra;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Job> Jobs
{
get; set;
}
public DbSet<User> Users
{
get; set;
}
public DbSet<Vehicle> Vehicles
{
get; set;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

98
test-aspnetcore/src/Program.cs Executable file
View File

@@ -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<AppDbContext>(
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<RequestLoggingFilter>();
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<AppDbContext>();
db.Database.EnsureCreated();
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<Compile Remove="**\*.Test.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
namespace TestAspNetCore.Utils;
public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
logger.LogInformation(
"HTTP {Method} {Path} recieved",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
return await next(context);
}
}

View File

@@ -0,0 +1,34 @@
namespace TestAspNetCore.Utils;
public class RequestValidationFilter<TRequest>(
ILogger<RequestValidationFilter<TRequest>> logger,
IValidator<TRequest>? validator = null) : IEndpointFilter
{
public async ValueTask<object?> 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<TRequest>().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);
}
}

View File

@@ -0,0 +1,12 @@
namespace TestAspNetCore.Utils;
public static class ValidationExtensions
{
public static RouteHandlerBuilder WithRequestValidation<TRequest>(
this RouteHandlerBuilder builder)
{
return builder
.AddEndpointFilter<RequestValidationFilter<TRequest>>()
.ProducesValidationProblem();
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit.v3" Version="2.0.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\src\TestAspNetCore.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\src\**\*.Test.cs" />
</ItemGroup>
</Project>

View File

@@ -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<Program> factory;
public TestFixture()
{
factory = new WebApplicationFactory<Program>();
}
public async ValueTask InitializeAsync()
{
Client = factory.CreateClient();
}
public ValueTask DisposeAsync()
{
Client.Dispose();
return ValueTask.CompletedTask;
}
}