diff --git a/Musify.WebApi.slnx b/Musify.WebApi.slnx index c239259..5a8e7b9 100644 --- a/Musify.WebApi.slnx +++ b/Musify.WebApi.slnx @@ -1,3 +1,6 @@ + + + diff --git a/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs b/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs new file mode 100644 index 0000000..7aa73d9 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Extensions/ResultHttpExtensionsTests.cs @@ -0,0 +1,122 @@ +using Ardalis.Result; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using WebApi.Extensions; +using Xunit; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Musify.WebApi.Tests.Extensions; + +public sealed class ResultHttpExtensionsTests +{ + private static int StatusCodeOf(IResult result) => + ((IStatusCodeHttpResult)result).StatusCode ?? 0; + + [Fact] + public void Result_Ok_maps_to_204_no_content() + { + StatusCodeOf(Result.Success().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); + } + + [Fact] + public void Result_NoContent_maps_to_204() + { + StatusCodeOf(Result.NoContent().ToHttpResult()).Should().Be(StatusCodes.Status204NoContent); + } + + [Fact] + public void Result_NotFound_maps_to_404() + { + StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void Result_Invalid_maps_to_validation_problem_400() + { + var result = Result.Invalid(new ValidationError("field", "bad")).ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status400BadRequest); + } + + [Fact] + public void Result_Unauthorized_maps_to_401() + { + StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public void Result_Forbidden_maps_to_403() + { + StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public void Result_Conflict_maps_to_409() + { + StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public void Result_Error_maps_to_problem_500() + { + var result = Result.Error("boom").ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); + } + + [Fact] + public void ResultT_Ok_maps_to_200_with_value() + { + var result = Result.Success("payload").ToHttpResult(); + result.Should().BeOfType>(); + result.As>().Value.Should().Be("payload"); + } + + [Fact] + public void ResultT_NotFound_maps_to_404() + { + StatusCodeOf(Result.NotFound("nope").ToHttpResult()).Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void ResultT_Invalid_maps_to_validation_problem_with_grouped_errors() + { + var result = Result.Invalid( + new ValidationError("Name", "required"), + new ValidationError("Name", "too short"), + new ValidationError("Age", "invalid")).ToHttpResult(); + + result.Should().BeOfType(); + var details = result.As().ProblemDetails.Should().BeOfType().Subject; + details.Errors.Should().ContainKey("Name"); + details.Errors["Name"].Should().BeEquivalentTo("required", "too short"); + details.Errors.Should().ContainKey("Age"); + } + + [Fact] + public void ResultT_Unauthorized_maps_to_401() + { + StatusCodeOf(Result.Unauthorized().ToHttpResult()).Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public void ResultT_Forbidden_maps_to_403() + { + StatusCodeOf(Result.Forbidden().ToHttpResult()).Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public void ResultT_Conflict_maps_to_409() + { + StatusCodeOf(Result.Conflict("dup").ToHttpResult()).Should().Be(StatusCodes.Status409Conflict); + } + + [Fact] + public void ResultT_Error_maps_to_problem_500() + { + var result = Result.Error("boom").ToHttpResult(); + result.Should().BeOfType(); + StatusCodeOf(result).Should().Be(StatusCodes.Status500InternalServerError); + } +} diff --git a/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs new file mode 100644 index 0000000..bfac0f0 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Integration/ApiIntegrationTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Users.Responses; +using NSubstitute; +using WebApi.DataTransferObjects.PlayLists; +using WebApi.DataTransferObjects.Tracks; +using WebApi.DataTransferObjects.Users; +using Xunit; + +namespace Musify.WebApi.Tests.Integration; + +public sealed class ApiIntegrationTests(MusifyApiFactory factory) + : IClassFixture +{ + private sealed record ValidationProblemResponse(Dictionary Errors); + + private sealed record TrackUploadUrlsLikeResponse(string PictureUploadUrl, string AudioUploadUrl); + + private readonly HttpClient _client = factory.CreateClient(); + + private async Task CreateUserAsync(string name = "IntegrationUser") + { + var id = Guid.NewGuid(); + var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, name, null, null)); + response.StatusCode.Should().Be(HttpStatusCode.Created); + return id; + } + + [Fact] + public async Task Create_then_get_user_roundtrips_through_the_real_pipeline() + { + var id = Guid.NewGuid(); + + var create = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Alice", "Al", "Ice")); + create.StatusCode.Should().Be(HttpStatusCode.Created); + + var get = await _client.GetAsync($"/users/{id}"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + var user = await get.Content.ReadFromJsonAsync(); + user!.Id.Should().Be(id); + user.Name.Should().Be("Alice"); + } + + [Fact] + public async Task Create_user_with_invalid_body_returns_400_validation_problem() + { + var response = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(Guid.Empty, "", null, null)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var problem = await response.Content.ReadFromJsonAsync(); + problem!.Errors.Should().ContainKey(nameof(CreateUserRequest.Name)); + } + + [Fact] + public async Task Create_duplicate_user_returns_409_conflict() + { + var id = await CreateUserAsync(); + + var duplicate = await _client.PostAsJsonAsync("/users/", new CreateUserRequest(id, "Other", null, null)); + + duplicate.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Get_unknown_user_returns_404() + { + var response = await _client.GetAsync($"/users/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Create_playlist_without_picture_returns_201_and_persists() + { + var userId = await CreateUserAsync(); + + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{userId}", + new CreatePlayListRequest("My Mix", "A description", null)); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var playList = await response.Content.ReadFromJsonAsync(); + playList!.Name.Should().Be("My Mix"); + + var get = await _client.GetAsync($"/playLists/{playList.Id}"); + get.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Create_playlist_for_unknown_user_returns_404() + { + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{Guid.NewGuid()}", + new CreatePlayListRequest("Mix", "desc", null)); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Create_playlist_with_invalid_body_returns_400() + { + var userId = await CreateUserAsync(); + + var response = await _client.PostAsJsonAsync( + $"/playLists/users/{userId}", + new CreatePlayListRequest("", "", null)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Get_unknown_track_returns_404() + { + var response = await _client.GetAsync($"/tracks/{Guid.NewGuid()}"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Request_track_upload_urls_issues_presigned_urls() + { + var userId = await CreateUserAsync(); + factory.Storage + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://signed.example/put"); + + var response = await _client.PostAsJsonAsync( + $"/tracks/upload-urls/users/{userId}", + new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + body!.PictureUploadUrl.Should().Be("https://signed.example/put"); + body.AudioUploadUrl.Should().Be("https://signed.example/put"); + } +} diff --git a/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs new file mode 100644 index 0000000..6085f16 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Integration/MusifyApiFactory.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Musify.Application.Contracts; +using Musify.Application.Tests.Infrastructure; +using NSubstitute; + +namespace Musify.WebApi.Tests.Integration; + +public sealed class MusifyApiFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:;Foreign Keys=False"); + + public IStorageService Storage { get; } = Substitute.For(); + public IEventBus EventBus { get; } = Substitute.For(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = "Host=localhost;Database=test;Username=test;Password=test", + ["ApplicationStorage:Bucket"] = TestData.Bucket, + ["PlayList:Routes:ParentFolders"] = "PlayLists", + ["Track:Routes:ParentFolder"] = "Tracks", + ["UploadIntent:TempRootPrefix"] = "temp", + ["InfrastructureStorage:Address"] = "http://localhost:9000", + ["InfrastructureStorage:AccessKey"] = "test", + ["InfrastructureStorage:SecretAccessKey"] = "test", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["MassTransit:Address"] = "rabbitmq://localhost", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + } ); + } ); + + builder.ConfigureTestServices(services => + { + // Never start the RabbitMQ bus, EF outbox delivery or OTLP exporter in tests. + services.RemoveAll(); + + _connection.Open(); + + // Replace the Npgsql-backed IDatabase with a shared SQLite TestDatabase. + services.RemoveAll(); + services.AddScoped(_ => + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + var db = new TestDatabase(options); + db.Database.EnsureCreated(); + return db; + }); + + // Replace S3 and MassTransit with controllable test doubles. + services.RemoveAll(); + services.AddScoped(_ => Storage); + + services.RemoveAll(); + services.AddScoped(_ => EventBus); + } ); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + _connection.Dispose(); + } +} diff --git a/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj new file mode 100644 index 0000000..8b5774e --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Musify.WebApi.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + Musify.WebApi.Tests + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs new file mode 100644 index 0000000..dfca788 --- /dev/null +++ b/Tests/Musify.WebApi.Tests/Validators/ValidatorTests.cs @@ -0,0 +1,215 @@ +using FluentValidation.TestHelper; +using WebApi.DataTransferObjects.PlayLists; +using WebApi.DataTransferObjects.Tracks; +using WebApi.DataTransferObjects.Users; +using WebApi.Validators.PlayLists; +using WebApi.Validators.Tracks; +using WebApi.Validators.Users; +using Xunit; + +namespace Musify.WebApi.Tests.Validators; + +public sealed class CreateTrackRequestValidatorTests +{ + private readonly CreateTrackRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Empty_title_fails(string? title) + { + var model = new CreateTrackRequest(title!, Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Title_longer_than_50_fails() + { + var model = new CreateTrackRequest(new string('a', 51), Guid.NewGuid(), Guid.NewGuid()); + _validator.TestValidate(model).ShouldHaveValidationErrorFor(x => x.Title); + } + + [Fact] + public void Empty_intent_ids_fail() + { + var model = new CreateTrackRequest("Song", Guid.Empty, Guid.Empty); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.PictureIntentId); + result.ShouldHaveValidationErrorFor(x => x.AudioIntentId); + } +} + +public sealed class RequestTrackUploadUrlsRequestValidatorTests +{ + private readonly RequestTrackUploadUrlsRequestValidator _validator = new(); + + private static RequestTrackUploadUrlsRequest Valid() => + new("png", "image/png", "mp3", "audio/mpeg"); + + [Fact] + public void Valid_request_passes() + { + _validator.TestValidate(Valid()).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Missing_required_fields_fail() + { + var model = new RequestTrackUploadUrlsRequest("", "", "", ""); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.PictureFileType); + result.ShouldHaveValidationErrorFor(x => x.PictureContentType); + result.ShouldHaveValidationErrorFor(x => x.AudioFileType); + result.ShouldHaveValidationErrorFor(x => x.AudioContentType); + } + + [Fact] + public void Non_positive_expected_sizes_fail_when_provided() + { + var model = Valid() with { ExpectedPictureSizeBytes = 0, ExpectedAudioSizeBytes = -1 }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); + result.ShouldHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); + } + + [Fact] + public void Null_expected_sizes_are_allowed() + { + var model = Valid() with { ExpectedPictureSizeBytes = null, ExpectedAudioSizeBytes = null }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveValidationErrorFor(x => x.ExpectedPictureSizeBytes); + result.ShouldNotHaveValidationErrorFor(x => x.ExpectedAudioSizeBytes); + } +} + +public sealed class CreatePlayListRequestValidatorTests +{ + private readonly CreatePlayListRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreatePlayListRequest("My List", "A description", null); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_name_and_description_fail() + { + var model = new CreatePlayListRequest("", "", null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + result.ShouldHaveValidationErrorFor(x => x.Description); + } + + [Fact] + public void Over_length_name_and_description_fail() + { + var model = new CreatePlayListRequest(new string('n', 51), new string('d', 257), null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Name); + result.ShouldHaveValidationErrorFor(x => x.Description); + } +} + +public sealed class UpdatePlayListRequestValidatorTests +{ + private readonly UpdatePlayListRequestValidator _validator = new(); + + [Fact] + public void All_null_is_valid() + { + _validator.TestValidate(new UpdatePlayListRequest(null, null, null)) + .ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Over_length_values_fail_only_when_provided() + { + var model = new UpdatePlayListRequest(new string('n', 51), new string('d', 257), null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.NewName); + result.ShouldHaveValidationErrorFor(x => x.NewDescription); + } + + [Fact] + public void Empty_string_name_is_allowed_by_length_rule() + { + // Only a MaximumLength rule applies, so an empty (non-null) string passes. + var model = new UpdatePlayListRequest("", null, null); + _validator.TestValidate(model).ShouldNotHaveValidationErrorFor(x => x.NewName); + } +} + +public sealed class RequestPlayListPictureUploadRequestValidatorTests +{ + private readonly RequestPlayListPictureUploadRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp")) + .ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Missing_filetype_and_contenttype_fail() + { + var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("", "")); + result.ShouldHaveValidationErrorFor(x => x.FileType); + result.ShouldHaveValidationErrorFor(x => x.ContentType); + } + + [Fact] + public void Non_positive_size_fails_when_provided() + { + var result = _validator.TestValidate(new RequestPlayListPictureUploadRequest("webp", "image/webp", 0)); + result.ShouldHaveValidationErrorFor(x => x.ExpectedSizeBytes); + } +} + +public sealed class CreateUserRequestValidatorTests +{ + private readonly CreateUserRequestValidator _validator = new(); + + [Fact] + public void Valid_request_passes() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", "Al", "Ice"); + _validator.TestValidate(model).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Empty_id_and_name_fail() + { + var model = new CreateUserRequest(Guid.Empty, "", null, null); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.Id); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Over_length_optional_names_fail_only_when_provided() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", new string('f', 49), new string('s', 49)); + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(x => x.FirstName); + result.ShouldHaveValidationErrorFor(x => x.SecondName); + } + + [Fact] + public void Null_optional_names_are_allowed() + { + var model = new CreateUserRequest(Guid.NewGuid(), "Alice", null, null); + var result = _validator.TestValidate(model); + result.ShouldNotHaveValidationErrorFor(x => x.FirstName); + result.ShouldNotHaveValidationErrorFor(x => x.SecondName); + } +} diff --git a/WebApi/Program.cs b/WebApi/Program.cs index eef071d..03be246 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -23,3 +23,6 @@ app .MapUserEndpoints(); app.Run(); + +// Exposed so WebApplicationFactory can bootstrap the app in integration tests. +public partial class Program { }