Add integration tests

This commit is contained in:
2024-08-17 16:38:40 +02:00
parent ff2da66a22
commit 19b105b0e8
14 changed files with 583 additions and 2 deletions

View File

@@ -0,0 +1,19 @@
using Bogus;
using Vegasco.WebApi.Cars;
namespace WebApi.Tests.Integration;
internal class CarFaker
{
private readonly Faker _faker = new();
internal CreateCar.Request CreateCarRequest()
{
return new CreateCar.Request(_faker.Vehicle.Model());
}
internal UpdateCar.Request UpdateCarRequest()
{
return new UpdateCar.Request(_faker.Vehicle.Model());
}
}

View File

@@ -0,0 +1,70 @@
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.Persistence;
namespace WebApi.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class CreateCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public CreateCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task CreateCar_ShouldCreateCar_WhenRequestIsValid()
{
// Arrange
var createCarRequest = _carFaker.CreateCarRequest();
// Act
var response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(createdCar);
}
[Fact]
public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{
// Arrange
var createCarRequest = new CreateCar.Request("");
// Act
var response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().NotContainEquivalentOf(createCarRequest, o => o.ExcludingMissingMembers());
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,64 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.WebApi.Cars;
using Vegasco.WebApi.Persistence;
namespace WebApi.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class DeleteCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public DeleteCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
var response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteCar_ShouldDeleteCar_WhenCarExists()
{
// Arrange
var createCarRequest = _carFaker.CreateCarRequest();
var createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
var response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
_dbContext.Cars.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,57 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.WebApi.Cars;
namespace WebApi.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
var response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetCar_ShouldReturnCar_WhenCarExists()
{
// Arrange
var createCarRequest = _carFaker.CreateCarRequest();
var createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
var response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
car.Should().BeEquivalentTo(createdCar);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,66 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.WebApi.Cars;
namespace WebApi.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarsTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarsTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCars_ShouldReturnEmptyList_WhenNoEntriesExist()
{
// Arrange
// Act
var response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var cars = await response.Content.ReadFromJsonAsync<IEnumerable<GetCars.Response>>();
cars.Should().BeEmpty();
}
[Fact]
public async Task GetCars_ShouldReturnEntries_WhenEntriesExist()
{
// Arrange
List<CreateCar.Response> createdCars = [];
const int numberOfCars = 5;
for (var i = 0; i < numberOfCars; i++)
{
var createCarRequest = _carFaker.CreateCarRequest();
var createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCars.Add(createdCar!);
}
// Act
var response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var cars = await response.Content.ReadFromJsonAsync<IEnumerable<GetCars.Response>>();
cars.Should().BeEquivalentTo(createdCars);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,99 @@
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.Persistence;
namespace WebApi.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class UpdateCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public UpdateCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task UpdateCar_ShouldUpdateCar_WhenCarExistsAndRequestIsValid()
{
// Arrange
var createCarRequest = _carFaker.CreateCarRequest();
var createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
var updateCarRequest = _carFaker.UpdateCarRequest();
// Act
var response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
updatedCar!.Id.Should().Be(createdCar.Id);
updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(updatedCar, o => o.ExcludingMissingMembers());
}
[Fact]
public async Task UpdateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{
// Arrange
var createCarRequest = _carFaker.CreateCarRequest();
var createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
var updateCarRequest = new UpdateCar.Request("");
// Act
var response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().ContainSingle(x => x.Id == createdCar.Id)
.Which
.Should().NotBeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
}
[Fact]
public async Task UpdateCar_ShouldReturnNotFound_WhenNoCarWithIdExists()
{
// Arrange
var updateCarRequest = _carFaker.UpdateCarRequest();
var randomCarId = Guid.NewGuid();
// Act
var response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
_dbContext.Cars.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -0,0 +1,40 @@
using Npgsql;
using Respawn;
using System.Data.Common;
namespace WebApi.Tests.Integration;
internal sealed class PostgresRespawner : IDisposable
{
private readonly DbConnection _connection;
private readonly Respawner _respawner;
private PostgresRespawner(Respawner respawner, DbConnection connection)
{
_respawner = respawner;
_connection = connection;
}
public static async Task<PostgresRespawner> CreateAsync(string connectionString)
{
DbConnection connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection,
new RespawnerOptions
{
SchemasToInclude = ["public"],
DbAdapter = DbAdapter.Postgres
});
return new PostgresRespawner(respawner, connection);
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_connection);
}
public void Dispose()
{
_connection.Dispose();
}
}

View File

@@ -0,0 +1,7 @@
namespace WebApi.Tests.Integration;
[CollectionDefinition(Name)]
public class SharedTestCollection : ICollectionFixture<WebAppFactory>
{
public const string Name = nameof(SharedTestCollection);
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace WebApi.Tests.Integration;
public sealed class TestUserAlwaysAuthorizedPolicyEvaluator : IPolicyEvaluator
{
public const string Username = "Test user";
public static readonly string UserId = Guid.NewGuid().ToString();
public Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context)
{
Claim[] claims =
[
new Claim(ClaimTypes.Name, Username),
new Claim("name", Username),
new Claim(ClaimTypes.NameIdentifier, UserId),
new Claim("aud", "https://localhost")
];
ClaimsIdentity identity = new(claims, JwtBearerDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new(identity);
AuthenticationTicket ticket = new(principal, JwtBearerDefaults.AuthenticationScheme);
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result); ;
}
public Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context,
object? resource)
{
return Task.FromResult(PolicyAuthorizationResult.Success());
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.9.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebApi\WebApi.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,79 @@
using DotNet.Testcontainers.Images;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Persistence;
namespace WebApi.Tests.Integration;
public sealed class WebAppFactory : WebApplicationFactory<IWebApiMarker>, IAsyncLifetime
{
private readonly PostgreSqlContainer _database = new PostgreSqlBuilder()
.WithImage(DockerImage)
.WithImagePullPolicy(PullPolicy.Always)
.Build();
private const string DockerImage = "postgres:16.3-alpine";
public HttpClient HttpClient => CreateClient();
private PostgresRespawner? _postgresRespawner;
public async Task InitializeAsync()
{
await _database.StartAsync();
// Force application startup (i.e. initialization and validation)
_ = CreateClient();
using var scope = Services.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync();
_postgresRespawner = await PostgresRespawner.CreateAsync(_database.GetConnectionString());
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
IEnumerable<KeyValuePair<string, string?>> customConfig =
[
new KeyValuePair<string, string?>("ConnectionStrings:Database", _database.GetConnectionString()),
new KeyValuePair<string, string?>("JWT:Authority", "https://localhost"),
new KeyValuePair<string, string?>("JWT:Audience", "https://localhost"),
new KeyValuePair<string, string?>("JWT:Issuer", "https://localhost"),
new KeyValuePair<string, string?>("JWT:NameClaimType", null),
];
builder.UseConfiguration(new ConfigurationBuilder()
.AddInMemoryCollection(customConfig)
.Build());
builder.ConfigureServices(services =>
{
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IPolicyEvaluator>();
services.AddSingleton<IPolicyEvaluator, TestUserAlwaysAuthorizedPolicyEvaluator>();
});
}
public async Task ResetDatabaseAsync()
{
await _postgresRespawner!.ResetDatabaseAsync();
}
async Task IAsyncLifetime.DisposeAsync()
{
_postgresRespawner!.Dispose();
await _database.DisposeAsync();
}
}