diff --git a/migrations/migration.sql b/migrations/migration.sql index 67a66e6..beeda51 100644 --- a/migrations/migration.sql +++ b/migrations/migration.sql @@ -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; diff --git a/src/WebApi/Cars/GetCar.cs b/src/WebApi/Cars/GetCar.cs index 14018a6..beb8432 100644 --- a/src/WebApi/Cars/GetCar.cs +++ b/src/WebApi/Cars/GetCar.cs @@ -13,12 +13,12 @@ public static class GetCar .WithTags("Cars"); } - public static async Task Endpoint( + private static async Task 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) { diff --git a/src/WebApi/Consumptions/CreateConsumption.cs b/src/WebApi/Consumptions/CreateConsumption.cs new file mode 100644 index 0000000..7d5def3 --- /dev/null +++ b/src/WebApi/Consumptions/CreateConsumption.cs @@ -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 + { + 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 Endpoint( + ApplicationDbContext dbContext, + Request request, + IEnumerable> validators, + CancellationToken cancellationToken) + { + List 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)); + } +} \ No newline at end of file diff --git a/src/WebApi/Consumptions/DeleteConsumptions.cs b/src/WebApi/Consumptions/DeleteConsumptions.cs new file mode 100644 index 0000000..aa93da5 --- /dev/null +++ b/src/WebApi/Consumptions/DeleteConsumptions.cs @@ -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 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(); + } +} \ No newline at end of file diff --git a/src/WebApi/Consumptions/GetConsumption.cs b/src/WebApi/Consumptions/GetConsumption.cs new file mode 100644 index 0000000..bcb9882 --- /dev/null +++ b/src/WebApi/Consumptions/GetConsumption.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/src/WebApi/Consumptions/GetConsumptions.cs b/src/WebApi/Consumptions/GetConsumptions.cs new file mode 100644 index 0000000..bc13cee --- /dev/null +++ b/src/WebApi/Consumptions/GetConsumptions.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/src/WebApi/Consumptions/UpdateConsumption.cs b/src/WebApi/Consumptions/UpdateConsumption.cs new file mode 100644 index 0000000..8d27bad --- /dev/null +++ b/src/WebApi/Consumptions/UpdateConsumption.cs @@ -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 + { + 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 Endpoint( + ApplicationDbContext dbContext, + Guid id, + Request request, + IEnumerable> validators, + CancellationToken cancellationToken) + { + List 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)); + } +} \ No newline at end of file diff --git a/src/WebApi/Endpoints/EndpointExtensions.cs b/src/WebApi/Endpoints/EndpointExtensions.cs index 28f7df1..1481541 100644 --- a/src/WebApi/Endpoints/EndpointExtensions.cs +++ b/src/WebApi/Endpoints/EndpointExtensions.cs @@ -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); } } diff --git a/src/WebApi/Persistence/ApplicationDbContext.cs b/src/WebApi/Persistence/ApplicationDbContext.cs index e02de1d..6c436c9 100644 --- a/src/WebApi/Persistence/ApplicationDbContext.cs +++ b/src/WebApi/Persistence/ApplicationDbContext.cs @@ -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 options public DbSet Users { get; set; } + public DbSet Consumptions { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs b/src/WebApi/Persistence/Migrations/20240818105918_Initial.Designer.cs similarity index 97% rename from src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs rename to src/WebApi/Persistence/Migrations/20240818105918_Initial.Designer.cs index 88ce60d..046955c 100644 --- a/src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs +++ b/src/WebApi/Persistence/Migrations/20240818105918_Initial.Designer.cs @@ -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 { /// @@ -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 => diff --git a/src/WebApi/Persistence/Migrations/20240817153531_Initial.cs b/src/WebApi/Persistence/Migrations/20240818105918_Initial.cs similarity index 91% rename from src/WebApi/Persistence/Migrations/20240817153531_Initial.cs rename to src/WebApi/Persistence/Migrations/20240818105918_Initial.cs index 57755c7..be2f349 100644 --- a/src/WebApi/Persistence/Migrations/20240817153531_Initial.cs +++ b/src/WebApi/Persistence/Migrations/20240818105918_Initial.cs @@ -42,7 +42,7 @@ namespace Vegasco.WebApi.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "Consumption", + name: "Consumptions", columns: table => new { Id = table.Column(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"); diff --git a/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 9e44e83..390dc72 100644 --- a/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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 => diff --git a/tests/WebApi.Tests.Integration/CarFaker.cs b/tests/WebApi.Tests.Integration/CarFaker.cs index 2d5bb5e..7364709 100644 --- a/tests/WebApi.Tests.Integration/CarFaker.cs +++ b/tests/WebApi.Tests.Integration/CarFaker.cs @@ -16,4 +16,4 @@ internal class CarFaker { return new UpdateCar.Request(_faker.Vehicle.Model()); } -} +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/ConsumptionFaker.cs b/tests/WebApi.Tests.Integration/ConsumptionFaker.cs new file mode 100644 index 0000000..d4e84a3 --- /dev/null +++ b/tests/WebApi.Tests.Integration/ConsumptionFaker.cs @@ -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); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/Consumptions/CreateConsumptionTests.cs b/tests/WebApi.Tests.Integration/Consumptions/CreateConsumptionTests.cs new file mode 100644 index 0000000..30887e9 --- /dev/null +++ b/tests/WebApi.Tests.Integration/Consumptions/CreateConsumptionTests.cs @@ -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(); + } + + [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(); + 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!.Errors.Keys.Should().Contain(x => + x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase)); + + _dbContext.Consumptions.Should().NotContainEquivalentOf(createConsumptionRequest, o => o.ExcludingMissingMembers()); + } + + private async Task 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(); + return createdCarResponse!; + } + + public Task InitializeAsync() + { + FluentAssertionConfiguration.SetupGlobalConfig(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _scope.Dispose(); + await _dbContext.DisposeAsync(); + await _factory.ResetDatabaseAsync(); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/Consumptions/DeleteConsumptionTests.cs b/tests/WebApi.Tests.Integration/Consumptions/DeleteConsumptionTests.cs new file mode 100644 index 0000000..af72f99 --- /dev/null +++ b/tests/WebApi.Tests.Integration/Consumptions/DeleteConsumptionTests.cs @@ -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(); + } + + [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 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(); + return createdConsumption!; + } + + private async Task 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(); + return createdCarResponse!; + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + _scope.Dispose(); + await _dbContext.DisposeAsync(); + await _factory.ResetDatabaseAsync(); + } +} diff --git a/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionTests.cs b/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionTests.cs new file mode 100644 index 0000000..2e490f9 --- /dev/null +++ b/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionTests.cs @@ -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(); + } + + [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(); + 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 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(); + return createdConsumption!; + } + + private async Task 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(); + return createdCarResponse!; + } + + public Task InitializeAsync() + { + FluentAssertionConfiguration.SetupGlobalConfig(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _scope.Dispose(); + await _dbContext.DisposeAsync(); + await _factory.ResetDatabaseAsync(); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionsTests.cs b/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionsTests.cs new file mode 100644 index 0000000..553879b --- /dev/null +++ b/tests/WebApi.Tests.Integration/Consumptions/GetConsumptionsTests.cs @@ -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(); + } + + [Fact] + public async Task GetConsumptions_ShouldReturnConsumptions_WhenConsumptionsExist() + { + // Arrange + List 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>(); + 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>(); + consumptions.Should().BeEmpty(); + } + + private async Task 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(); + return createdConsumption!; + } + + private async Task 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(); + return createdCarResponse!; + } + + public Task InitializeAsync() + { + FluentAssertionConfiguration.SetupGlobalConfig(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _scope.Dispose(); + await _dbContext.DisposeAsync(); + await _factory.ResetDatabaseAsync(); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/Consumptions/UpdateConsumptionTests.cs b/tests/WebApi.Tests.Integration/Consumptions/UpdateConsumptionTests.cs new file mode 100644 index 0000000..723f7eb --- /dev/null +++ b/tests/WebApi.Tests.Integration/Consumptions/UpdateConsumptionTests.cs @@ -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(); + } + + [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(); + 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!.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 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(); + return createdConsumption!; + } + + private async Task 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(); + return createdCarResponse!; + } + + public Task InitializeAsync() + { + FluentAssertionConfiguration.SetupGlobalConfig(); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _scope.Dispose(); + await _dbContext.DisposeAsync(); + await _factory.ResetDatabaseAsync(); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Integration/FluentAssertionConfiguration.cs b/tests/WebApi.Tests.Integration/FluentAssertionConfiguration.cs new file mode 100644 index 0000000..019e491 --- /dev/null +++ b/tests/WebApi.Tests.Integration/FluentAssertionConfiguration.cs @@ -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(ctx => ctx.Subject.ToUniversalTime().Should().BeCloseTo(ctx.Expectation.ToUniversalTime(), TimeSpan.FromMilliseconds(DateTimeComparisonPrecision))) + .WhenTypeIs()); + + AssertionOptions.AssertEquivalencyUsing(options => options + .Using(ctx => ctx.Subject.ToUniversalTime().Should().BeCloseTo(ctx.Expectation.ToUniversalTime(), TimeSpan.FromMilliseconds(DateTimeComparisonPrecision))) + .WhenTypeIs()); + } +} diff --git a/tests/WebApi.Tests.Unit/Consumptions/CreateConsumptionRequestValidatorTests.cs b/tests/WebApi.Tests.Unit/Consumptions/CreateConsumptionRequestValidatorTests.cs new file mode 100644 index 0000000..fa7f346 --- /dev/null +++ b/tests/WebApi.Tests.Unit/Consumptions/CreateConsumptionRequestValidatorTests.cs @@ -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(); + + 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)); + } +} \ No newline at end of file diff --git a/tests/WebApi.Tests.Unit/Consumptions/UpdateConsumptionRequestValidatorTests.cs b/tests/WebApi.Tests.Unit/Consumptions/UpdateConsumptionRequestValidatorTests.cs new file mode 100644 index 0000000..14a91e8 --- /dev/null +++ b/tests/WebApi.Tests.Unit/Consumptions/UpdateConsumptionRequestValidatorTests.cs @@ -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(); + + 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)); + } +} \ No newline at end of file