New Angular based web version #1

Closed
thomas.nuyken wants to merge 150 commits from main into ddd
22 changed files with 979 additions and 23 deletions
Showing only changes of commit 2463c11be3 - Show all commits

View File

@@ -9,7 +9,7 @@ START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Users" (
"Id" text NOT NULL,
CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
@@ -19,7 +19,7 @@ END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Cars" (
"Id" uuid NOT NULL,
"Name" character varying(50) NOT NULL,
@@ -32,39 +32,39 @@ END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE TABLE "Consumption" (
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE TABLE "Consumptions" (
"Id" uuid NOT NULL,
"DateTime" timestamp with time zone NOT NULL,
"Distance" double precision NOT NULL,
"Amount" double precision NOT NULL,
"IgnoreInCalculation" boolean NOT NULL,
"CarId" uuid NOT NULL,
CONSTRAINT "PK_Consumption" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Consumption_Cars_CarId" FOREIGN KEY ("CarId") REFERENCES "Cars" ("Id") ON DELETE CASCADE
CONSTRAINT "PK_Consumptions" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Consumptions_Cars_CarId" FOREIGN KEY ("CarId") REFERENCES "Cars" ("Id") ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE INDEX "IX_Cars_UserId" ON "Cars" ("UserId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE INDEX "IX_Consumption_CarId" ON "Consumption" ("CarId");
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
CREATE INDEX "IX_Consumptions_CarId" ON "Consumptions" ("CarId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240817153531_Initial', '8.0.8');
VALUES ('20240818105918_Initial', '8.0.8');
END IF;
END $EF$;
COMMIT;

View File

@@ -13,12 +13,12 @@ public static class GetCar
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
private static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Consumptions;
public static class GetConsumptions
{
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("consumptions", Endpoint)
.WithTags("Consumptions");
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var consumptions = await dbContext.Consumptions
.Select(x => new Response(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
.ToListAsync(cancellationToken);
return TypedResults.Ok(consumptions);
}
}

View File

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

View File

@@ -3,6 +3,7 @@ using Asp.Versioning.Conventions;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Vegasco.WebApi.Cars;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Consumptions;
namespace Vegasco.WebApi.Endpoints;
@@ -39,5 +40,11 @@ public static class EndpointExtensions
CreateCar.MapEndpoint(versionedApis);
UpdateCar.MapEndpoint(versionedApis);
DeleteCar.MapEndpoint(versionedApis);
GetConsumptions.MapEndpoint(versionedApis);
GetConsumption.MapEndpoint(versionedApis);
CreateConsumption.MapEndpoint(versionedApis);
UpdateConsumption.MapEndpoint(versionedApis);
DeleteConsumption.MapEndpoint(versionedApis);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.WebApi.Cars;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Consumptions;
using Vegasco.WebApi.Users;
namespace Vegasco.WebApi.Persistence;
@@ -11,6 +12,8 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
public DbSet<User> Users { get; set; }
public DbSet<Consumption> Consumptions { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

View File

@@ -12,7 +12,7 @@ using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240817153531_Initial")]
[Migration("20240818105918_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -70,7 +70,7 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.HasIndex("CarId");
b.ToTable("Consumption");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>

View File

@@ -42,7 +42,7 @@ namespace Vegasco.WebApi.Persistence.Migrations
});
migrationBuilder.CreateTable(
name: "Consumption",
name: "Consumptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
@@ -54,9 +54,9 @@ namespace Vegasco.WebApi.Persistence.Migrations
},
constraints: table =>
{
table.PrimaryKey("PK_Consumption", x => x.Id);
table.PrimaryKey("PK_Consumptions", x => x.Id);
table.ForeignKey(
name: "FK_Consumption_Cars_CarId",
name: "FK_Consumptions_Cars_CarId",
column: x => x.CarId,
principalTable: "Cars",
principalColumn: "Id",
@@ -69,8 +69,8 @@ namespace Vegasco.WebApi.Persistence.Migrations
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Consumption_CarId",
table: "Consumption",
name: "IX_Consumptions_CarId",
table: "Consumptions",
column: "CarId");
}
@@ -78,7 +78,7 @@ namespace Vegasco.WebApi.Persistence.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Consumption");
name: "Consumptions");
migrationBuilder.DropTable(
name: "Cars");

View File

@@ -67,7 +67,7 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.HasIndex("CarId");
b.ToTable("Consumption");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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