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 { }