Add editorconfig and analyzers, format backend code

This commit is contained in:
2026-09-24 04:54:17 +02:00
parent 87c3f97529
commit c8fe28b07b
367 changed files with 12179 additions and 12459 deletions
@@ -3,83 +3,82 @@ using Microsoft.AspNetCore.Http;
using Musify.Api.Authentication;
using Xunit;
namespace Musify.Api.Tests.Authentication
namespace Musify.Api.Tests.Authentication;
public sealed class CurrentUserTests
{
public sealed class CurrentUserTests
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
{
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
{
var context = new DefaultHttpContext { User = principal };
var bound = await CurrentUser.BindAsync(context, parameter: null!);
return bound!;
}
var context = new DefaultHttpContext { User = principal };
var bound = await CurrentUser.BindAsync(context, parameter: null!);
return bound!;
}
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
[Fact]
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
{
var principal = AuthenticatedPrincipal(
new Claim(ClaimTypes.NameIdentifier, "42"),
new Claim(ClaimTypes.Name, "jdoe"),
new Claim(ClaimTypes.GivenName, "Jane"),
new Claim(ClaimTypes.Surname, "Doe"));
[Fact]
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
{
var principal = AuthenticatedPrincipal(
new Claim(ClaimTypes.NameIdentifier, "42"),
new Claim(ClaimTypes.Name, "jdoe"),
new Claim(ClaimTypes.GivenName, "Jane"),
new Claim(ClaimTypes.Surname, "Doe"));
var user = await BindAsync(principal);
var user = await BindAsync(principal);
Assert.True(user.IsAuthenticated);
Assert.Equal(42, user.Id);
Assert.Equal(42, user.RequiredId);
Assert.Equal("jdoe", user.Username);
Assert.Equal("Jane", user.FirstName);
Assert.Equal("Doe", user.LastName);
}
Assert.True(user.IsAuthenticated);
Assert.Equal(42, user.Id);
Assert.Equal(42, user.RequiredId);
Assert.Equal("jdoe", user.Username);
Assert.Equal("Jane", user.FirstName);
Assert.Equal("Doe", user.LastName);
}
[Fact]
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
{
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
[Fact]
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
{
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.False(user.IsAuthenticated);
Assert.Null(user.Id);
}
Assert.False(user.IsAuthenticated);
Assert.Null(user.Id);
}
[Theory]
[InlineData("not-a-number")]
[InlineData("0")]
[InlineData("")]
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
[Theory]
[InlineData("not-a-number")]
[InlineData("0")]
[InlineData("")]
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
Assert.Null(user.Id);
}
Assert.Null(user.Id);
}
[Fact]
public async Task RequiredId_MissingId_Throws()
{
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
[Fact]
public async Task RequiredId_MissingId_Throws()
{
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
}
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
}
[Fact]
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
[Fact]
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
Assert.True(user.HasClaim("role"));
Assert.False(user.HasClaim("missing"));
}
Assert.True(user.HasClaim("role"));
Assert.False(user.HasClaim("missing"));
}
[Fact]
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
[Fact]
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
{
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
Assert.Null(user.FindClaim("missing"));
}
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
Assert.Null(user.FindClaim("missing"));
}
}
@@ -8,129 +8,128 @@ using Musify.Application.Contracts;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
private async Task<long> CreateUserAsync()
{
private async Task<long> CreateUserAsync()
{
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
response.EnsureSuccessStatusCode();
return userId;
}
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
response.EnsureSuccessStatusCode();
return userId;
}
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
fixture.StorageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var response = await client.PostAsJsonAsync(
"/albums/upload-picture",
new RequestAlbumPictureUploadRequest("webp", "image/webp"),
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var response = await client.PostAsJsonAsync(
"/albums/upload-picture",
new RequestAlbumPictureUploadRequest("webp", "image/webp"),
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AlbumPictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId;
}
var body = await response.Content.ReadFromJsonAsync<AlbumPictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId;
}
[Fact]
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
[Fact]
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId);
}
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId);
}
[Fact]
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
[Fact]
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
[Fact]
public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetAlbumById_AfterCreate_ReturnsIt()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
[Fact]
public async Task GetAlbumById_AfterCreate_ReturnsIt()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
{
var owner = await CreateUserAsync();
var stranger = await CreateUserAsync();
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
[Fact]
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
{
var owner = await CreateUserAsync();
var stranger = await CreateUserAsync();
var ownerClient = fixture.CreateAuthenticatedClient(owner);
var pictureIntentId = await CreatePictureIntentAsync(ownerClient);
var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
[Fact]
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
}
@@ -4,22 +4,21 @@ using Musify.Api.Tests.TestSupport;
using Musify.Application.Configuration.Responses;
using Xunit;
namespace Musify.Api.Tests.Endpoints
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
{
[Collection(ApiCollection.Name)]
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
[Fact]
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
{
[Fact]
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
{
var client = fixture.CreateAnonymousClient();
var client = fixture.CreateAnonymousClient();
var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken);
var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlaybackPublicConfigResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.False(body.AllowAnonymousListening);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlaybackPublicConfigResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.False(body.AllowAnonymousListening);
}
}
@@ -11,96 +11,95 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
Converters = { new JsonStringEnumConverter() }
};
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Converters = { new JsonStringEnumConverter() }
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
}
@@ -4,106 +4,105 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
using Musify.Api.Tests.TestSupport;
namespace Musify.Api.Tests.Endpoints
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
{
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
private async Task<long> CreateUserAsync()
{
private async Task<long> CreateUserAsync()
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return userId;
}
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
var track = new Track
{
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return userId;
}
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
Title = "Progress track",
NormalizedTitle = "PROGRESS TRACK",
OwnerUserId = owner.Id,
Owner = owner,
DurationSeconds = durationSeconds
};
var listen = new ListeningHistory
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
var track = new Track
{
Title = "Progress track",
NormalizedTitle = "PROGRESS TRACK",
OwnerUserId = owner.Id,
Owner = owner,
DurationSeconds = durationSeconds
};
var listen = new ListeningHistory
{
UserId = userId,
TrackId = track.Id,
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
};
database.Tracks.Add(track);
database.ListeningHistories.Add(listen);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return listen.Id;
}
UserId = userId,
TrackId = track.Id,
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
};
database.Tracks.Add(track);
database.ListeningHistories.Add(listen);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return listen.Id;
}
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
return await database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
}
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
return await database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
}
[Fact]
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
[Fact]
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
[Fact]
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var stored = await LoadListenAsync(listenId);
Assert.Equal(45, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var stored = await LoadListenAsync(listenId);
Assert.Equal(45, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
{
var ownerId = await CreateUserAsync();
var otherId = await CreateUserAsync();
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
[Fact]
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
{
var ownerId = await CreateUserAsync();
var otherId = await CreateUserAsync();
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
}
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
}
[Fact]
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
[Fact]
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
@@ -12,125 +12,124 @@ using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class UserEndpointsTests(ApiTestFixture fixture)
{
[Collection(ApiCollection.Name)]
public sealed class UserEndpointsTests(ApiTestFixture fixture)
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
Converters = { new JsonStringEnumConverter() }
};
private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId);
var track = new Track
{
Converters = { new JsonStringEnumConverter() }
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner
};
private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt)
database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId);
var track = new Track
{
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner
};
database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory
{
UserId = userId,
TrackId = track.Id,
ListenedAt = listenedAt
});
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
}
UserId = userId,
TrackId = track.Id,
ListenedAt = listenedAt
});
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
[Fact]
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("Jane Doe", body.Name);
}
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("Jane Doe", body.Name);
}
[Fact]
public async Task PostUsers_BlankName_ReturnsValidationProblem()
{
var client = fixture.CreateAnonymousClient();
[Fact]
public async Task PostUsers_BlankName_ReturnsValidationProblem()
{
var client = fixture.CreateAnonymousClient();
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostUsers_DuplicateId_ReturnsConflict()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
[Fact]
public async Task PostUsers_DuplicateId_ReturnsConflict()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task GetUserById_ExistingUser_ReturnsIt()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
[Fact]
public async Task GetUserById_ExistingUser_ReturnsIt()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.Equal("Findable User", body?.Name);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.Equal("Findable User", body?.Name);
}
[Fact]
public async Task GetUserById_MissingUser_ReturnsNotFound()
{
var client = fixture.CreateAnonymousClient();
[Fact]
public async Task GetUserById_MissingUser_ReturnsNotFound()
{
var client = fixture.CreateAnonymousClient();
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
[Fact]
public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TrackApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal("Newer listen", body?.Title);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TrackApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal("Newer listen", body?.Title);
}
[Fact]
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
[Fact]
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
@@ -4,133 +4,132 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Extensions;
using Xunit;
namespace Musify.Api.Tests.Extensions
namespace Musify.Api.Tests.Extensions;
public sealed class ErrorOrHttpExtensionsTests
{
public sealed class ErrorOrHttpExtensionsTests
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
{
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
var context = new DefaultHttpContext { RequestServices = Services };
var bodyStream = new MemoryStream();
context.Response.Body = bodyStream;
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
await result.ExecuteAsync(context);
bodyStream.Position = 0;
using var reader = new StreamReader(bodyStream);
return (context.Response.StatusCode, await reader.ReadToEndAsync());
}
[Fact]
public async Task ToHttpResult_Success_ReturnsNoContent()
{
ErrorOr<Success> result = new Success();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
}
[Fact]
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
{
ErrorOr<string> result = "hello";
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status200OK, statusCode);
Assert.Contains("hello", body);
}
[Fact]
public async Task ToHttpResult_NotFoundError_ReturnsNotFound()
{
ErrorOr<string> result = Error.NotFound(description: "not here");
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
}
[Fact]
public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized()
{
ErrorOr<string> result = Error.Unauthorized();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
}
[Fact]
public async Task ToHttpResult_ForbiddenError_ReturnsForbidden()
{
ErrorOr<string> result = Error.Forbidden();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status403Forbidden, statusCode);
}
[Fact]
public async Task ToHttpResult_ConflictError_ReturnsConflict()
{
ErrorOr<string> result = Error.Conflict();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
}
[Fact]
public async Task ToHttpResult_FailureError_ReturnsProblem500()
{
ErrorOr<string> result = Error.Failure();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status500InternalServerError, statusCode);
}
[Fact]
public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem()
{
ErrorOr<string> result = new List<Error>
{
var context = new DefaultHttpContext { RequestServices = Services };
var bodyStream = new MemoryStream();
context.Response.Body = bodyStream;
Error.Validation("Name", "Name is required"),
Error.Validation("Name", "Name is too long"),
Error.Validation("Age", "Age must be positive"),
};
await result.ExecuteAsync(context);
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
bodyStream.Position = 0;
using var reader = new StreamReader(bodyStream);
return (context.Response.StatusCode, await reader.ReadToEndAsync());
}
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
Assert.Contains("Name is required", body);
Assert.Contains("Age must be positive", body);
}
[Fact]
public async Task ToHttpResult_Success_ReturnsNoContent()
{
ErrorOr<Success> result = new Success();
[Fact]
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
{
ErrorOr<string> result = "the-id";
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
}
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
Assert.Equal("/things/the-id", context.Response.Headers.Location);
}
[Fact]
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
{
ErrorOr<string> result = "hello";
[Fact]
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
{
ErrorOr<string> result = Error.Conflict();
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
Assert.Equal(StatusCodes.Status200OK, statusCode);
Assert.Contains("hello", body);
}
[Fact]
public async Task ToHttpResult_NotFoundError_ReturnsNotFound()
{
ErrorOr<string> result = Error.NotFound(description: "not here");
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
}
[Fact]
public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized()
{
ErrorOr<string> result = Error.Unauthorized();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
}
[Fact]
public async Task ToHttpResult_ForbiddenError_ReturnsForbidden()
{
ErrorOr<string> result = Error.Forbidden();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status403Forbidden, statusCode);
}
[Fact]
public async Task ToHttpResult_ConflictError_ReturnsConflict()
{
ErrorOr<string> result = Error.Conflict();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
}
[Fact]
public async Task ToHttpResult_FailureError_ReturnsProblem500()
{
ErrorOr<string> result = Error.Failure();
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status500InternalServerError, statusCode);
}
[Fact]
public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem()
{
ErrorOr<string> result = new List<Error>
{
Error.Validation("Name", "Name is required"),
Error.Validation("Name", "Name is too long"),
Error.Validation("Age", "Age must be positive"),
};
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
Assert.Contains("Name is required", body);
Assert.Contains("Age must be positive", body);
}
[Fact]
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
{
ErrorOr<string> result = "the-id";
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
Assert.Equal("/things/the-id", context.Response.Headers.Location);
}
[Fact]
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
{
ErrorOr<string> result = Error.Conflict();
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
}
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
}
}
@@ -6,57 +6,56 @@ using Musify.Api.Filters;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Filters
namespace Musify.Api.Tests.Filters;
public sealed class ValidationFilterTests
{
public sealed class ValidationFilterTests
public sealed record Payload(string Name);
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
[Fact]
public async Task InvokeAsync_ValidPayload_CallsNext()
{
public sealed record Payload(string Name);
var validator = Substitute.For<IValidator<Payload>>();
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()).Returns(new ValidationResult());
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(new Payload("ok"));
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
[Fact]
public async Task InvokeAsync_ValidPayload_CallsNext()
{
var validator = Substitute.For<IValidator<Payload>>();
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()).Returns(new ValidationResult());
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(new Payload("ok"));
Assert.Equal("next-called", result);
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
}
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
[Fact]
public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
{
var validator = Substitute.For<IValidator<Payload>>();
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")]));
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(new Payload(""));
var nextCalled = false;
Assert.Equal("next-called", result);
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
}
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
[Fact]
public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
{
var validator = Substitute.For<IValidator<Payload>>();
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>())
.Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")]));
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(new Payload(""));
var nextCalled = false;
Assert.False(nextCalled);
var problem = Assert.IsType<ProblemHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
}
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
[Fact]
public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating()
{
var validator = Substitute.For<IValidator<Payload>>();
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(42);
Assert.False(nextCalled);
var problem = Assert.IsType<ProblemHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
}
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
[Fact]
public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating()
{
var validator = Substitute.For<IValidator<Payload>>();
var filter = new ValidationFilter<Payload>(validator);
var context = CreateContext(42);
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
Assert.Equal("next-called", result);
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
}
Assert.Equal("next-called", result);
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
}
}
@@ -11,92 +11,91 @@ using NSubstitute;
using Testcontainers.PostgreSql;
using Xunit;
namespace Musify.Api.Tests.TestSupport
namespace Musify.Api.Tests.TestSupport;
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_api_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async ValueTask InitializeAsync()
{
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_api_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
await postgres.StartAsync();
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async ValueTask InitializeAsync()
{
await postgres.StartAsync();
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await postgres.DisposeAsync();
}
public HttpClient CreateAnonymousClient() => CreateClient();
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
return client;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:ConnectionString"] = postgres.GetConnectionString(),
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
["Authentication:IssuerAddress"] = "https://auth.musify.test",
["Authentication:AudienceAddress"] = "musify-api-test",
["Authentication:ClientId"] = "musify-api-test",
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
["Authentication:Scopes:0"] = "openid",
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
["MassTransit:Address"] = "rabbitmq://localhost:5672",
["MassTransit:Username"] = "guest",
["MassTransit:Password"] = "guest",
["InfrastructureStorage:Address"] = "http://localhost:8333",
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
["InfrastructureStorage:ForcePathStyle"] = "true",
["InfrastructureStorage:UseHttp"] = "true",
["ApplicationStorage:Bucket"] = "musify-api-test",
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
});
});
builder.ConfigureTestServices(services =>
{
services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
services
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
});
}
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
public override async ValueTask DisposeAsync()
{
public const string Name = "Api";
await base.DisposeAsync();
await postgres.DisposeAsync();
}
public HttpClient CreateAnonymousClient() => CreateClient();
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
return client;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:ConnectionString"] = postgres.GetConnectionString(),
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
["Authentication:IssuerAddress"] = "https://auth.musify.test",
["Authentication:AudienceAddress"] = "musify-api-test",
["Authentication:ClientId"] = "musify-api-test",
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
["Authentication:Scopes:0"] = "openid",
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
["MassTransit:Address"] = "rabbitmq://localhost:5672",
["MassTransit:Username"] = "guest",
["MassTransit:Password"] = "guest",
["InfrastructureStorage:Address"] = "http://localhost:8333",
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
["InfrastructureStorage:ForcePathStyle"] = "true",
["InfrastructureStorage:UseHttp"] = "true",
["ApplicationStorage:Bucket"] = "musify-api-test",
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
});
});
builder.ConfigureTestServices(services =>
{
services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
services
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
});
}
}
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
{
public const string Name = "Api";
}
@@ -4,29 +4,28 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Musify.Api.Tests.TestSupport
namespace Musify.Api.Tests.TestSupport;
public sealed class FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public sealed class FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
public const string SchemeName = "Test";
public const string UserIdHeader = "X-Test-User-Id";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
public const string SchemeName = "Test";
public const string UserIdHeader = "X-Test-User-Id";
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
claims.Add(new Claim(ClaimTypes.Name, userName!));
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
claims.Add(new Claim(ClaimTypes.Name, userName!));
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -2,84 +2,83 @@ using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Validators.Albums;
using Xunit;
namespace Musify.Api.Tests.Validators
namespace Musify.Api.Tests.Validators;
public sealed class CreateAlbumRequestValidatorTests
{
public sealed class CreateAlbumRequestValidatorTests
private readonly CreateAlbumRequestValidator validator = new();
[Fact]
public void Validate_ValidRequest_Passes()
{
private readonly CreateAlbumRequestValidator validator = new();
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid()));
[Fact]
public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ValidRequestWithoutOptionalFields_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid()));
[Fact]
public void Validate_ValidRequestWithoutOptionalFields_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_BlankTitle_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid()));
[Fact]
public void Validate_BlankTitle_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid()));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
}
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
}
[Fact]
public void Validate_TitleOverMaxLength_Fails()
{
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid()));
[Fact]
public void Validate_TitleOverMaxLength_Fails()
{
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid()));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid()));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid()));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_EmptyPictureIntentId_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty));
[Fact]
public void Validate_EmptyPictureIntentId_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId));
}
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId));
}
}
@@ -2,51 +2,50 @@ using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Validators.Users;
using Xunit;
namespace Musify.Api.Tests.Validators
namespace Musify.Api.Tests.Validators;
public sealed class CreateUserRequestValidatorTests
{
public sealed class CreateUserRequestValidatorTests
private readonly CreateUserRequestValidator validator = new();
[Fact]
public void Validate_ValidRequest_Passes()
{
private readonly CreateUserRequestValidator validator = new();
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
[Fact]
public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ZeroId_Fails()
{
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
[Fact]
public void Validate_ZeroId_Fails()
{
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
}
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
}
[Fact]
public void Validate_BlankName_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
[Fact]
public void Validate_BlankName_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_NameOverMaxLength_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
[Fact]
public void Validate_NameOverMaxLength_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_OptionalNamesOmitted_Passes()
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
[Fact]
public void Validate_OptionalNamesOmitted_Passes()
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
}