Change editor rule

This commit is contained in:
2026-09-24 13:24:43 +02:00
parent 16478678b5
commit 73e37dbecd
401 changed files with 14735 additions and 12995 deletions
@@ -3,64 +3,65 @@ using Microsoft.AspNetCore.Http;
using Musify.Api.Authentication;
using Xunit;
namespace Musify.Api.Tests.Authentication;
public sealed class CurrentUserTests
namespace Musify.Api.Tests.Authentication
{
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
public sealed class CurrentUserTests
{
var context = new DefaultHttpContext { User = principal };
var bound = await CurrentUser.BindAsync(context, parameter: null!);
return bound!;
}
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
{
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);
}
}
}
@@ -9,121 +9,122 @@ using Musify.Application.Pictures.Responses;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), 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 Task<long> CreateUserAsync() => fixture.SeedUserAsync();
var response = await client.PostAsJsonAsync(
"/albums/upload-picture",
new PictureUploadRequest("webp", "image/webp"),
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
{
fixture.StorageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), 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 body = await response.Content.ReadFromJsonAsync<PictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId;
}
var response = await client.PostAsJsonAsync(
"/albums/upload-picture",
new PictureUploadRequest("webp", "image/webp"),
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
[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);
var body = await response.Content.ReadFromJsonAsync<PictureUploadResponse>(TestContext.Current.CancellationToken);
return body!.IntentId;
}
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[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_AuthenticatedExistingUser_ReturnsCreatedAlbum()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var pictureIntentId = await CreatePictureIntentAsync(client);
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);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
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);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken);
[Fact]
public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem()
{
var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), 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);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", 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);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
[Fact]
public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden()
{
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);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
[Fact]
public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden()
{
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);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", 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);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", 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);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
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);
}
}
}
@@ -4,21 +4,22 @@ using Musify.Api.Tests.TestSupport;
using Musify.Application.Configuration.Responses;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
[Fact]
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
[Collection(ApiCollection.Name)]
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
{
var client = fixture.CreateAnonymousClient();
[Fact]
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
{
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);
}
}
}
@@ -12,94 +12,95 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
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
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
Converters = { new JsonStringEnumConverter() }
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
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);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
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);
}
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", 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<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<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);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", 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.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.BadRequest, response.StatusCode);
}
[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);
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Title == title);
}
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Title == title);
}
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags));
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<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags));
}
}
}
@@ -8,93 +8,94 @@ using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
{
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
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
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;
}
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
{
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;
}
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);
}
}
}
@@ -9,148 +9,149 @@ using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class PlayListEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
[Collection(ApiCollection.Name)]
public sealed class PlayListEndpointsTests(ApiTestFixture fixture)
{
Converters = { new JsonStringEnumConverter() }
};
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private async Task<PlayListApplicationResponse> CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null)
{
var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync(
"/playlists",
new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility),
Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<PlayListApplicationResponse>(JsonOptions, Ct))!;
}
private async Task<PlayListApplicationResponse> CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null)
{
var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync(
"/playlists",
new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility),
Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return (await response.Content.ReadFromJsonAsync<PlayListApplicationResponse>(JsonOptions, Ct))!;
}
[Fact]
public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
[Fact]
public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct);
var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct);
var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct);
var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct);
var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode);
}
Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode);
}
[Fact]
public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
[Fact]
public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
[Fact]
public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct);
Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
}
Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode);
}
[Fact]
public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
[Fact]
public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner);
var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner);
var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner);
var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner);
Assert.Equal([publicPlayList.Id], asStranger);
Assert.Contains(publicPlayList.Id, asOwner);
Assert.Contains(privatePlayList.Id, asOwner);
}
Assert.Equal([publicPlayList.Id], asStranger);
Assert.Contains(publicPlayList.Id, asOwner);
Assert.Contains(privatePlayList.Id, asOwner);
}
[Fact]
public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
[Fact]
public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct);
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task DeletePlayList_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
[Fact]
public async Task DeletePlayList_NotTheOwner_ReturnsForbidden()
{
var owner = await fixture.SeedUserAsync();
var stranger = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task UpdatePlayList_Owner_RenamesIt()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
[Fact]
public async Task UpdatePlayList_Owner_RenamesIt()
{
var owner = await fixture.SeedUserAsync();
var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private);
var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct);
var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync(
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayListApplicationResponse>(JsonOptions, Ct);
Assert.Equal("Renamed", body!.Name);
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayListApplicationResponse>(JsonOptions, Ct);
Assert.Equal("Renamed", body!.Name);
}
[Fact]
public async Task GetPlayListById_NotAGuid_ReturnsNotFound()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct);
[Fact]
public async Task GetPlayListById_NotAGuid_ReturnsNotFound()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem()
{
var owner = await fixture.SeedUserAsync();
[Fact]
public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem()
{
var owner = await fixture.SeedUserAsync();
var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync(
"/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct);
var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync(
"/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static async Task<IReadOnlyList<Guid>> ReadPlayListIdsAsync(HttpClient client, long userId)
{
var response = await client.GetAsync($"/playlists/users/{userId}", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PaginatedResponse<PlayListApplicationResponse>>(JsonOptions, Ct);
return page!.Items.Select(playList => playList.Id).ToList();
private static async Task<IReadOnlyList<Guid>> ReadPlayListIdsAsync(HttpClient client, long userId)
{
var response = await client.GetAsync($"/playlists/users/{userId}", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PaginatedResponse<PlayListApplicationResponse>>(JsonOptions, Ct);
return page!.Items.Select(playList => playList.Id).ToList();
}
}
}
@@ -11,131 +11,132 @@ using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints;
[Collection(ApiCollection.Name)]
public sealed class UserEndpointsTests(ApiTestFixture fixture)
namespace Musify.Api.Tests.Endpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
[Collection(ApiCollection.Name)]
public sealed class UserEndpointsTests(ApiTestFixture fixture)
{
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
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner
Converters = { new JsonStringEnumConverter() }
};
database.Tracks.Add(track);
database.ListeningHistories.Add(new ListeningHistory
private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt)
{
UserId = userId,
TrackId = track.Id,
ListenedAt = listenedAt,
IsCounted = true
});
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
}
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,
IsCounted = true
});
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
}
[Fact]
public async Task PostUsers_IsNotExposed()
{
var client = fixture.CreateAnonymousClient();
[Fact]
public async Task PostUsers_IsNotExposed()
{
var client = fixture.CreateAnonymousClient();
var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken);
Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
}
Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
}
[Fact]
public async Task GetUserById_ExistingUser_ReturnsIt()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await fixture.SeedUserAsync(userId, "Findable User");
[Fact]
public async Task GetUserById_ExistingUser_ReturnsIt()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await fixture.SeedUserAsync(userId, "Findable User");
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 fixture.SeedUserAsync(userId);
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 fixture.SeedUserAsync(userId);
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_ReturnsNoContent()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await fixture.SeedUserAsync(userId);
[Fact]
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent()
{
var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue);
await fixture.SeedUserAsync(userId);
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.NoContent, response.StatusCode);
}
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Theory]
[InlineData("pageSize=0")]
[InlineData("pageSize=101")]
[InlineData("pageNumber=0")]
public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query)
{
var client = fixture.CreateAnonymousClient();
[Theory]
[InlineData("pageSize=0")]
[InlineData("pageSize=101")]
[InlineData("pageNumber=0")]
public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query)
{
var client = fixture.CreateAnonymousClient();
var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetListeningStats_AnotherUser_ReturnsForbidden()
{
var userId = await fixture.SeedUserAsync();
var other = await fixture.SeedUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
[Fact]
public async Task GetListeningStats_AnotherUser_ReturnsForbidden()
{
var userId = await fixture.SeedUserAsync();
var other = await fixture.SeedUserAsync();
var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task GetListeningStats_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken);
[Fact]
public async Task GetListeningStats_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
}
@@ -4,132 +4,133 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Extensions;
using Xunit;
namespace Musify.Api.Tests.Extensions;
public sealed class ErrorOrHttpExtensionsTests
namespace Musify.Api.Tests.Extensions
{
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
public sealed class ErrorOrHttpExtensionsTests
{
var context = new DefaultHttpContext { RequestServices = Services };
var bodyStream = new MemoryStream();
context.Response.Body = bodyStream;
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
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>
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
{
Error.Validation("Name", "Name is required"),
Error.Validation("Name", "Name is too long"),
Error.Validation("Age", "Age must be positive"),
};
var context = new DefaultHttpContext { RequestServices = Services };
var bodyStream = new MemoryStream();
context.Response.Body = bodyStream;
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
await result.ExecuteAsync(context);
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
Assert.Contains("Name is required", body);
Assert.Contains("Age must be positive", body);
}
bodyStream.Position = 0;
using var reader = new StreamReader(bodyStream);
return (context.Response.StatusCode, await reader.ReadToEndAsync());
}
[Fact]
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
{
ErrorOr<string> result = "the-id";
[Fact]
public async Task ToHttpResult_Success_ReturnsNoContent()
{
ErrorOr<Success> result = new Success();
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
Assert.Equal("/things/the-id", context.Response.Headers.Location);
}
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
}
[Fact]
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
{
ErrorOr<string> result = Error.Conflict();
[Fact]
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
{
ErrorOr<string> result = "hello";
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
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);
}
}
}
@@ -6,56 +6,57 @@ using Musify.Api.Filters;
using NSubstitute;
using Xunit;
namespace Musify.Api.Tests.Filters;
public sealed class ValidationFilterTests
namespace Musify.Api.Tests.Filters
{
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 class ValidationFilterTests
{
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"));
public sealed record Payload(string Name);
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
Assert.Equal("next-called", result);
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
}
[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"));
[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;
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
Assert.Equal("next-called", result);
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
}
Assert.False(nextCalled);
var problem = Assert.IsType<ProblemHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
}
[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;
[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, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
Assert.False(nextCalled);
var problem = Assert.IsType<ProblemHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
}
Assert.Equal("next-called", result);
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
[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>());
}
}
}
@@ -12,108 +12,109 @@ using NSubstitute;
using Testcontainers.PostgreSql;
using Xunit;
namespace Musify.Api.Tests.TestSupport;
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
namespace Musify.Api.Tests.TestSupport
{
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()
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
await postgres.StartAsync();
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_api_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await postgres.DisposeAsync();
}
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
public HttpClient CreateAnonymousClient() => CreateClient();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async Task<long> SeedUserAsync(long? userId = null, string? name = null)
{
var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue);
var userName = name ?? $"user-{id}";
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
database.Users.Add(new Musify.Domain.Entities.User
public async ValueTask InitializeAsync()
{
Id = id,
Name = userName,
NormalizedName = userName.ToUpperInvariant()
});
await database.SaveChangesAsync();
return id;
}
await postgres.StartAsync();
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture));
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
return client;
}
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
public override async ValueTask DisposeAsync()
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
await base.DisposeAsync();
await postgres.DisposeAsync();
}
public HttpClient CreateAnonymousClient() => CreateClient();
public async Task<long> SeedUserAsync(long? userId = null, string? name = null)
{
var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue);
var userName = name ?? $"user-{id}";
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
database.Users.Add(new Musify.Domain.Entities.User
{
["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",
Id = id,
Name = userName,
NormalizedName = userName.ToUpperInvariant()
});
});
await database.SaveChangesAsync();
return id;
}
builder.ConfigureTestServices(services =>
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
{
services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
var client = CreateClient();
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture));
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
return client;
}
services
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
});
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";
}
}
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
{
public const string Name = "Api";
}
@@ -4,28 +4,29 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Musify.Api.Tests.TestSupport;
public sealed class FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
namespace Musify.Api.Tests.TestSupport
{
public const string SchemeName = "Test";
public const string UserIdHeader = "X-Test-User-Id";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
public sealed class FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
public const string SchemeName = "Test";
public const string UserIdHeader = "X-Test-User-Id";
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!));
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
var identity = new ClaimsIdentity(claims, SchemeName);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
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));
}
}
}
@@ -3,83 +3,84 @@ using Musify.Api.Validators;
using Musify.Api.Validators.Albums;
using Xunit;
namespace Musify.Api.Tests.Validators;
public sealed class CreateAlbumRequestValidatorTests
namespace Musify.Api.Tests.Validators
{
private readonly CreateAlbumRequestValidator validator = new();
[Fact]
public void Validate_ValidRequest_Passes()
public sealed class CreateAlbumRequestValidatorTests
{
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid()));
private readonly CreateAlbumRequestValidator validator = new();
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, 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_ValidRequestWithoutOptionalFields_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid()));
[Fact]
public void Validate_BlankTitle_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
}
[Fact]
public void Validate_BlankTitle_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("", 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.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_TitleOverMaxLength_Fails()
{
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid()));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid()));
Assert.False(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.True(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid()));
[Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid()));
Assert.True(result.IsValid);
}
Assert.False(result.IsValid);
}
[Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid()));
[Fact]
public void Validate_EmptyPictureIntentId_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty));
Assert.False(result.IsValid);
}
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId));
[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));
}
}
}
@@ -3,46 +3,47 @@ using Musify.Api.Validators.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Api.Tests.Validators;
public sealed class TrackRequestValidatorTests
namespace Musify.Api.Tests.Validators
{
[Fact]
public void CreateTrack_ValidRequest_Passes()
public sealed class TrackRequestValidatorTests
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]);
[Fact]
public void CreateTrack_ValidRequest_Passes()
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]);
Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid);
}
Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void CreateTrack_NoTags_Fails()
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []);
[Fact]
public void CreateTrack_NoTags_Fails()
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []);
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Fact]
public void CreateTrack_IncompatibleTags_Fails()
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]);
[Fact]
public void CreateTrack_IncompatibleTags_Fails()
{
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]);
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid);
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void RecordListeningProgress_InvalidSeconds_Fails(double seconds)
{
Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid);
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void RecordListeningProgress_InvalidSeconds_Fails(double seconds)
{
Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid);
}
[Fact]
public void RecordListeningProgress_ZeroSeconds_Passes()
{
Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid);
[Fact]
public void RecordListeningProgress_ZeroSeconds_Passes()
{
Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid);
}
}
}
@@ -5,45 +5,46 @@ using Musify.Api.Validators.Tracks;
using Musify.Application.Configuration;
using Xunit;
namespace Musify.Api.Tests.Validators;
public sealed class UploadRequestValidatorTests
namespace Musify.Api.Tests.Validators
{
private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 };
[Fact]
public void PictureUpload_ValidRequest_Passes()
public sealed class UploadRequestValidatorTests
{
var validator = new PictureUploadRequestValidator(configuration);
private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 };
var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500));
[Fact]
public void PictureUpload_ValidRequest_Passes()
{
var validator = new PictureUploadRequestValidator(configuration);
Assert.True(result.IsValid);
}
var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500));
[Theory]
[InlineData("exe", "image/webp", 500L)]
[InlineData("averyveryveryloongextension", "image/webp", 500L)]
[InlineData("we bp", "image/webp", 500L)]
[InlineData("webp", "text/html", 500L)]
[InlineData("webp", "image/webp", 0L)]
[InlineData("webp", "image/webp", 1_001L)]
public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size)
{
var validator = new PictureUploadRequestValidator(configuration);
Assert.True(result.IsValid);
}
var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size));
[Theory]
[InlineData("exe", "image/webp", 500L)]
[InlineData("averyveryveryloongextension", "image/webp", 500L)]
[InlineData("we bp", "image/webp", 500L)]
[InlineData("webp", "text/html", 500L)]
[InlineData("webp", "image/webp", 0L)]
[InlineData("webp", "image/webp", 1_001L)]
public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size)
{
var validator = new PictureUploadRequestValidator(configuration);
Assert.False(result.IsValid);
}
var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size));
[Fact]
public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList()
{
var validator = new RequestTrackUploadUrlsRequestValidator(configuration);
Assert.False(result.IsValid);
}
Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid);
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid);
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid);
[Fact]
public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList()
{
var validator = new RequestTrackUploadUrlsRequestValidator(configuration);
Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid);
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid);
Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid);
}
}
}
@@ -5,108 +5,109 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToAlbumCommandHandler>());
[Fact]
public async Task Handle_FirstTrack_AddsItAtNumberOne()
public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track);
private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToAlbumCommandHandler>());
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_FirstTrack_AddsItAtNumberOne()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track);
Assert.False(result.IsError);
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(1, link.TrackNumber);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError);
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(1, link.TrackNumber);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, album, track);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, album, track);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.Album(owner.Id);
var foreignTrack = TestEntities.Track(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.Album(owner.Id);
var foreignTrack = TestEntities.Track(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlreadyInAlbum_IsSkipped()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlreadyInAlbum_IsSkipped()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
Assert.False(result.IsError);
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
Assert.False(result.IsError);
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
Assert.False(result.IsError);
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(4, newLink.TrackNumber);
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(4, newLink.TrackNumber);
}
}
}
@@ -6,69 +6,70 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Album());
[Fact]
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var owner = TestEntities.User();
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
await SeedAsync(owner, intent);
private CreateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateAlbumCommandHandler>(),
TestConfigurations.Album());
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id);
[Fact]
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var owner = TestEntities.User();
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp");
await SeedAsync(owner, intent);
Assert.False(result.IsError);
Assert.Equal("My Album", result.Value.Title);
Assert.Equal("A description", result.Value.Description);
Assert.Equal(2024, result.Value.ReleaseYear);
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id);
var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
Assert.Equal("cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
{
var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid());
Assert.False(result.IsError);
Assert.Equal("My Album", result.Value.Title);
Assert.Equal("A description", result.Value.Description);
Assert.Equal(2024, result.Value.ReleaseYear);
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
Assert.Equal("cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateAlbumResourcesEvent>(), Arg.Any<CancellationToken>());
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
{
var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid());
[Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid());
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.True(result.IsError);
var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
}
}
@@ -6,49 +6,50 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteAlbumCommandHandler>());
[Fact]
public async Task Handle_Owner_MarksAlbumAsRemoving()
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteAlbumCommandHandler>());
Assert.False(result.IsError);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteAlbumEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Owner_MarksAlbumAsRemoving()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.False(result.IsError);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteAlbumEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
[Fact]
public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
}
}
@@ -4,33 +4,34 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
Assert.False(result.IsError);
Assert.Equal(album.Id, result.Value.Id);
Assert.Equal(1, result.Value.TrackCount);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(album.Id, result.Value.Id);
Assert.Equal(1, result.Value.TrackCount);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
Assert.False(result.IsError);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -3,31 +3,32 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs"));
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.Album(owner.Id, "Mine"), TestEntities.Album(other.Id, "Theirs"));
Assert.False(result.IsError);
var album = Assert.Single(result.Value.Items);
Assert.Equal("Mine", album.Title);
}
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var album = Assert.Single(result.Value.Items);
Assert.Equal("Mine", album.Title);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -2,42 +2,43 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private GetAlbumsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta"));
private GetAlbumsQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
Assert.All(result.Items, item => Assert.NotNull(item));
}
[Fact]
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta"));
[Fact]
public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
Assert.All(result.Items, item => Assert.NotNull(item));
}
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Rock Classics", item.Title);
}
[Fact]
public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites"));
[Fact]
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Rock Classics", item.Title);
}
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Items.Count);
Assert.True(result.HasNextPage);
[Fact]
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Items.Count);
Assert.True(result.HasNextPage);
}
}
}
@@ -3,72 +3,73 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var olderAlbum = TestEntities.Album(owner.Id, "Older");
var newerAlbum = TestEntities.Album(owner.Id, "Newer");
var olderTrack = TestEntities.Track(owner, "In older album");
var newerTrack = TestEntities.Track(owner, "In newer album");
await SeedAsync(
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Newer", "Older"], result.Select(album => album.Title));
}
[Fact]
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
{
var owner = TestEntities.User();
var olderAlbum = TestEntities.Album(owner.Id, "Older");
var newerAlbum = TestEntities.Album(owner.Id, "Newer");
var olderTrack = TestEntities.Track(owner, "In older album");
var newerTrack = TestEntities.Track(owner, "In newer album");
await SeedAsync(
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
[Fact]
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Newer", "Older"], result.Select(album => album.Title));
}
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
[Fact]
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
{
var owner = TestEntities.User();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList();
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
{
var owner = TestEntities.User();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList();
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Count);
}
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
Assert.Equal(["Counted album"], result.Select(album => album.Title));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Select(album => album.Title));
}
}
}
@@ -5,68 +5,69 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
[Fact]
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, album, first, second, third,
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, album, first, second, third,
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
Assert.False(result.IsError);
var remaining = await Database.AlbumHasTracks
.Where(link => link.AlbumId == album.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
}
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var remaining = await Database.AlbumHasTracks
.Where(link => link.AlbumId == album.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -8,70 +8,71 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestAlbumPictureUploadCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Album());
}
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
[Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
return new RequestAlbumPictureUploadCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Album());
}
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp");
[Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp");
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp");
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp");
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var user = TestEntities.User();
await SeedAsync(user);
[Fact]
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -6,86 +6,87 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Albums;
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Albums
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Album());
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null);
private UpdateAlbumCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdateAlbumCommandHandler>(),
TestConfigurations.Album());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
Assert.False(result.IsError);
Assert.Equal("New Title", result.Value.Title);
Assert.Equal("New description", result.Value.Description);
Assert.Equal(2025, result.Value.ReleaseYear);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
Assert.False(result.IsError);
Assert.Equal("New Title", result.Value.Title);
Assert.Equal("New description", result.Value.Description);
Assert.Equal(2025, result.Value.ReleaseYear);
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Old Title");
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
}
var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id);
[Fact]
public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, title: "Old Title");
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp");
await SeedAsync(owner, album, intent);
Assert.False(result.IsError);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id);
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdateAlbumPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, album);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
}
}
@@ -2,29 +2,30 @@ using Musify.Application.Configuration;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Configuration;
public sealed class GetPlaybackPublicConfigTests
namespace Musify.Application.Tests.Configuration
{
[Fact]
public async Task Handle_ReflectsPlaybackConfiguration()
public sealed class GetPlaybackPublicConfigTests
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
var handler = new GetPlaybackPublicConfigQueryHandler(playback);
[Fact]
public async Task Handle_ReflectsPlaybackConfiguration()
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
var handler = new GetPlaybackPublicConfigQueryHandler(playback);
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
Assert.True(response.AllowAnonymousListening);
Assert.Equal(30, response.AnonymousFragmentSeconds);
}
Assert.True(response.AllowAnonymousListening);
Assert.Equal(30, response.AnonymousFragmentSeconds);
}
[Fact]
public async Task Handle_DefaultsToDisabled()
{
var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback());
[Fact]
public async Task Handle_DefaultsToDisabled()
{
var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback());
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
Assert.False(response.AllowAnonymousListening);
Assert.False(response.AllowAnonymousListening);
}
}
}
@@ -1,50 +1,51 @@
using Musify.Application.Configuration;
using Xunit;
namespace Musify.Application.Tests.Configuration;
public sealed class PicturesConfigurationTests
namespace Musify.Application.Tests.Configuration
{
[Fact]
public void AlbumRoutes_BuildTheSameKeysAsBefore()
public sealed class PicturesConfigurationTests
{
var routes = new AlbumConfiguration().Routes;
[Fact]
public void AlbumRoutes_BuildTheSameKeysAsBefore()
{
var routes = new AlbumConfiguration().Routes;
Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp"));
Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp"));
Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp"));
Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp"));
Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp"));
Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp"));
Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp"));
Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp"));
Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp"));
Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp"));
}
[Fact]
public void TrackRoutes_KeepTheTrackFolderLayout()
{
var routes = new TrackConfiguration().Routes;
Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder"));
Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3"));
Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath);
}
[Fact]
public void ToImageSizes_UsesTheRoutesFolders()
{
var configuration = new PlayListConfiguration();
var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes);
Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath);
Assert.Equal(128, sizes.Small.Width);
Assert.Equal(512, sizes.Large.Width);
}
[Theory]
[InlineData("small", PictureSize.Small)]
[InlineData("LARGE", PictureSize.Large)]
[InlineData("medium", PictureSize.Medium)]
[InlineData("whatever", PictureSize.Medium)]
[InlineData(null, PictureSize.Medium)]
public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) =>
Assert.Equal(expected, PictureSizeParser.Parse(value));
}
[Fact]
public void TrackRoutes_KeepTheTrackFolderLayout()
{
var routes = new TrackConfiguration().Routes;
Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder"));
Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3"));
Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath);
}
[Fact]
public void ToImageSizes_UsesTheRoutesFolders()
{
var configuration = new PlayListConfiguration();
var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes);
Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath);
Assert.Equal(128, sizes.Small.Width);
Assert.Equal(512, sizes.Large.Width);
}
[Theory]
[InlineData("small", PictureSize.Small)]
[InlineData("LARGE", PictureSize.Large)]
[InlineData("medium", PictureSize.Medium)]
[InlineData("whatever", PictureSize.Medium)]
[InlineData(null, PictureSize.Medium)]
public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) =>
Assert.Equal(expected, PictureSizeParser.Parse(value));
}
@@ -3,30 +3,31 @@ using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres;
public sealed class GetAvailableGenresQueryHandlerTests
namespace Musify.Application.Tests.Genres
{
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
public sealed class GetAvailableGenresQueryHandlerTests
{
var result = await RunAsync();
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
}
}
}
@@ -3,35 +3,36 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres;
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Genres
{
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
}
@@ -3,46 +3,47 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Likes;
public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Likes
{
private GetLikedTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_MultipleLikes_ReturnsMostRecentFirst()
public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older like");
var newer = TestEntities.Track(owner, "Newer like");
await SeedAsync(
owner, older, newer,
new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) },
new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow });
private GetLikedTracksQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title));
}
[Fact]
public async Task Handle_MultipleLikes_ReturnsMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older like");
var newer = TestEntities.Track(owner, "Newer like");
await SeedAsync(
owner, older, newer,
new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) },
new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow });
[Fact]
public async Task Handle_NoLikes_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title));
}
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Empty(result.Items);
}
[Fact]
public async Task Handle_NoLikes_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
[Fact]
public async Task Handle_OtherUsersLikes_AreExcluded()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Empty(result.Items);
}
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Empty(result.Items);
[Fact]
public async Task Handle_OtherUsersLikes_AreExcluded()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.Empty(result.Items);
}
}
}
@@ -4,52 +4,53 @@ using Musify.Application.Likes;
using Musify.Application.Tests.TestSupport;
using Xunit;
namespace Musify.Application.Tests.Likes;
public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Likes
{
private ToggleTrackLikeCommandHandler CreateHandler() =>
new(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
[Fact]
public async Task Handle_TrackNotLiked_LikesIt()
public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
private ToggleTrackLikeCommandHandler CreateHandler() =>
new(Database, NoOpLogger<ToggleTrackLikeCommandHandler>());
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackNotLiked_LikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError);
Assert.True(result.Value);
Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackAlreadyLiked_UnlikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id });
Assert.False(result.IsError);
Assert.True(result.Value);
Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackAlreadyLiked_UnlikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id });
Assert.False(result.IsError);
Assert.False(result.Value);
Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.False(result.IsError);
Assert.False(result.Value);
Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -4,78 +4,79 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Mixes;
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Mixes
{
private GenerateMixesForUserCommandHandler CreateHandler() => new(
Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
[Fact]
public async Task Handle_NoTracks_DoesNotCreateAnyMix()
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
{
var user = TestEntities.User();
await SeedAsync(user);
private GenerateMixesForUserCommandHandler CreateHandler() => new(
Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_NoTracks_DoesNotCreateAnyMix()
{
var user = TestEntities.User();
await SeedAsync(user);
[Fact]
public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id));
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
}
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Single(mixes);
Assert.Equal(MixKind.Daily, mixes[0].Kind);
}
[Fact]
public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id));
[Fact]
public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes()
{
var user = TestEntities.User();
var listenedTrack = TestEntities.Track(user, "Listened Track");
var unheardTrack = TestEntities.Track(user, "Unheard Track");
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Single(mixes);
Assert.Equal(MixKind.Daily, mixes[0].Kind);
}
var staleMix = TestEntities.Mix(user.Id, MixKind.Daily);
var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id);
[Fact]
public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes()
{
var user = TestEntities.User();
var listenedTrack = TestEntities.Track(user, "Listened Track");
var unheardTrack = TestEntities.Track(user, "Unheard Track");
await SeedAsync(
user, listenedTrack, unheardTrack, staleMix, staleItem,
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var staleMix = TestEntities.Mix(user.Id, MixKind.Daily);
var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id);
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily);
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
await SeedAsync(
user, listenedTrack, unheardTrack, staleMix, staleItem,
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery);
var discoveryItems = await Database.MixItems
.Where(item => item.MixId == discoveryMix.Id)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
}
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily);
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
[Fact]
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
{
var user = TestEntities.User();
var skipped = TestEntities.Track(user, "Only skipped");
await SeedAsync(
user, skipped,
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery);
var discoveryItems = await Database.MixItems
.Where(item => item.MixId == discoveryMix.Id)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
}
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
{
var user = TestEntities.User();
var skipped = TestEntities.Track(user, "Only skipped");
await SeedAsync(
user, skipped,
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery);
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery);
}
}
}
@@ -4,47 +4,48 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Mixes;
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Mixes
{
private GetMixByIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var mix = TestEntities.Mix(owner.Id, MixKind.Daily);
var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1);
var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0);
await SeedAsync(owner, firstTrack, secondTrack, mix, second, first);
private GetMixByIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
{
var owner = TestEntities.User();
var firstTrack = TestEntities.Track(owner, "First");
var secondTrack = TestEntities.Track(owner, "Second");
var mix = TestEntities.Mix(owner.Id, MixKind.Daily);
var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1);
var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0);
await SeedAsync(owner, firstTrack, secondTrack, mix, second, first);
Assert.False(result.IsError);
Assert.Equal(MixKind.Daily, result.Value.Kind);
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
}
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MixMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(MixKind.Daily, result.Value.Kind);
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_MixMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var mix = TestEntities.Mix(owner.Id);
await SeedAsync(owner, mix);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var mix = TestEntities.Mix(owner.Id);
await SeedAsync(owner, mix);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -3,33 +3,34 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Mixes;
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Mixes
{
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1);
var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0);
var theirs = TestEntities.Mix(other.Id);
await SeedAsync(owner, other, second, first, theirs);
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind));
}
[Fact]
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1);
var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0);
var theirs = TestEntities.Mix(other.Id);
await SeedAsync(owner, other, second, first, theirs);
[Fact]
public async Task Handle_NoMixes_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind));
}
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Empty(result);
[Fact]
public async Task Handle_NoMixes_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
}
@@ -5,100 +5,101 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Pictures;
public sealed class GetCoverQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Pictures
{
private GetCoverQueryHandler CreateHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName)
public sealed class GetCoverQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
private GetCoverQueryHandler CreateHandler() => new(
Database,
TestConfigurations.Storage("covers-bucket"),
TestConfigurations.Track(),
TestConfigurations.Album(),
TestConfigurations.PlayList());
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken);
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken);
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.False(result.IsError);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken);
[Theory]
[InlineData(PictureSize.Small, "small.webp")]
[InlineData(PictureSize.Medium, "medium.webp")]
[InlineData(PictureSize.Large, "large.webp")]
public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName)
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, album);
Assert.False(result.IsError);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ProcessedPlayList_ReturnsRequestedSize()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ProcessedPlayList_ReturnsRequestedSize()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.EndsWith("large.webp", result.Value.Key);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken);
[Theory]
[InlineData(CoverOwner.Track)]
[InlineData(CoverOwner.Album)]
[InlineData(CoverOwner.PlayList)]
public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner)
{
var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.EndsWith("large.webp", result.Value.Key);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Theory]
[InlineData(CoverOwner.Track)]
[InlineData(CoverOwner.Album)]
[InlineData(CoverOwner.PlayList)]
public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner)
{
var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png"));
await SeedAsync(owner, album);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_RemovingAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.LifeCycleStatus = LifeCycleStatus.Removing;
await SeedAsync(owner, album);
Assert.False(result.IsError);
Assert.Contains($"{owner.Id}", result.Value.Key);
Assert.EndsWith("original.png", result.Value.Key);
Assert.Equal("image/png", result.Value.ContentType);
}
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_RemovingAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.Album(owner.Id);
album.LifeCycleStatus = LifeCycleStatus.Removing;
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -5,125 +5,126 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToPlayListCommandHandler>());
[Fact]
public async Task Handle_FirstTrack_AddsItAtPositionZero()
public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track);
private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToPlayListCommandHandler>());
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_FirstTrack_AddsItAtPositionZero()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track);
Assert.False(result.IsError);
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(0, link.Position);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existingTrack = TestEntities.Track(owner, "Existing");
var newTrack = TestEntities.Track(owner, "New");
await SeedAsync(
owner, playList, existingTrack, newTrack,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
Assert.False(result.IsError);
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(0, link.Position);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existingTrack = TestEntities.Track(owner, "Existing");
var newTrack = TestEntities.Track(owner, "New");
await SeedAsync(
owner, playList, existingTrack, newTrack,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
Assert.False(result.IsError);
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(1, newLink.Position);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(1, newLink.Position);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackAlreadyInPlayList_IsSkipped()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackAlreadyInPlayList_IsSkipped()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.False(result.IsError);
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
[Fact]
public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 });
Assert.False(result.IsError);
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken);
[Fact]
public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 });
Assert.Equal("Test.Duplicate", result.FirstError.Code);
}
var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existing = TestEntities.Track(owner, "Existing");
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
await SeedAsync(
owner, playList, existing, first, second,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 });
Assert.Equal("Test.Duplicate", result.FirstError.Code);
}
var result = await CreateHandler().Handle(
new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]),
TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existing = TestEntities.Track(owner, "Existing");
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
await SeedAsync(
owner, playList, existing, first, second,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 });
Assert.False(result.IsError);
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([0, 1, 2], links.Select(link => link.Position));
var result = await CreateHandler().Handle(
new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]),
TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([0, 1, 2], links.Select(link => link.Position));
}
}
}
@@ -6,106 +6,107 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.PlayList());
[Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
{
var user = TestEntities.User();
await SeedAsync(user);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
private CreatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.PlayList());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.False(result.IsError);
Assert.Equal("My Playlist", result.Value.Name);
Assert.Null(result.Value.SmallImageKeyName);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
[Fact]
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var user = TestEntities.User();
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
await SeedAsync(user, intent);
Assert.False(result.IsError);
Assert.Equal("My Playlist", result.Value.Name);
Assert.Null(result.Value.SmallImageKeyName);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
[Fact]
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var user = TestEntities.User();
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
await SeedAsync(user, intent);
Assert.False(result.IsError);
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
[Fact]
public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null);
Assert.False(result.IsError);
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility);
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null);
[Fact]
public async Task Handle_PublicVisibility_PersistsIt()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PublicVisibility_PersistsIt()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
[Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.True(result.IsError);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
}
}
@@ -6,48 +6,49 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
[Fact]
public async Task Handle_Owner_MarksPlayListAsRemoving()
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken);
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
Assert.False(result.IsError);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Owner_MarksPlayListAsRemoving()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.False(result.IsError);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
}
}
@@ -4,81 +4,82 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.False(result.IsError);
Assert.Equal(playList.Name, result.Value.Name);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(playList.Name, result.Value.Name);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, stranger, playList);
Assert.False(result.IsError);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
}
}
@@ -5,59 +5,60 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, playList, second, first,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var second = TestEntities.Track(owner, "Second");
var first = TestEntities.Track(owner, "First");
await SeedAsync(
owner, playList, second, first,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
Assert.False(result.IsError);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
}
}
@@ -3,63 +3,64 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(
owner, other,
TestEntities.PlayList(owner.Id, "Mine"),
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
TestEntities.PlayList(other.Id, "Theirs"));
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Mine", playList.Name);
}
[Fact]
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(
owner, other,
TestEntities.PlayList(owner.Id, "Mine"),
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
TestEntities.PlayList(other.Id, "Theirs"));
[Fact]
public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(
owner,
TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public),
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Mine", playList.Name);
}
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Public one", playList.Name);
}
[Fact]
public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(
owner,
TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public),
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
[Fact]
public async Task Handle_Owner_SeesPrivatePlayLists()
{
var owner = TestEntities.User();
await SeedAsync(
owner,
TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public),
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Public one", playList.Name);
}
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Items.Count);
}
[Fact]
public async Task Handle_Owner_SeesPrivatePlayLists()
{
var owner = TestEntities.User();
await SeedAsync(
owner,
TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public),
TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private));
[Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Items.Count);
}
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Road Trip", playList.Name);
[Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Items);
Assert.Equal("Road Trip", playList.Name);
}
}
}
@@ -4,78 +4,79 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
[Fact]
public async Task Handle_TrackInPlayList_RemovesTheLink()
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackInPlayList_RemovesTheLink()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.Track(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.False(result.IsError);
Assert.Empty(Database.PlayListHasTracks);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(Database.PlayListHasTracks);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackNotLinked_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackNotLinked_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, playList, first, second, third,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 });
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
var third = TestEntities.Track(owner, "Third");
await SeedAsync(
owner, playList, first, second, third,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 });
Assert.False(result.IsError);
var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList();
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([0, 1], remaining.Select(link => link.Position));
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList();
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([0, 1], remaining.Select(link => link.Position));
}
}
}
@@ -8,70 +8,71 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestPlayListPictureUploadCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.PlayList());
}
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
[Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
return new RequestPlayListPictureUploadCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.PlayList());
}
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
[Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var user = TestEntities.User();
await SeedAsync(user);
[Fact]
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -6,128 +6,129 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.PlayLists;
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.PlayLists
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.PlayList());
[Fact]
public async Task Handle_Owner_RenamesPlayList()
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Old Name");
await SeedAsync(owner, playList);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.PlayList());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Owner_RenamesPlayList()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Old Name");
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.Equal("New Name", result.Value.Name);
}
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("New Name", result.Value.Name);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
[Fact]
public async Task Handle_NewVisibility_UpdatesIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NewVisibility_UpdatesIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public);
[Fact]
public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
[Fact]
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
await SeedAsync(owner, playList);
Assert.False(result.IsError);
Assert.Equal("Original Name", result.Value.Name);
Assert.Null(result.Value.Description);
}
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
[Fact]
public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp");
await SeedAsync(owner, playList, intent);
Assert.False(result.IsError);
Assert.Equal("Original Name", result.Value.Name);
Assert.Null(result.Value.Description);
}
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id);
[Fact]
public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp");
await SeedAsync(owner, playList, intent);
Assert.False(result.IsError);
Assert.Null(result.Value.SmallImageKeyName);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Null(result.Value.SmallImageKeyName);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
}
}
@@ -5,59 +5,60 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Services;
public sealed class TrackStreamIssuerTests
namespace Musify.Application.Tests.Services
{
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
public TrackStreamIssuerTests() =>
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("signed-token", 120));
private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new(
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test/"),
playback ?? TestConfigurations.Playback());
[Fact]
public void Issue_BuildsManifestUrlAndCarriesTheListenId()
public sealed class TrackStreamIssuerTests
{
var listenId = Guid.NewGuid();
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
var response = CreateIssuer().Issue("folder-42", userId: 7, listenId);
public TrackStreamIssuerTests() =>
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("signed-token", 120));
Assert.Equal("signed-token", response.Ticket);
Assert.Equal(120, response.ExpiresInSeconds);
Assert.Equal(listenId, response.ListenId);
Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl);
}
private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new(
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test/"),
playback ?? TestConfigurations.Playback());
[Fact]
public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService()
{
CreateIssuer().Issue("folder-42", userId: 1, listenId: null);
[Fact]
public void Issue_BuildsManifestUrlAndCarriesTheListenId()
{
var listenId = Guid.NewGuid();
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
}
var response = CreateIssuer().Issue("folder-42", userId: 7, listenId);
[Fact]
public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService()
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
Assert.Equal("signed-token", response.Ticket);
Assert.Equal(120, response.ExpiresInSeconds);
Assert.Equal(listenId, response.ListenId);
Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl);
}
CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null);
[Fact]
public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService()
{
CreateIssuer().Issue("folder-42", userId: 1, listenId: null);
ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond);
}
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
}
[Fact]
public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap()
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
[Fact]
public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService()
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null);
CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null);
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond);
}
[Fact]
public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap()
{
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null);
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
}
}
}
@@ -7,152 +7,153 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Services;
public sealed class UploadIntentValidatorTests : HandlerTestBase
namespace Musify.Application.Tests.Services
{
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private readonly Domain.Entities.User owner = TestEntities.User();
private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) =>
new(Database, storageService, config ?? TestConfigurations.UploadIntent());
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
public sealed class UploadIntentValidatorTests : HandlerTestBase
{
storageService
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private readonly Domain.Entities.User owner = TestEntities.User();
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
await SeedAsync(owner, intent);
private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) =>
new(Database, storageService, config ?? TestConfigurations.UploadIntent());
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
{
storageService
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
Assert.False(result.IsError);
Assert.Equal(intent.Id, result.Value.Id);
}
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(intent.Id, result.Value.Id);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio);
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{
var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
{
var stranger = TestEntities.User(2, "stranger");
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent);
[Fact]
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{
var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
{
var stranger = TestEntities.User(2, "stranger");
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent);
[Fact]
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
{
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
[Fact]
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
{
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((ObjectMetaData?)null);
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
[Fact]
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((ObjectMetaData?)null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxUploadBytes = 1024;
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxUploadBytes = 1024;
var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, intent);
[Fact]
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
{
await SeedAsync(owner);
var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
Assert.False(result.IsError);
}
[Fact]
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
{
await SeedAsync(owner);
[Fact]
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
[Fact]
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(
owner,
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
Assert.False(result.IsError);
[Fact]
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(
owner,
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
}
}
@@ -1,40 +1,41 @@
using Musify.Application.Shared;
using Xunit;
namespace Musify.Application.Tests.Shared;
public sealed class StorageKeyTests
namespace Musify.Application.Tests.Shared
{
[Fact]
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
public sealed class StorageKeyTests
{
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
[Fact]
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
{
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
}
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
}
[Fact]
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
{
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
[Fact]
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
{
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
}
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Combine_BlankSegment_IsSkipped(string? blank)
{
var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Combine_BlankSegment_IsSkipped(string? blank)
{
var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
Assert.Equal("Tracks/cover.webp", key);
}
Assert.Equal("Tracks/cover.webp", key);
}
[Fact]
public void Combine_NoSegments_ReturnsEmptyString()
{
Assert.Equal(string.Empty, StorageKey.Combine());
[Fact]
public void Combine_NoSegments_ReturnsEmptyString()
{
Assert.Equal(string.Empty, StorageKey.Combine());
}
}
}
@@ -2,31 +2,32 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks.Responses;
using Xunit;
namespace Musify.Application.Tests.Shared;
public sealed class TrackApplicationResponseTests
namespace Musify.Application.Tests.Shared
{
[Fact]
public void FromEntity_UsesOwnerNameAsArtist()
public sealed class TrackApplicationResponseTests
{
var owner = TestEntities.User(1, "The Owner");
var track = TestEntities.Track(owner);
[Fact]
public void FromEntity_UsesOwnerNameAsArtist()
{
var owner = TestEntities.User(1, "The Owner");
var track = TestEntities.Track(owner);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.True(response.IsExplicit);
Assert.True(response.IsExplicit);
}
}
}
@@ -2,29 +2,30 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Musify.Application.Tests.TestSupport;
public abstract class HandlerTestBase : IAsyncLifetime
namespace Musify.Application.Tests.TestSupport
{
private TestDatabase database = null!;
protected TestDatabase Database => database;
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
public ValueTask DisposeAsync()
public abstract class HandlerTestBase : IAsyncLifetime
{
GC.SuppressFinalize(this);
return database.DisposeAsync();
}
private TestDatabase database = null!;
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
protected TestDatabase Database => database;
protected async Task SeedAsync(params object[] entities)
{
foreach (var entity in entities)
Database.Add(entity);
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
await Database.SaveChangesAsync(CancellationToken.None);
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return database.DisposeAsync();
}
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
protected async Task SeedAsync(params object[] entities)
{
foreach (var entity in entities)
Database.Add(entity);
await Database.SaveChangesAsync(CancellationToken.None);
}
}
}
@@ -1,25 +1,26 @@
using Musify.Application.Configuration;
namespace Musify.Application.Tests.TestSupport;
public static class TestConfigurations
namespace Musify.Application.Tests.TestSupport
{
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
new() { Bucket = bucket };
public static class TestConfigurations
{
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
new() { Bucket = bucket };
public static TrackConfiguration Track() => new();
public static TrackConfiguration Track() => new();
public static PlayListConfiguration PlayList() => new();
public static PlayListConfiguration PlayList() => new();
public static AlbumConfiguration Album() => new();
public static AlbumConfiguration Album() => new();
public static MixConfiguration Mix() => new();
public static MixConfiguration Mix() => new();
public static UploadIntentConfiguration UploadIntent() => new();
public static UploadIntentConfiguration UploadIntent() => new();
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
new() { PublicBaseUrl = publicBaseUrl };
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
new() { PublicBaseUrl = publicBaseUrl };
public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) =>
new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds };
public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) =>
new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds };
}
}
@@ -5,96 +5,97 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
namespace Musify.Application.Tests.TestSupport;
public sealed class TestDatabase : DbContext, IDatabase
namespace Musify.Application.Tests.TestSupport
{
private readonly SqliteConnection connection;
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
: base(options)
public sealed class TestDatabase : DbContext, IDatabase
{
this.connection = connection;
}
private readonly SqliteConnection connection;
public static async Task<TestDatabase> CreateAsync()
{
var connection = new SqliteConnection("Filename=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<TestDatabase>()
.UseSqlite(connection)
.Options;
var database = new TestDatabase(connection, options);
await database.Database.EnsureCreatedAsync();
return database;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ModelConfiguration.Apply(modelBuilder);
}
public DbSet<User> Users => Set<User>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new SqliteDatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
{
try
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
: base(options)
{
await SaveChangesAsync(cancellationToken);
return Result.Success;
this.connection = connection;
}
catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 })
public static async Task<TestDatabase> CreateAsync()
{
return onUniqueViolation;
var connection = new SqliteConnection("Filename=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<TestDatabase>()
.UseSqlite(connection)
.Options;
var database = new TestDatabase(connection, options);
await database.Database.EnsureCreatedAsync();
return database;
}
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await connection.DisposeAsync();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
: IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken = default) =>
inner.CommitAsync(cancellationToken);
ModelConfiguration.Apply(modelBuilder);
}
public ValueTask DisposeAsync() => inner.DisposeAsync();
public DbSet<User> Users => Set<User>();
public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<Track> Tracks => Set<Track>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new SqliteDatabaseTransaction(transaction);
}
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
{
try
{
await SaveChangesAsync(cancellationToken);
return Result.Success;
}
catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 })
{
return onUniqueViolation;
}
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await connection.DisposeAsync();
}
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
: IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken = default) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
}
}
@@ -1,191 +1,192 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tests.TestSupport;
public static class TestEntities
namespace Musify.Application.Tests.TestSupport
{
public static User User(
long id = 1,
string name = "test-user",
string? firstName = "Test",
string? secondName = "User",
string? profilePictureUrl = null) =>
new()
public static class TestEntities
{
public static User User(
long id = 1,
string name = "test-user",
string? firstName = "Test",
string? secondName = "User",
string? profilePictureUrl = null) =>
new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
FirstName = firstName,
SecondName = secondName,
ProfilePictureUrl = profilePictureUrl
};
public static TrackPictures ProcessedPictures() => new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
FirstName = firstName,
SecondName = secondName,
ProfilePictureUrl = profilePictureUrl
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp",
ProcessingStatus = ProcessingStatus.Completed
};
public static TrackPictures ProcessedPictures() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp",
ProcessingStatus = ProcessingStatus.Completed
};
public static TrackPictures PendingPictures() => new()
{
OriginalName = "original.webp",
ProcessingStatus = ProcessingStatus.Pending
};
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
{
OriginalName = "original.mp3",
FolderName = folderName,
TranscodeStatus = ProcessingStatus.Completed
};
public static TrackAudio PendingAudio() => new()
{
OriginalName = "original.mp3",
TranscodeStatus = ProcessingStatus.Pending
};
public static Track Track(
User owner,
string title = "Test Track",
double durationSeconds = 180,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{
var track = new Track
public static TrackPictures PendingPictures() => new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
OriginalName = "original.webp",
ProcessingStatus = ProcessingStatus.Pending
};
track.Tags = (tags ?? [Genre.Pop])
.Select(tag => new TrackTag { TrackId = track.Id, Tag = tag })
.ToList();
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
{
OriginalName = "original.mp3",
FolderName = folderName,
TranscodeStatus = ProcessingStatus.Completed
};
return track;
public static TrackAudio PendingAudio() => new()
{
OriginalName = "original.mp3",
TranscodeStatus = ProcessingStatus.Pending
};
public static Track Track(
User owner,
string title = "Test Track",
double durationSeconds = 180,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
};
track.Tags = (tags ?? [Genre.Pop])
.Select(tag => new TrackTag { TrackId = track.Id, Tag = tag })
.ToList();
return track;
}
public static EntityPictures PlayListPictures() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
};
public static PlayList PlayList(
long userId,
string name = "Test Playlist",
string? description = null,
EntityPictures? pictures = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
PlayListVisibility visibility = PlayListVisibility.Private) =>
new()
{
OwnerUserId = userId,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Description = description,
Pictures = pictures ?? PlayListPictures(),
LifeCycleStatus = lifeCycleStatus,
Visibility = visibility
};
public static EntityPictures AlbumPictures() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
};
public static Album Album(
long ownerUserId,
string title = "Test Album",
string? description = null,
int? releaseYear = 2024,
EntityPictures? pictures = null) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = description,
ReleaseYear = releaseYear,
OwnerUserId = ownerUserId,
Pictures = pictures ?? AlbumPictures()
};
public static UploadIntent UploadIntent(
long userId,
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
string bucket = "test-bucket",
string key = "temp/object.webp",
string objectName = "object.webp",
string contentType = "image/webp",
long? expectedSizeBytes = 1024,
UploadIntentStatus status = UploadIntentStatus.Issued,
DateTime? expiresAt = null) =>
new()
{
UserId = userId,
Bucket = bucket,
Key = key,
ObjectName = objectName,
ContentType = contentType,
ExpectedSizeBytes = expectedSizeBytes,
Purpose = purpose,
Status = status,
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
};
public static Mix Mix(
long userId,
MixKind kind = MixKind.Discovery,
int position = 0) =>
new()
{
UserId = userId,
Kind = kind,
Position = position
};
public static MixItem MixItem(
Guid mixId,
Guid trackId,
int position = 0) =>
new()
{
MixId = mixId,
Position = position,
TrackId = trackId
};
public static ListeningHistory ListeningHistory(
long userId,
Guid trackId,
DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
new()
{
UserId = userId,
TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow,
PlayedSeconds = playedSeconds,
IsCounted = isCounted
};
}
public static EntityPictures PlayListPictures() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
};
public static PlayList PlayList(
long userId,
string name = "Test Playlist",
string? description = null,
EntityPictures? pictures = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
PlayListVisibility visibility = PlayListVisibility.Private) =>
new()
{
OwnerUserId = userId,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Description = description,
Pictures = pictures ?? PlayListPictures(),
LifeCycleStatus = lifeCycleStatus,
Visibility = visibility
};
public static EntityPictures AlbumPictures() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
};
public static Album Album(
long ownerUserId,
string title = "Test Album",
string? description = null,
int? releaseYear = 2024,
EntityPictures? pictures = null) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = description,
ReleaseYear = releaseYear,
OwnerUserId = ownerUserId,
Pictures = pictures ?? AlbumPictures()
};
public static UploadIntent UploadIntent(
long userId,
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
string bucket = "test-bucket",
string key = "temp/object.webp",
string objectName = "object.webp",
string contentType = "image/webp",
long? expectedSizeBytes = 1024,
UploadIntentStatus status = UploadIntentStatus.Issued,
DateTime? expiresAt = null) =>
new()
{
UserId = userId,
Bucket = bucket,
Key = key,
ObjectName = objectName,
ContentType = contentType,
ExpectedSizeBytes = expectedSizeBytes,
Purpose = purpose,
Status = status,
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
};
public static Mix Mix(
long userId,
MixKind kind = MixKind.Discovery,
int position = 0) =>
new()
{
UserId = userId,
Kind = kind,
Position = position
};
public static MixItem MixItem(
Guid mixId,
Guid trackId,
int position = 0) =>
new()
{
MixId = mixId,
Position = position,
TrackId = trackId
};
public static ListeningHistory ListeningHistory(
long userId,
Guid trackId,
DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
new()
{
UserId = userId,
TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow,
PlayedSeconds = playedSeconds,
IsCounted = isCounted
};
}
@@ -8,125 +8,126 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateHandler()
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private readonly IStorageService storageService = Substitute.For<IStorageService>();
return new CreateTrackCommandHandler(
Database,
eventBus,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Track());
}
private CreateTrackCommandHandler CreateHandler()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
return new CreateTrackCommandHandler(
Database,
eventBus,
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Track());
}
[Fact]
public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]);
[Fact]
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]);
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(user.Id, localResponse.OwnerUserId);
Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
}
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(user.Id, localResponse.OwnerUserId);
Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
}
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
{
var user = TestEntities.User(1, "user");
var otherUser = TestEntities.User(2, "other");
var (picture, audio) = SeedIntents(otherUser.Id);
await SeedAsync(user, otherUser, picture, audio);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
[Fact]
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
{
var user = TestEntities.User(1, "user");
var otherUser = TestEntities.User(2, "other");
var (picture, audio) = SeedIntents(otherUser.Id);
await SeedAsync(user, otherUser, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
Assert.True(result.IsError);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
await SeedAsync(user, picture, audio);
Assert.True(result.IsError);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
}
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
[Fact]
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
}
}
@@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
[Fact]
public async Task Handle_DeletesOnlyOldUncountedListens()
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_DeletesOnlyOldUncountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
Assert.Equal(1, deleted);
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(oldUncounted.Id, remaining);
Assert.Contains(oldCounted.Id, remaining);
Assert.Contains(recentUncounted.Id, remaining);
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, deleted);
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(oldUncounted.Id, remaining);
Assert.Contains(oldCounted.Id, remaining);
Assert.Contains(recentUncounted.Id, remaining);
}
}
}
@@ -6,60 +6,61 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
[Fact]
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
Assert.False(result.IsError);
var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.False(result.IsError);
var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_NotOwner_ReturnsForbidden()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.Track(owner);
await SeedAsync(owner, stranger, track);
[Fact]
public async Task Handle_AudioStillProcessing_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
[Fact]
public async Task Handle_AudioStillProcessing_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
}
}
@@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack()
public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older listen");
var newer = TestEntities.Track(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack()
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older listen");
var newer = TestEntities.Track(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
Assert.NotNull(result);
Assert.Equal("Newer listen", result.Title);
}
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoHistory_ReturnsNull()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.NotNull(result);
Assert.Equal("Newer listen", result.Title);
}
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoHistory_ReturnsNull()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.Null(result);
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Null(result);
}
}
}
@@ -2,53 +2,54 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older listen");
var newer = TestEntities.Track(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.Track(owner, "Older listen");
var newer = TestEntities.Track(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
}
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoHistory_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
}
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoHistory_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.Empty(result);
}
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
Assert.Empty(result);
}
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
Assert.Equal(["Counted"], result.Select(track => track.Title));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
}
}
@@ -4,49 +4,50 @@ using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private GetTrackByIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User(1, "artist-name");
var track = TestEntities.Track(owner, "My Song");
await SeedAsync(owner, track);
private GetTrackByIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
{
var owner = TestEntities.User(1, "artist-name");
var track = TestEntities.Track(owner, "My Song");
await SeedAsync(owner, track);
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
Assert.Equal(owner.Name, result.Value.Artist);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(owner.Id, localResponse.OwnerUserId);
}
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("My Song", result.Value.Title);
Assert.Equal(owner.Name, result.Value.Artist);
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
Assert.Equal(owner.Id, localResponse.OwnerUserId);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListensCount_OnlyCountsCountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(
owner, track,
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListensCount_OnlyCountsCountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(
owner, track,
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
Assert.Equal(2, result.Value.ListensCount);
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.ListensCount);
}
}
}
@@ -2,26 +2,27 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var mine = TestEntities.Track(owner, "Mine");
var theirs = TestEntities.Track(other, "Theirs");
await SeedAsync(
owner, other, mine, theirs);
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var mine = TestEntities.Track(owner, "Mine");
var theirs = TestEntities.Track(other, "Theirs");
await SeedAsync(
owner, other, mine, theirs);
Assert.False(result.IsError);
var track = Assert.Single(result.Value.Items);
Assert.Equal("Mine", track.Title);
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var track = Assert.Single(result.Value.Items);
Assert.Equal("Mine", track.Title);
}
}
}
@@ -3,51 +3,52 @@ using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private GetTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var trackA = TestEntities.Track(owner, "Alpha");
var trackB = TestEntities.Track(owner, "Beta");
await SeedAsync(
owner, trackA, trackB);
private GetTracksQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
Assert.Equal(2, result.Items.Count);
}
[Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
{
var owner = TestEntities.User();
var trackA = TestEntities.Track(owner, "Alpha");
var trackB = TestEntities.Track(owner, "Beta");
await SeedAsync(
owner, trackA, trackB);
[Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
{
var owner = TestEntities.User();
var match = TestEntities.Track(owner, "Bohemian Rhapsody");
var other = TestEntities.Track(owner, "Imagine");
await SeedAsync(
owner, match, other);
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
Assert.Equal(2, result.Items.Count);
}
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Bohemian Rhapsody", item.Title);
}
[Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
{
var owner = TestEntities.User();
var match = TestEntities.Track(owner, "Bohemian Rhapsody");
var other = TestEntities.Track(owner, "Imagine");
await SeedAsync(
owner, match, other);
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz);
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Bohemian Rhapsody", item.Title);
}
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Loud", item.Title);
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz);
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
var item = Assert.Single(result.Items);
Assert.Equal("Loud", item.Title);
}
}
}
@@ -5,109 +5,110 @@ using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
await Database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
double durationSeconds, DateTime listenedAt)
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
await SeedAsync(user, track, listen);
return (user, listen);
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
await Database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
double durationSeconds, DateTime listenedAt)
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
await SeedAsync(user, track, listen);
return (user, listen);
}
[Fact]
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(10, stored.PlayedSeconds);
Assert.False(stored.IsCounted);
}
[Fact]
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_ShortTrack_CountsAtHalfDuration()
{
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_LowerValueThanStored_KeepsHighest()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var handler = CreateHandler();
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(40, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
}
[Fact]
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
{
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
}
[Fact]
public async Task Handle_OtherUsersListen_ReturnsNotFound()
{
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
[Fact]
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(10, stored.PlayedSeconds);
Assert.False(stored.IsCounted);
}
[Fact]
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_ShortTrack_CountsAtHalfDuration()
{
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_LowerValueThanStored_KeepsHighest()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var handler = CreateHandler();
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(40, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
}
[Fact]
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
{
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
}
[Fact]
public async Task Handle_OtherUsersListen_ReturnsNotFound()
{
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
@@ -9,77 +9,78 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
private readonly IStorageService storageService = Substitute.For<IStorageService>();
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{
storageService
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestTrackUploadUrlsCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
return new RequestTrackUploadUrlsCommandHandler(
new UploadIntentIssuer(
Database,
storageService,
new UploadIntentValidator(Database, storageService, config),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
config,
NoOpLogger<UploadIntentIssuer>()),
TestConfigurations.Storage(),
TestConfigurations.Track(),
config);
}
TestConfigurations.Track(),
config);
}
[Fact]
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
[Fact]
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
Assert.False(result.IsError);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal(2, intents.Count);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
}
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal(2, intents.Count);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
[Fact]
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
var user = TestEntities.User();
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
var user = TestEntities.User();
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -8,94 +8,95 @@ using Musify.Application.Tracks;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Tracks;
public sealed class StartListeningCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Tracks
{
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
public sealed class StartListeningCommandHandlerTests : HandlerTestBase
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket-token", 60));
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
var issuer = new TrackStreamIssuer(
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test"),
playback ?? TestConfigurations.Playback());
private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
{
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket-token", 60));
return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger<StartListeningCommandHandler>());
}
var issuer = new TrackStreamIssuer(
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test"),
playback ?? TestConfigurations.Playback());
[Fact]
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger<StartListeningCommandHandler>());
}
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError);
Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Equal(60, result.Value.ExpiresInSeconds);
Assert.Contains("audio-folder", result.Value.ManifestUrl);
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
}
Assert.False(result.IsError);
Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Equal(60, result.Value.ExpiresInSeconds);
Assert.Contains("audio-folder", result.Value.ManifestUrl);
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
[Fact]
public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken);
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
[Fact]
public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(owner, track);
var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken);
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
Assert.False(result.IsError);
Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
}
}
}
@@ -6,66 +6,67 @@ using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.UploadIntents;
public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.UploadIntents
{
[Fact]
public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired()
public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase
{
var owner = TestEntities.User();
var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5));
var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5));
await SeedAsync(owner, overdue, active);
var handler = new ExpireUploadIntentsCommandHandler(
Database, TestConfigurations.UploadIntent(), NoOpLogger<ExpireUploadIntentsCommandHandler>());
[Fact]
public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired()
{
var owner = TestEntities.User();
var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5));
var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5));
await SeedAsync(owner, overdue, active);
var handler = new ExpireUploadIntentsCommandHandler(
Database, TestConfigurations.UploadIntent(), NoOpLogger<ExpireUploadIntentsCommandHandler>());
var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken);
var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, changed);
var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status);
Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status);
}
Assert.Equal(1, changed);
var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status);
Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status);
}
[Fact]
public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted()
{
var owner = TestEntities.User();
var old = TestEntities.UploadIntent(
owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30));
await SeedAsync(owner, old);
var handler = new ExpireUploadIntentsCommandHandler(
Database, TestConfigurations.UploadIntent(), NoOpLogger<ExpireUploadIntentsCommandHandler>());
[Fact]
public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted()
{
var owner = TestEntities.User();
var old = TestEntities.UploadIntent(
owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30));
await SeedAsync(owner, old);
var handler = new ExpireUploadIntentsCommandHandler(
Database, TestConfigurations.UploadIntent(), NoOpLogger<ExpireUploadIntentsCommandHandler>());
await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken);
await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention()
{
var storage = Substitute.For<IStorageService>();
storage.ListObjectsAsync("test-bucket", "temp", Arg.Any<CancellationToken>()).Returns(Objects(
new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)),
new StorageObject("temp/new", DateTime.UtcNow)));
var handler = new DeleteStaleTempUploadsCommandHandler(
storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(),
NoOpLogger<DeleteStaleTempUploadsCommandHandler>());
[Fact]
public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention()
{
var storage = Substitute.For<IStorageService>();
storage.ListObjectsAsync("test-bucket", "temp", Arg.Any<CancellationToken>()).Returns(Objects(
new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)),
new StorageObject("temp/new", DateTime.UtcNow)));
var handler = new DeleteStaleTempUploadsCommandHandler(
storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(),
NoOpLogger<DeleteStaleTempUploadsCommandHandler>());
var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken);
var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, deleted);
await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any<CancellationToken>());
await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any<CancellationToken>());
}
Assert.Equal(1, deleted);
await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any<CancellationToken>());
await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any<CancellationToken>());
}
private static async IAsyncEnumerable<StorageObject> Objects(params StorageObject[] objects)
{
foreach (var item in objects)
yield return item;
private static async IAsyncEnumerable<StorageObject> Objects(params StorageObject[] objects)
{
foreach (var item in objects)
yield return item;
await Task.CompletedTask;
await Task.CompletedTask;
}
}
}
@@ -4,57 +4,58 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class FollowUserCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<FollowUserCommandHandler>());
[Fact]
public async Task Handle_ValidTarget_CreatesFollow()
public sealed class FollowUserCommandHandlerTests : HandlerTestBase
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<FollowUserCommandHandler>());
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ValidTarget_CreatesFollow()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlreadyFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_AlreadyFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_SelfFollow_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_SelfFollow_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TargetMissing_ReturnsNotFound()
{
var follower = TestEntities.User();
await SeedAsync(follower);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_TargetMissing_ReturnsNotFound()
{
var follower = TestEntities.User();
await SeedAsync(follower);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -2,130 +2,131 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetListeningStatsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoHistory_ReturnsZeroes()
public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
{
var user = TestEntities.User();
await SeedAsync(user);
private GetListeningStatsQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoHistory_ReturnsZeroes()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.SecondsThisWeek);
Assert.Equal(0, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds()
{
var user = TestEntities.User();
var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200);
var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100);
await SeedAsync(
user, trackA, trackB,
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)),
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10)));
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.SecondsThisWeek);
Assert.Equal(0, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds()
{
var user = TestEntities.User();
var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200);
var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100);
await SeedAsync(
user, trackA, trackB,
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)),
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10)));
Assert.Equal(2, result.TracksThisWeek);
Assert.Equal(500, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: 200);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
Assert.Equal(2, result.TracksThisWeek);
Assert.Equal(500, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: 200);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
Assert.Equal(212, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
Assert.Equal(212, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.StreakDays);
Assert.Equal(3, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.StreakDays);
Assert.Equal(3, result.SecondsThisWeek);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
Assert.Equal(2, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_GapBeforeToday_StopsStreakAtGap()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
Assert.Equal(2, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_GapBeforeToday_StopsStreakAtGap()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
Assert.Equal(1, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
Assert.Equal(1, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
Assert.Equal(1, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoListenTodayOrYesterday_StreakIsZero()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
Assert.Equal(1, result.StreakDays);
}
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NoListenTodayOrYesterday_StreakIsZero()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
Assert.Equal(0, result.StreakDays);
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.StreakDays);
}
}
}
@@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetUserByIdQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ExistingUser_ReturnsIt()
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
{
var user = TestEntities.User(1, "Jane Doe");
await SeedAsync(user);
private GetUserByIdQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingUser_ReturnsIt()
{
var user = TestEntities.User(1, "Jane Doe");
await SeedAsync(user);
Assert.False(result.IsError);
Assert.Equal("Jane Doe", result.Value.Name);
}
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Jane Doe", result.Value.Name);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -6,89 +6,90 @@ using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_ReturnsFollowers()
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
{
var user = TestEntities.User(1, "user");
var follower = TestEntities.User(2, "follower");
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
var result = await Send(user.Id, user.Id);
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal(follower.Id, item.Id);
Assert.False(item.IsFollowedByViewer);
}
[Fact]
public async Task Handle_OwnProfile_ReturnsFollowers()
{
var user = TestEntities.User(1, "user");
var follower = TestEntities.User(2, "follower");
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
[Fact]
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var other = TestEntities.User(3, "other");
await SeedAsync(
viewer, user, other,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
var result = await Send(user.Id, user.Id);
var result = await Send(viewer.Id, user.Id);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal(follower.Id, item.Id);
Assert.False(item.IsFollowedByViewer);
}
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var other = TestEntities.User(3, "other");
await SeedAsync(
viewer, user, other,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
[Fact]
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await Send(viewer.Id, user.Id);
var result = await Send(viewer.Id, user.Id);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
[Fact]
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await Send(viewer.Id, user.Id);
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
[Fact]
public async Task Handle_Anonymous_ReturnsForbidden()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await Send(viewer.Id, user.Id);
var result = await Send(null, 1);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_Anonymous_ReturnsForbidden()
{
await SeedAsync(TestEntities.User(1, "user"));
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await Send(1, 404);
var result = await Send(null, 1);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await Send(1, 404);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -4,71 +4,72 @@ using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Anonymous_ReturnsFollowedUsers()
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
{
var user = TestEntities.User(1, "user");
var followedA = TestEntities.User(2, "followedA");
var followedB = TestEntities.User(3, "followedB");
await SeedAsync(
user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Anonymous_ReturnsFollowedUsers()
{
var user = TestEntities.User(1, "user");
var followedA = TestEntities.User(2, "followedA");
var followedB = TestEntities.User(3, "followedB");
await SeedAsync(
user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
}
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var followedA = TestEntities.User(3, "followedA");
var followedB = TestEntities.User(4, "followedB");
await SeedAsync(
viewer, user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
}
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var followedA = TestEntities.User(3, "followedA");
var followedB = TestEntities.User(4, "followedB");
await SeedAsync(
viewer, user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
}
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Pagination_ReturnsRequestedPage()
{
await SeedAsync(
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
new UserFollow { FollowerId = 1, FollowedId = 2 },
new UserFollow { FollowerId = 1, FollowedId = 3 },
new UserFollow { FollowerId = 1, FollowedId = 4 });
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
}
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Pagination_ReturnsRequestedPage()
{
await SeedAsync(
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
new UserFollow { FollowerId = 1, FollowedId = 2 },
new UserFollow { FollowerId = 1, FollowedId = 3 },
new UserFollow { FollowerId = 1, FollowedId = 4 });
Assert.False(result.IsError);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.False(result.Value.HasNextPage);
}
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.False(result.Value.HasNextPage);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -4,77 +4,78 @@ using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetUserProfileQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts()
public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase
{
var user = TestEntities.User(1, "user");
var followerA = TestEntities.User(2, "followerA");
var followerB = TestEntities.User(3, "followerB");
var followed = TestEntities.User(4, "followed");
await SeedAsync(
user, followerA, followerB, followed,
new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id },
new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followed.Id });
private GetUserProfileQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts()
{
var user = TestEntities.User(1, "user");
var followerA = TestEntities.User(2, "followerA");
var followerB = TestEntities.User(3, "followerB");
var followed = TestEntities.User(4, "followed");
await SeedAsync(
user, followerA, followerB, followed,
new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id },
new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followed.Id });
Assert.False(result.IsError);
Assert.Equal(2, result.Value.FollowersCount);
Assert.Equal(1, result.Value.FollowingCount);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(
viewer, user,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
Assert.False(result.IsError);
Assert.Equal(2, result.Value.FollowersCount);
Assert.Equal(1, result.Value.FollowingCount);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(
viewer, user,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
Assert.True(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
Assert.True(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
Assert.True(result.Value.IsFollowing);
Assert.False(result.Value.CanViewFollowers);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
{
await SeedAsync(TestEntities.User(1, "user"));
Assert.True(result.Value.IsFollowing);
Assert.False(result.Value.CanViewFollowers);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
{
await SeedAsync(TestEntities.User(1, "user"));
Assert.False(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken);
Assert.False(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -3,39 +3,40 @@ using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private GetUsersQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoSearch_ReturnsEveryUser()
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
{
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
private GetUsersQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
}
[Fact]
public async Task Handle_NoSearch_ReturnsEveryUser()
{
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
[Fact]
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
{
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TotalItemCount);
}
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
var user = Assert.Single(result.Items);
Assert.Equal(1, user.Id);
}
[Fact]
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
{
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
[Fact]
public async Task Handle_Viewer_MarksFollowedUsers()
{
await SeedAsync(
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
new UserFollow { FollowerId = 1, FollowedId = 2 });
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
var user = Assert.Single(result.Items);
Assert.Equal(1, user.Id);
}
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
[Fact]
public async Task Handle_Viewer_MarksFollowedUsers()
{
await SeedAsync(
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
new UserFollow { FollowerId = 1, FollowedId = 2 });
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
}
}
}
@@ -3,33 +3,34 @@ using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private IsFollowingUserQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Following_ReturnsTrue()
public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
private IsFollowingUserQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_Following_ReturnsTrue()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
Assert.True(result);
}
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotFollowing_ReturnsFalse()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
Assert.True(result);
}
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotFollowing_ReturnsFalse()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
Assert.False(result);
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result);
}
}
}
@@ -2,41 +2,42 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
[Fact]
public async Task Handle_UnknownUser_ProvisionsIt()
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
{
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_UnknownUser_ProvisionsIt()
{
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
Assert.False(result.IsError);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("Jane Doe", stored.Name);
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingUser_UpdatesProfileFields()
{
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
await SeedAsync(user);
Assert.False(result.IsError);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("Jane Doe", stored.Name);
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
}
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
[Fact]
public async Task Handle_ExistingUser_UpdatesProfileFields()
{
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
Assert.False(result.IsError);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("New Name", stored.Name);
Assert.Equal("NEW NAME", stored.NormalizedName);
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("New Name", stored.Name);
Assert.Equal("NEW NAME", stored.NormalizedName);
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
}
}
}
@@ -4,34 +4,35 @@ using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users;
public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase
namespace Musify.Application.Tests.Users
{
private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<UnfollowUserCommandHandler>());
[Fact]
public async Task Handle_ExistingFollow_RemovesIt()
public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<UnfollowUserCommandHandler>());
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ExistingFollow_RemovesIt()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
Assert.False(result.IsError);
Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
Assert.False(result.IsError);
Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_NotFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
Assert.False(result.IsError);
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
}
}
@@ -1,50 +1,51 @@
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Domain.Tests.ValueObjects;
public sealed class GenreCompatibilityTests
namespace Musify.Domain.Tests.ValueObjects
{
[Theory]
[InlineData(Genre.Classical, Genre.Metal)]
[InlineData(Genre.Classical, Genre.Punk)]
[InlineData(Genre.Ambient, Genre.Dubstep)]
[InlineData(Genre.Lofi, Genre.DrumAndBass)]
public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second)
public sealed class GenreCompatibilityTests
{
Assert.False(GenreCompatibility.AreCompatible(first, second));
Assert.False(GenreCompatibility.AreCompatible(second, first));
}
[Theory]
[InlineData(Genre.Classical, Genre.Metal)]
[InlineData(Genre.Classical, Genre.Punk)]
[InlineData(Genre.Ambient, Genre.Dubstep)]
[InlineData(Genre.Lofi, Genre.DrumAndBass)]
public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second)
{
Assert.False(GenreCompatibility.AreCompatible(first, second));
Assert.False(GenreCompatibility.AreCompatible(second, first));
}
[Theory]
[InlineData(Genre.Pop, Genre.Rock)]
[InlineData(Genre.HipHop, Genre.RnB)]
[InlineData(Genre.Classical, Genre.Jazz)]
public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second)
{
Assert.True(GenreCompatibility.AreCompatible(first, second));
}
[Theory]
[InlineData(Genre.Pop, Genre.Rock)]
[InlineData(Genre.HipHop, Genre.RnB)]
[InlineData(Genre.Classical, Genre.Jazz)]
public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second)
{
Assert.True(GenreCompatibility.AreCompatible(first, second));
}
[Fact]
public void AreCompatible_SameGenre_ReturnsTrue()
{
Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop));
}
[Fact]
public void AreCompatible_SameGenre_ReturnsTrue()
{
Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop));
}
[Fact]
public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]);
[Fact]
public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]);
Assert.Empty(conflicts);
}
Assert.Empty(conflicts);
}
[Fact]
public void FindConflicts_IncompatiblePairPresent_ReturnsIt()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]);
[Fact]
public void FindConflicts_IncompatiblePairPresent_ReturnsIt()
{
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]);
var conflict = Assert.Single(conflicts);
Assert.Equal((Genre.Classical, Genre.Metal), conflict);
var conflict = Assert.Single(conflicts);
Assert.Equal((Genre.Classical, Genre.Metal), conflict);
}
}
}
@@ -1,22 +1,23 @@
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Domain.Tests.ValueObjects;
public sealed class TrackAudioTests
namespace Musify.Domain.Tests.ValueObjects
{
[Theory]
[InlineData(ProcessingStatus.Pending, false, false, true)]
[InlineData(ProcessingStatus.Processing, false, false, true)]
[InlineData(ProcessingStatus.Completed, true, false, false)]
[InlineData(ProcessingStatus.Failed, false, true, false)]
public void ComputedFlags_ReflectTranscodeStatus(
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
public sealed class TrackAudioTests
{
var audio = new TrackAudio { TranscodeStatus = status };
[Theory]
[InlineData(ProcessingStatus.Pending, false, false, true)]
[InlineData(ProcessingStatus.Processing, false, false, true)]
[InlineData(ProcessingStatus.Completed, true, false, false)]
[InlineData(ProcessingStatus.Failed, false, true, false)]
public void ComputedFlags_ReflectTranscodeStatus(
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
{
var audio = new TrackAudio { TranscodeStatus = status };
Assert.Equal(expectedProcessed, audio.IsProcessed);
Assert.Equal(expectedFailed, audio.IsFailed);
Assert.Equal(expectedInProgress, audio.IsInProgress);
Assert.Equal(expectedProcessed, audio.IsProcessed);
Assert.Equal(expectedFailed, audio.IsFailed);
Assert.Equal(expectedInProgress, audio.IsInProgress);
}
}
}
@@ -1,19 +1,20 @@
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Domain.Tests.ValueObjects;
public sealed class TrackPicturesTests
namespace Musify.Domain.Tests.ValueObjects
{
[Theory]
[InlineData(ProcessingStatus.Pending, false)]
[InlineData(ProcessingStatus.Processing, false)]
[InlineData(ProcessingStatus.Completed, true)]
[InlineData(ProcessingStatus.Failed, false)]
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
public sealed class TrackPicturesTests
{
var pictures = new TrackPictures { ProcessingStatus = status };
[Theory]
[InlineData(ProcessingStatus.Pending, false)]
[InlineData(ProcessingStatus.Processing, false)]
[InlineData(ProcessingStatus.Completed, true)]
[InlineData(ProcessingStatus.Failed, false)]
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
{
var pictures = new TrackPictures { ProcessingStatus = status };
Assert.Equal(expected, pictures.IsProcessed);
Assert.Equal(expected, pictures.IsProcessed);
}
}
}
@@ -1,22 +1,23 @@
using Musify.Infrastructure.Jobs;
using Xunit;
namespace Musify.Infrastructure.Tests.Jobs;
public sealed class RecurringJobsRegistrarTests
namespace Musify.Infrastructure.Tests.Jobs
{
[Theory]
[InlineData(1, "*/1 * * * *")]
[InlineData(59, "*/1 * * * *")]
[InlineData(60, "*/1 * * * *")]
[InlineData(61, "*/2 * * * *")]
[InlineData(300, "*/5 * * * *")]
[InlineData(3599, "0 */1 * * *")]
[InlineData(3600, "0 */1 * * *")]
[InlineData(7200, "0 */2 * * *")]
[InlineData(0, "*/1 * * * *")]
public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected)
public sealed class RecurringJobsRegistrarTests
{
Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds));
[Theory]
[InlineData(1, "*/1 * * * *")]
[InlineData(59, "*/1 * * * *")]
[InlineData(60, "*/1 * * * *")]
[InlineData(61, "*/2 * * * *")]
[InlineData(300, "*/5 * * * *")]
[InlineData(3599, "0 */1 * * *")]
[InlineData(3600, "0 */1 * * *")]
[InlineData(7200, "0 */2 * * *")]
[InlineData(0, "*/1 * * * *")]
public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected)
{
Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds));
}
}
}
@@ -6,200 +6,201 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
[Collection(InfrastructureCollection.Name)]
public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture)
namespace Musify.Infrastructure.Tests.MassTransit
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow()
[Collection(InfrastructureCollection.Name)]
public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture)
{
var album = await SeedAlbumAsync(new EntityPictures
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
});
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkAlbumAsRemoving,
ActivityNames.RemoveAlbumOriginalPicture,
ActivityNames.RemoveAlbumSmallPicture,
ActivityNames.RemoveAlbumMediumPicture,
ActivityNames.RemoveAlbumLargePicture,
ActivityNames.DeleteAlbumFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal()
{
var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp"));
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes()
{
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes()
{
var playList = await SeedPlayListAsync(pictures: null);
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete()
{
var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkPlayListAsRemoving,
ActivityNames.RemovePlayListOriginalPicture,
ActivityNames.RemovePlayListSmallPicture,
ActivityNames.DeletePlayListFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures()
{
var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalPicture,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
{
var track = await SeedTrackAsync(
new TrackPictures
var album = await SeedAlbumAsync(new EntityPictures
{
OriginalName = "original.webp",
SmallName = "s.webp",
MediumName = "m.webp",
LargeName = "l.webp",
ProcessingStatus = ProcessingStatus.Completed
},
new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed });
await using var database = fixture.CreateDatabase();
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
});
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalPicture,
ActivityNames.RemoveTrackSmallPicture,
ActivityNames.RemoveTrackMediumPicture,
ActivityNames.RemoveTrackLargePicture,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.RemoveTrackProcessedAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
Assert.Equal(
[
ActivityNames.MarkAlbumAsRemoving,
ActivityNames.RemoveAlbumOriginalPicture,
ActivityNames.RemoveAlbumSmallPicture,
ActivityNames.RemoveAlbumMediumPicture,
ActivityNames.RemoveAlbumLargePicture,
ActivityNames.DeleteAlbumFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) =>
new(
database,
new TrackConfiguration(),
new AlbumConfiguration(),
new PlayListConfiguration(),
new ApplicationStorageConfiguration { Bucket = "bucket" });
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"delete-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Album> SeedAlbumAsync(EntityPictures? pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
private async Task<PlayList> SeedPlayListAsync(EntityPictures? pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
}
private async Task<Track> SeedTrackAsync(TrackPictures pictures, TrackAudio audio)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
[Fact]
public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = pictures,
Audio = audio
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp"));
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes()
{
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes()
{
var playList = await SeedPlayListAsync(pictures: null);
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete()
{
var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkPlayListAsRemoving,
ActivityNames.RemovePlayListOriginalPicture,
ActivityNames.RemovePlayListSmallPicture,
ActivityNames.DeletePlayListFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures()
{
var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalPicture,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
[Fact]
public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio()
{
var track = await SeedTrackAsync(
new TrackPictures
{
OriginalName = "original.webp",
SmallName = "s.webp",
MediumName = "m.webp",
LargeName = "l.webp",
ProcessingStatus = ProcessingStatus.Completed
},
new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed });
await using var database = fixture.CreateDatabase();
var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build();
Assert.Equal(
[
ActivityNames.MarkTrackAsRemoving,
ActivityNames.RemoveTrackOriginalPicture,
ActivityNames.RemoveTrackSmallPicture,
ActivityNames.RemoveTrackMediumPicture,
ActivityNames.RemoveTrackLargePicture,
ActivityNames.RemoveTrackOriginalAudio,
ActivityNames.RemoveTrackProcessedAudio,
ActivityNames.DeleteTrackFromDb,
],
slip.Itinerary.Select(activity => activity.Name));
}
private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) =>
new(
database,
new TrackConfiguration(),
new AlbumConfiguration(),
new PlayListConfiguration(),
new ApplicationStorageConfiguration { Bucket = "bucket" });
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"delete-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Album> SeedAlbumAsync(EntityPictures? pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
private async Task<PlayList> SeedPlayListAsync(EntityPictures? pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
}
private async Task<Track> SeedTrackAsync(TrackPictures pictures, TrackAudio audio)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = pictures,
Audio = audio
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
}
}
}
@@ -9,177 +9,178 @@ using Musify.Infrastructure.Tests.TestSupport;
using NSubstitute;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
[Collection(InfrastructureCollection.Name)]
public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture)
namespace Musify.Infrastructure.Tests.MassTransit
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task MarkTrack_ExistingTrack_SetsTheStatus()
[Collection(InfrastructureCollection.Name)]
public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture)
{
var track = await SeedTrackAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkTrackLifeCycleActivity(database, NullLogger<MarkTrackLifeCycleActivity>.Instance);
var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing));
private static CancellationToken Ct => TestContext.Current.CancellationToken;
await activity.Execute(context);
context.Received(1).Completed();
await using var verifier = fixture.CreateDatabase();
var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
}
[Fact]
public async Task MarkAlbum_ExistingAlbum_SetsTheStatus()
{
var album = await SeedAlbumAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkAlbumLifeCycleActivity(database, NullLogger<MarkAlbumLifeCycleActivity>.Instance);
await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed)));
await using var verifier = fixture.CreateDatabase();
Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus);
}
[Fact]
public async Task MarkPlayList_ExistingPlayList_SetsTheStatus()
{
var playList = await SeedPlayListAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkPlayListLifeCycleActivity(database, NullLogger<MarkPlayListLifeCycleActivity>.Instance);
await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing)));
await using var verifier = fixture.CreateDatabase();
Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus);
}
[Fact]
public async Task Mark_MissingEntity_CompletesWithoutFailing()
{
await using var database = fixture.CreateDatabase();
var activity = new MarkTrackLifeCycleActivity(database, NullLogger<MarkTrackLifeCycleActivity>.Instance);
var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed));
await activity.Execute(context);
context.Received(1).Completed();
}
[Fact]
public async Task DeleteTrack_ExistingTrack_RemovesTheRow()
{
var track = await SeedTrackAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeleteTrackActivity(database, NullLogger<DeleteTrackActivity>.Instance);
var context = ExecuteContextFor(new DeleteEntityArguments(track.Id));
await activity.Execute(context);
context.Received(1).Completed();
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct));
}
[Fact]
public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow()
{
var album = await SeedAlbumAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeleteAlbumActivity(database, NullLogger<DeleteAlbumActivity>.Instance);
await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id)));
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct));
}
[Fact]
public async Task DeletePlayList_ExistingPlayList_RemovesTheRow()
{
var playList = await SeedPlayListAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeletePlayListActivity(database, NullLogger<DeletePlayListActivity>.Instance);
await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id)));
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct));
}
[Fact]
public async Task Delete_MissingEntity_CompletesWithoutFailing()
{
await using var database = fixture.CreateDatabase();
var activity = new DeleteTrackActivity(database, NullLogger<DeleteTrackActivity>.Instance);
var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid()));
await activity.Execute(context);
context.Received(1).Completed();
}
private static ExecuteContext<TArguments> ExecuteContextFor<TArguments>(TArguments arguments)
where TArguments : class
{
var context = Substitute.For<ExecuteContext<TArguments>>();
context.Arguments.Returns(arguments);
context.CancellationToken.Returns(Ct);
return context;
}
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"lifecycle-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Track> SeedTrackAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
[Fact]
public async Task MarkTrack_ExistingTrack_SetsTheStatus()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
}
var track = await SeedTrackAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkTrackLifeCycleActivity(database, NullLogger<MarkTrackLifeCycleActivity>.Instance);
var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing));
private async Task<Album> SeedAlbumAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
await activity.Execute(context);
private async Task<PlayList> SeedPlayListAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
context.Received(1).Completed();
await using var verifier = fixture.CreateDatabase();
var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
}
[Fact]
public async Task MarkAlbum_ExistingAlbum_SetsTheStatus()
{
var album = await SeedAlbumAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkAlbumLifeCycleActivity(database, NullLogger<MarkAlbumLifeCycleActivity>.Instance);
await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed)));
await using var verifier = fixture.CreateDatabase();
Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus);
}
[Fact]
public async Task MarkPlayList_ExistingPlayList_SetsTheStatus()
{
var playList = await SeedPlayListAsync();
await using var database = fixture.CreateDatabase();
var activity = new MarkPlayListLifeCycleActivity(database, NullLogger<MarkPlayListLifeCycleActivity>.Instance);
await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing)));
await using var verifier = fixture.CreateDatabase();
Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus);
}
[Fact]
public async Task Mark_MissingEntity_CompletesWithoutFailing()
{
await using var database = fixture.CreateDatabase();
var activity = new MarkTrackLifeCycleActivity(database, NullLogger<MarkTrackLifeCycleActivity>.Instance);
var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed));
await activity.Execute(context);
context.Received(1).Completed();
}
[Fact]
public async Task DeleteTrack_ExistingTrack_RemovesTheRow()
{
var track = await SeedTrackAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeleteTrackActivity(database, NullLogger<DeleteTrackActivity>.Instance);
var context = ExecuteContextFor(new DeleteEntityArguments(track.Id));
await activity.Execute(context);
context.Received(1).Completed();
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct));
}
[Fact]
public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow()
{
var album = await SeedAlbumAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeleteAlbumActivity(database, NullLogger<DeleteAlbumActivity>.Instance);
await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id)));
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct));
}
[Fact]
public async Task DeletePlayList_ExistingPlayList_RemovesTheRow()
{
var playList = await SeedPlayListAsync();
await using var database = fixture.CreateDatabase();
var activity = new DeletePlayListActivity(database, NullLogger<DeletePlayListActivity>.Instance);
await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id)));
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct));
}
[Fact]
public async Task Delete_MissingEntity_CompletesWithoutFailing()
{
await using var database = fixture.CreateDatabase();
var activity = new DeleteTrackActivity(database, NullLogger<DeleteTrackActivity>.Instance);
var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid()));
await activity.Execute(context);
context.Received(1).Completed();
}
private static ExecuteContext<TArguments> ExecuteContextFor<TArguments>(TArguments arguments)
where TArguments : class
{
var context = Substitute.For<ExecuteContext<TArguments>>();
context.Arguments.Returns(arguments);
context.CancellationToken.Returns(Ct);
return context;
}
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"lifecycle-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Track> SeedTrackAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
}
private async Task<Album> SeedAlbumAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
private async Task<PlayList> SeedPlayListAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
}
}
}
@@ -7,102 +7,103 @@ using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Infrastructure.MassTransit.RoutingSlip;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
public sealed class ProcessingSlipFaultConsumerTests
namespace Musify.Infrastructure.Tests.MassTransit
{
private static readonly Guid SubjectId = Guid.NewGuid();
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() =>
await AssertPublishedAsync<TrackPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId);
[Fact]
public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() =>
await AssertPublishedAsync<TrackAudioProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId);
[Fact]
public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() =>
await AssertPublishedAsync<PlayListPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId);
[Fact]
public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() =>
await AssertPublishedAsync<AlbumPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId);
[Fact]
public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() =>
await AssertPublishedAsync<TrackProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId);
[Fact]
public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() =>
await AssertPublishedAsync<AlbumProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.AlbumCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId);
[Fact]
public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() =>
await AssertPublishedAsync<PlayListProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.PlayListCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId);
[Fact]
public async Task Consume_UnknownKind_PublishesNothing()
public sealed class ProcessingSlipFaultConsumerTests
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
private static readonly Guid SubjectId = Guid.NewGuid();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables("Something else") }, Ct);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
[Fact]
public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() =>
await AssertPublishedAsync<TrackPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId);
[Fact]
public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() =>
await AssertPublishedAsync<TrackAudioProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId);
[Fact]
public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() =>
await AssertPublishedAsync<PlayListPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId);
[Fact]
public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() =>
await AssertPublishedAsync<AlbumPictureProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId);
[Fact]
public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() =>
await AssertPublishedAsync<TrackProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.TrackCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId);
[Fact]
public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() =>
await AssertPublishedAsync<AlbumProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.AlbumCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId);
[Fact]
public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() =>
await AssertPublishedAsync<PlayListProcessingFailed>(
RoutingSlipVariableNames.ProcessKinds.PlayListCreation,
message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId);
[Fact]
public async Task Consume_UnknownKind_PublishesNothing()
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables("Something else") }, Ct);
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Consume_WithoutProcessKind_PublishesNothing()
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = new Dictionary<string, object>() }, Ct);
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
}
private static async Task AssertPublishedAsync<TEvent>(string processKind, Func<TEvent, bool> matches)
where TEvent : class
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables(processKind) }, Ct);
Assert.True(await harness.Published.Any<TEvent>(published => matches(published.Context.Message), Ct));
}
private static Dictionary<string, object> Variables(string processKind) => new()
{
[RoutingSlipVariableNames.Workflow.ProcessKind] = processKind,
[RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId,
[RoutingSlipVariableNames.Workflow.Bucket] = "bucket",
[RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key",
[RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key",
};
private static ServiceProvider CreateProvider() =>
new ServiceCollection()
.AddMassTransitTestHarness(bus => bus.AddConsumer<ProcessingSlipFaultConsumer>())
.BuildServiceProvider(true);
}
[Fact]
public async Task Consume_WithoutProcessKind_PublishesNothing()
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = new Dictionary<string, object>() }, Ct);
Assert.True(await harness.Consumed.Any<RoutingSlipFaulted>(Ct));
Assert.False(await harness.Published.Any<TrackPictureProcessingFailed>(Ct));
}
private static async Task AssertPublishedAsync<TEvent>(string processKind, Func<TEvent, bool> matches)
where TEvent : class
{
await using var provider = CreateProvider();
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish<RoutingSlipFaulted>(new { Variables = Variables(processKind) }, Ct);
Assert.True(await harness.Published.Any<TEvent>(published => matches(published.Context.Message), Ct));
}
private static Dictionary<string, object> Variables(string processKind) => new()
{
[RoutingSlipVariableNames.Workflow.ProcessKind] = processKind,
[RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId,
[RoutingSlipVariableNames.Workflow.Bucket] = "bucket",
[RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key",
[RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key",
};
private static ServiceProvider CreateProvider() =>
new ServiceCollection()
.AddMassTransitTestHarness(bus => bus.AddConsumer<ProcessingSlipFaultConsumer>())
.BuildServiceProvider(true);
}
@@ -6,202 +6,203 @@ using Musify.Application.Shared;
using Musify.Infrastructure.MassTransit.Sagas;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
public sealed class ProcessingStateMachineTests
namespace Musify.Infrastructure.Tests.MassTransit
{
private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3));
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// Track
[Fact]
public async Task Track_PictureAndAudioProcessed_CompletesTheSaga()
public sealed class ProcessingStateMachineTests
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3));
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
private static CancellationToken Ct => TestContext.Current.CancellationToken;
await harness.Bus.Publish(new TrackPictureProcessed(id), Ct);
await harness.Bus.Publish(new TrackAudioProcessed(id), Ct);
// Track
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
[Fact]
public async Task Track_PictureAndAudioProcessed_CompletesTheSaga()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackPictureProcessed(id), Ct);
await harness.Bus.Publish(new TrackAudioProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<TrackProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" }
&& published.Context.Message.TrackId == id, Ct));
}
[Fact]
public async Task Track_AudioFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var id = Guid.NewGuid();
await harness.Bus.Publish(new TrackPictureProcessed(id), Ct);
await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct);
Assert.True(await harness.Consumed.Any<TrackAudioProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<TrackPictureProcessed>>(Ct));
Assert.False(await harness.Published.Any<Fault<TrackAudioProcessingFailed>>(Ct));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
// Album
[Fact]
public async Task Album_PictureProcessed_CompletesTheSaga()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<AlbumProcessingFailed>(Ct));
}
[Fact]
public async Task Album_PictureFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<AlbumProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" }
&& published.Context.Message.AlbumId == id, Ct));
}
[Fact]
public async Task Album_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct);
Assert.True(await harness.Consumed.Any<AlbumPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<AlbumProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<AlbumPictureProcessingFailed>>(Ct));
}
// PlayList
[Fact]
public async Task PlayList_PictureProcessed_CompletesTheSaga()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<PlayListProcessingFailed>(Ct));
}
[Fact]
public async Task PlayList_PictureFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<PlayListProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" }
&& published.Context.Message.PlayListId == id, Ct));
}
[Fact]
public async Task PlayList_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct);
Assert.True(await harness.Consumed.Any<PlayListPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<PlayListProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<PlayListPictureProcessingFailed>>(Ct));
}
private static CreateTrackResourcesEvent TrackStarted(Guid id) =>
new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes);
private static async Task<ITestHarness> StartAsync(ServiceProvider provider)
{
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
return harness;
}
private static ServiceProvider CreateProvider<TMachine, TState>()
where TMachine : class, SagaStateMachine<TState>
where TState : class, SagaStateMachineInstance =>
new ServiceCollection()
.AddMassTransitTestHarness(bus => bus.AddSagaStateMachine<TMachine, TState>().InMemoryRepository())
.BuildServiceProvider(true);
}
[Fact]
public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<TrackProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" }
&& published.Context.Message.TrackId == id, Ct));
}
[Fact]
public async Task Track_AudioFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<TrackProcessingStateMachine, TrackProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(TrackStarted(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
[Fact]
public async Task Track_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<TrackProcessingStateMachine, TrackProcessingState>();
var harness = await StartAsync(provider);
var id = Guid.NewGuid();
await harness.Bus.Publish(new TrackPictureProcessed(id), Ct);
await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct);
Assert.True(await harness.Consumed.Any<TrackAudioProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<TrackPictureProcessed>>(Ct));
Assert.False(await harness.Published.Any<Fault<TrackAudioProcessingFailed>>(Ct));
Assert.False(await harness.Published.Any<TrackProcessingFailed>(Ct));
}
// Album
[Fact]
public async Task Album_PictureProcessed_CompletesTheSaga()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<AlbumProcessingFailed>(Ct));
}
[Fact]
public async Task Album_PictureFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<AlbumProcessingStateMachine, AlbumProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<AlbumProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" }
&& published.Context.Message.AlbumId == id, Ct));
}
[Fact]
public async Task Album_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<AlbumProcessingStateMachine, AlbumProcessingState>();
var harness = await StartAsync(provider);
await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct);
Assert.True(await harness.Consumed.Any<AlbumPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<AlbumProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<AlbumPictureProcessingFailed>>(Ct));
}
// PlayList
[Fact]
public async Task PlayList_PictureProcessed_CompletesTheSaga()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct);
Assert.Null(await saga.NotExists(id));
Assert.False(await harness.Published.Any<PlayListProcessingFailed>(Ct));
}
[Fact]
public async Task PlayList_PictureFailed_PublishesProcessingFailed()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
var saga = provider.GetRequiredService<ISagaStateMachineTestHarness<PlayListProcessingStateMachine, PlayListProcessingState>>();
var id = Guid.NewGuid();
await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Processing));
await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct);
Assert.NotNull(await saga.Exists(id, machine => machine.Failed));
Assert.True(await harness.Published.Any<PlayListProcessingFailed>(
published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" }
&& published.Context.Message.PlayListId == id, Ct));
}
[Fact]
public async Task PlayList_EventForMissingInstance_IsDiscarded()
{
await using var provider = CreateProvider<PlayListProcessingStateMachine, PlayListProcessingState>();
var harness = await StartAsync(provider);
await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct);
Assert.True(await harness.Consumed.Any<PlayListPictureProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<PlayListProcessingFailed>(Ct));
Assert.False(await harness.Published.Any<Fault<PlayListPictureProcessingFailed>>(Ct));
}
private static CreateTrackResourcesEvent TrackStarted(Guid id) =>
new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes);
private static async Task<ITestHarness> StartAsync(ServiceProvider provider)
{
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
return harness;
}
private static ServiceProvider CreateProvider<TMachine, TState>()
where TMachine : class, SagaStateMachine<TState>
where TState : class, SagaStateMachineInstance =>
new ServiceCollection()
.AddMassTransitTestHarness(bus => bus.AddSagaStateMachine<TMachine, TState>().InMemoryRepository())
.BuildServiceProvider(true);
}
@@ -7,123 +7,124 @@ using Musify.Infrastructure.MassTransit.RoutingSlip;
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
public sealed class RoutingSlipBuilderTests
namespace Musify.Infrastructure.Tests.MassTransit
{
private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3));
private static readonly Guid SubjectId = Guid.NewGuid();
private static readonly Guid IntentId = Guid.NewGuid();
private static readonly Guid CorrelationId = Guid.NewGuid();
[Fact]
public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId()
public sealed class RoutingSlipBuilderTests
{
var slip = RoutingSlips.Create(CorrelationId).Build();
private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3));
Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
var subscription = Assert.Single(slip.Subscriptions);
Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address);
Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events);
}
private static readonly Guid SubjectId = Guid.NewGuid();
private static readonly Guid IntentId = Guid.NewGuid();
private static readonly Guid CorrelationId = Guid.NewGuid();
[Fact]
public void Create_WithoutCorrelationId_UsesEmptyGuid()
{
var slip = RoutingSlips.Create(null).Build();
[Fact]
public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId()
{
var slip = RoutingSlips.Create(CorrelationId).Build();
Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
}
Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
var subscription = Assert.Single(slip.Subscriptions);
Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address);
Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events);
}
[Fact]
public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription()
{
var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build();
[Fact]
public void Create_WithoutCorrelationId_UsesEmptyGuid()
{
var slip = RoutingSlips.Create(null).Build();
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Contains(slip.Subscriptions, subscription =>
subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName)
&& subscription.Events == RoutingSlipEvents.Faulted);
}
Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
}
[Fact]
public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder()
{
var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
[Fact]
public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription()
{
var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build();
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Contains(slip.Subscriptions, subscription =>
subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName)
&& subscription.Events == RoutingSlipEvents.Faulted);
}
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent);
}
[Fact]
public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder()
{
var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
[Fact]
public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind()
{
var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent);
}
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent);
}
[Fact]
public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind()
{
var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
[Fact]
public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder()
{
var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent);
}
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent);
}
[Fact]
public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder()
{
var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
[Fact]
public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind()
{
var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent);
}
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent);
}
[Fact]
public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind()
{
var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
[Fact]
public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate()
{
var configuration = new WorkerConfiguration();
var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder");
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build();
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent);
}
Assert.Equal(
[
ActivityNames.GenerateAudioWorkflowPaths,
ActivityNames.DownloadFile,
ActivityNames.TranscodeAudio,
ActivityNames.TransferFiles,
ActivityNames.UpdateTrackAudio,
],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString()));
}
[Fact]
public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate()
{
var configuration = new WorkerConfiguration();
var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder");
private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep)
{
Assert.Equal(
[ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep],
slip.Itinerary.Select(activity => activity.Name));
var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build();
Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]);
Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]);
Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
Assert.Equal(
[
ActivityNames.GenerateAudioWorkflowPaths,
ActivityNames.DownloadFile,
ActivityNames.TranscodeAudio,
ActivityNames.TransferFiles,
ActivityNames.UpdateTrackAudio,
],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString()));
}
var copy = new Dictionary<string, object>(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal("source-key", copy["SourceKey"]?.ToString());
Assert.Equal("final-key", copy["DestinationKey"]?.ToString());
private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep)
{
Assert.Equal(
[ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep],
slip.Itinerary.Select(activity => activity.Name));
Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]);
Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]);
Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]);
Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]);
Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
var copy = new Dictionary<string, object>(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase);
Assert.Equal("source-key", copy["SourceKey"]?.ToString());
Assert.Equal("final-key", copy["DestinationKey"]?.ToString());
}
}
}
@@ -14,247 +14,248 @@ using Musify.Infrastructure.MassTransit.RoutingSlip;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
namespace Musify.Infrastructure.Tests.MassTransit;
/// <summary>Runs the picture-update activities inside real routing slips on the MassTransit test harness.</summary>
[Collection(InfrastructureCollection.Name)]
public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture)
namespace Musify.Infrastructure.Tests.MassTransit
{
private const string SmallVariable = "Picture.Small";
private const string MediumVariable = "Picture.Medium";
private const string LargeVariable = "Picture.Large";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent()
/// <summary>Runs the picture-update activities inside real routing slips on the MassTransit test harness.</summary>
[Collection(InfrastructureCollection.Name)]
public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture)
{
var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
private const string SmallVariable = "Picture.Small";
private const string MediumVariable = "Picture.Medium";
private const string LargeVariable = "Picture.Large";
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp");
await harness.Bus.Execute(slip.Build(), Ct);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!;
Assert.Equal("new-original.webp", stored.OriginalName);
Assert.Equal("small.webp", stored.SmallName);
Assert.Equal("medium.webp", stored.MediumName);
Assert.Equal("large.webp", stored.LargeName);
Assert.True(await harness.Published.Any<AlbumPictureProcessed>(published => published.Context.Message.AlbumId == album.Id, Ct));
}
[Fact]
public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent()
{
var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp");
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!;
Assert.Equal("new.webp", stored.OriginalName);
Assert.Equal("small.webp", stored.SmallName);
Assert.True(await harness.Published.Any<PlayListPictureProcessed>(published => published.Context.Message.PlayListId == playList.Id, Ct));
}
[Fact]
public async Task Track_Execute_MarksThePicturesAsCompleted()
{
var track = await SeedTrackAsync();
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp");
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures;
Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus);
Assert.Equal("large.webp", stored.LargeName);
Assert.True(await harness.Published.Any<TrackPictureProcessed>(published => published.Context.Message.TrackId == track.Id, Ct));
}
[Fact]
public async Task Execute_MissingEntity_FaultsTheSlip()
{
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp");
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
}
[Fact]
public async Task Execute_MissingVariable_FaultsTheSlip()
{
var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = RoutingSlips.Create(null).AddStep(
"Update",
UpdateAlbumPictureActivity.ExecuteEndpointName,
new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable));
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await using var verifier = fixture.CreateDatabase();
Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName);
}
[Fact]
public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames()
{
var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp");
slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments());
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await WaitUntilAsync(async () =>
[Fact]
public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent()
{
await using var poll = fixture.CreateDatabase();
return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp";
});
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!;
Assert.Equal("old-original.webp", stored.OriginalName);
Assert.Null(stored.SmallName);
Assert.Null(stored.MediumName);
Assert.Null(stored.LargeName);
}
var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
[Fact]
public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed()
{
var track = await SeedTrackAsync();
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp");
await harness.Bus.Execute(slip.Build(), Ct);
var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp");
slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments());
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!;
Assert.Equal("new-original.webp", stored.OriginalName);
Assert.Equal("small.webp", stored.SmallName);
Assert.Equal("medium.webp", stored.MediumName);
Assert.Equal("large.webp", stored.LargeName);
Assert.True(await harness.Published.Any<AlbumPictureProcessed>(published => published.Context.Message.AlbumId == album.Id, Ct));
}
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await WaitUntilAsync(async () =>
[Fact]
public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent()
{
await using var poll = fixture.CreateDatabase();
return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed;
});
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures;
Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus);
Assert.Equal("old.webp", stored.OriginalName);
}
var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
private static async Task WaitUntilAsync(Func<Task<bool>> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(100, Ct);
}
var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp");
await harness.Bus.Execute(slip.Build(), Ct);
private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey)
{
var builder = RoutingSlips.Create(null).AddStep(
"Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable));
builder.AddVariable(SmallVariable, "pictures/small.webp");
builder.AddVariable(MediumVariable, "pictures/medium.webp");
builder.AddVariable(LargeVariable, "pictures/large.webp");
return builder;
}
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!;
Assert.Equal("new.webp", stored.OriginalName);
Assert.Equal("small.webp", stored.SmallName);
Assert.True(await harness.Published.Any<PlayListPictureProcessed>(published => published.Context.Message.PlayListId == playList.Id, Ct));
}
private ServiceProvider CreateProvider() =>
new ServiceCollection()
.AddScoped<IDatabase>(_ => fixture.CreateDatabase())
.AddMassTransitTestHarness(bus =>
[Fact]
public async Task Track_Execute_MarksThePicturesAsCompleted()
{
var track = await SeedTrackAsync();
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp");
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipCompleted>(Ct));
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures;
Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus);
Assert.Equal("large.webp", stored.LargeName);
Assert.True(await harness.Published.Any<TrackPictureProcessed>(published => published.Context.Message.TrackId == track.Id, Ct));
}
[Fact]
public async Task Execute_MissingEntity_FaultsTheSlip()
{
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp");
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
}
[Fact]
public async Task Execute_MissingVariable_FaultsTheSlip()
{
var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = RoutingSlips.Create(null).AddStep(
"Update",
UpdateAlbumPictureActivity.ExecuteEndpointName,
new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable));
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await using var verifier = fixture.CreateDatabase();
Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName);
}
[Fact]
public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames()
{
var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp"));
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp");
slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments());
await harness.Bus.Execute(slip.Build(), Ct);
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await WaitUntilAsync(async () =>
{
bus.SetKebabCaseEndpointNameFormatter();
bus.AddActivity<UpdateAlbumPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddActivity<UpdatePlayListPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddActivity<UpdateTrackPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddExecuteActivity<AlwaysFailsActivity, AlwaysFailsArguments>();
})
.BuildServiceProvider(true);
await using var poll = fixture.CreateDatabase();
return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp";
});
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!;
Assert.Equal("old-original.webp", stored.OriginalName);
Assert.Null(stored.SmallName);
Assert.Null(stored.MediumName);
Assert.Null(stored.LargeName);
}
private static async Task<ITestHarness> StartAsync(ServiceProvider provider)
{
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
return harness;
}
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"pictures-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Album> SeedAlbumAsync(EntityPictures pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
private async Task<PlayList> SeedPlayListAsync(EntityPictures pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
}
private async Task<Track> SeedTrackAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
[Fact]
public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = new TrackPictures { OriginalName = "old.webp" },
Audio = new TrackAudio()
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
}
var track = await SeedTrackAsync();
await using var provider = CreateProvider();
var harness = await StartAsync(provider);
public sealed record AlwaysFailsArguments;
var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp");
slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments());
await harness.Bus.Execute(slip.Build(), Ct);
public sealed class AlwaysFailsActivity : IExecuteActivity<AlwaysFailsArguments>
{
public const string ExecuteEndpointName = "always-fails";
Assert.True(await harness.Sent.Any<RoutingSlipFaulted>(Ct));
await WaitUntilAsync(async () =>
{
await using var poll = fixture.CreateDatabase();
return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed;
});
await using var verifier = fixture.CreateDatabase();
var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures;
Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus);
Assert.Equal("old.webp", stored.OriginalName);
}
public Task<ExecutionResult> Execute(ExecuteContext<AlwaysFailsArguments> context) =>
throw new InvalidOperationException("Forced failure");
private static async Task WaitUntilAsync(Func<Task<bool>> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(100, Ct);
}
private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey)
{
var builder = RoutingSlips.Create(null).AddStep(
"Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable));
builder.AddVariable(SmallVariable, "pictures/small.webp");
builder.AddVariable(MediumVariable, "pictures/medium.webp");
builder.AddVariable(LargeVariable, "pictures/large.webp");
return builder;
}
private ServiceProvider CreateProvider() =>
new ServiceCollection()
.AddScoped<IDatabase>(_ => fixture.CreateDatabase())
.AddMassTransitTestHarness(bus =>
{
bus.SetKebabCaseEndpointNameFormatter();
bus.AddActivity<UpdateAlbumPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddActivity<UpdatePlayListPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddActivity<UpdateTrackPictureActivity, UpdatePicturesArguments, UpdatePicturesLog>();
bus.AddExecuteActivity<AlwaysFailsActivity, AlwaysFailsArguments>();
})
.BuildServiceProvider(true);
private static async Task<ITestHarness> StartAsync(ServiceProvider provider)
{
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
return harness;
}
private async Task<User> SeedUserAsync()
{
await using var database = fixture.CreateDatabase();
var name = $"pictures-{Guid.NewGuid():N}"[..30];
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, Ct);
await database.SaveChangesAsync(Ct);
return user;
}
private async Task<Album> SeedAlbumAsync(EntityPictures pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"album-{Guid.NewGuid():N}";
var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.Albums.AddAsync(album, Ct);
await database.SaveChangesAsync(Ct);
return album;
}
private async Task<PlayList> SeedPlayListAsync(EntityPictures pictures)
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var name = $"pl-{Guid.NewGuid():N}"[..30];
var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures };
await database.PlayLists.AddAsync(playList, Ct);
await database.SaveChangesAsync(Ct);
return playList;
}
private async Task<Track> SeedTrackAsync()
{
var user = await SeedUserAsync();
await using var database = fixture.CreateDatabase();
var title = $"track-{Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 10,
OwnerUserId = user.Id,
Pictures = new TrackPictures { OriginalName = "old.webp" },
Audio = new TrackAudio()
};
await database.Tracks.AddAsync(track, Ct);
await database.SaveChangesAsync(Ct);
return track;
}
public sealed record AlwaysFailsArguments;
public sealed class AlwaysFailsActivity : IExecuteActivity<AlwaysFailsArguments>
{
public const string ExecuteEndpointName = "always-fails";
public Task<ExecutionResult> Execute(ExecuteContext<AlwaysFailsArguments> context) =>
throw new InvalidOperationException("Forced failure");
}
}
}
@@ -3,51 +3,52 @@ using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
namespace Musify.Infrastructure.Tests.Persistence;
[Collection(InfrastructureCollection.Name)]
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
namespace Musify.Infrastructure.Tests.Persistence
{
[Fact]
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow()
[Collection(InfrastructureCollection.Name)]
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
{
await using var database = fixture.CreateDatabase();
var before = DateTime.UtcNow;
[Fact]
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow()
{
await using var database = fixture.CreateDatabase();
var before = DateTime.UtcNow;
var name = "audit-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var name = "audit-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var after = DateTime.UtcNow;
var after = DateTime.UtcNow;
Assert.InRange(user.CreatedAt, before, after);
Assert.InRange(user.UpdatedAt, before, after);
}
Assert.InRange(user.CreatedAt, before, after);
Assert.InRange(user.UpdatedAt, before, after);
}
[Fact]
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
{
await using var database = fixture.CreateDatabase();
[Fact]
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
{
await using var database = fixture.CreateDatabase();
var name = "audit-user-2";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var originalCreatedAt = user.CreatedAt;
var name = "audit-user-2";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var originalCreatedAt = user.CreatedAt;
await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
user.Name = "audit-user-2-renamed";
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
user.Name = "audit-user-2-renamed";
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
}
}
}
@@ -4,104 +4,105 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
namespace Musify.Infrastructure.Tests.Persistence;
[Collection(InfrastructureCollection.Name)]
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
namespace Musify.Infrastructure.Tests.Persistence
{
[Fact]
public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres()
[Collection(InfrastructureCollection.Name)]
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
{
await using var database = fixture.CreateDatabase();
var userName = "roundtrip-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
var trackTitle = "Round Trip Track";
var track = new Track
[Fact]
public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres()
{
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
DurationSeconds = 123.4,
OwnerUserId = user.Id,
Owner = user,
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
};
await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var database = fixture.CreateDatabase();
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
var userName = "roundtrip-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
Assert.Equal(trackTitle, stored.Title);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal("song.mp3", stored.Audio.OriginalName);
Assert.Equal(user.Id, stored.OwnerUserId);
}
var trackTitle = "Round Trip Track";
var track = new Track
{
Title = trackTitle,
NormalizedTitle = trackTitle.ToUpperInvariant(),
DurationSeconds = 123.4,
OwnerUserId = user.Id,
Owner = user,
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
};
await database.Tracks.AddAsync(track, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum()
{
await using var database = fixture.CreateDatabase();
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
var userName = "tags-roundtrip-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}");
track.Tags =
[
new TrackTag { TrackId = track.Id, Tag = Genre.Pop },
new TrackTag { TrackId = track.Id, Tag = Genre.Rock }
];
Assert.Equal(trackTitle, stored.Title);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
Assert.Equal("song.mp3", stored.Audio.OriginalName);
Assert.Equal(user.Id, stored.OwnerUserId);
}
await database.AddRangeAsync(user, track);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking()
.Include(t => t.Tags)
.SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t));
}
[Fact]
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
{
await using var database = fixture.CreateDatabase();
var userName = "cascade-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var albumTitle = "Cascade Album";
var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id };
var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}");
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await database.AddRangeAsync(user, album, track, link);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var deleter = fixture.CreateDatabase();
var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.Albums.Remove(albumToDelete);
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
}
private static Track BuildTrack(User owner, string title)
{
return new Track
[Fact]
public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 100,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
await using var database = fixture.CreateDatabase();
var userName = "tags-roundtrip-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}");
track.Tags =
[
new TrackTag { TrackId = track.Id, Tag = Genre.Pop },
new TrackTag { TrackId = track.Id, Tag = Genre.Rock }
];
await database.AddRangeAsync(user, track);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking()
.Include(t => t.Tags)
.SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t));
}
[Fact]
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
{
await using var database = fixture.CreateDatabase();
var userName = "cascade-user";
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
var albumTitle = "Cascade Album";
var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id };
var track = BuildTrack(user, $"cascade-{Guid.NewGuid():N}");
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await database.AddRangeAsync(user, album, track, link);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var deleter = fixture.CreateDatabase();
var albumToDelete = await deleter.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.Albums.Remove(albumToDelete);
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
}
private static Track BuildTrack(User owner, string title)
{
return new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = 100,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
}
}
}
@@ -4,54 +4,55 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Xunit;
namespace Musify.Infrastructure.Tests.Services;
public sealed class PictureServiceTests
namespace Musify.Infrastructure.Tests.Services
{
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
private static MemoryStream CreateSourceImage(int width, int height)
public sealed class PictureServiceTests
{
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
var stream = new MemoryStream();
image.SaveAsPng(stream);
stream.Position = 0;
return stream;
}
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
[Fact]
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
{
using var source = CreateSourceImage(800, 600);
private static MemoryStream CreateSourceImage(int width, int height)
{
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
var stream = new MemoryStream();
image.SaveAsPng(stream);
stream.Position = 0;
return stream;
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken);
[Fact]
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
{
using var source = CreateSourceImage(800, 600);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
Assert.InRange(result.Width, 1, 200);
Assert.InRange(result.Height, 1, 200);
Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken);
[Fact]
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
{
using var source = CreateSourceImage(100, 100);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
Assert.InRange(result.Width, 1, 200);
Assert.InRange(result.Height, 1, 200);
Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken);
[Fact]
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
{
using var source = CreateSourceImage(100, 100);
var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken);
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken);
[Fact]
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
{
using var source = CreateSourceImage(400, 200);
var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken);
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken);
[Fact]
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
{
using var source = CreateSourceImage(400, 200);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
Assert.Equal(100, result.Width);
Assert.Equal(50, result.Height);
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
Assert.Equal(100, result.Width);
Assert.Equal(50, result.Height);
}
}
}
@@ -7,130 +7,131 @@ using Musify.Infrastructure.Services;
using Musify.Infrastructure.Tests.TestSupport;
using Xunit;
namespace Musify.Infrastructure.Tests.Services;
[Collection(InfrastructureCollection.Name)]
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
namespace Musify.Infrastructure.Tests.Services
{
private StorageService service = null!;
public ValueTask InitializeAsync()
[Collection(InfrastructureCollection.Name)]
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
{
var storageConfiguration = new InfrastructureStorageConfiguration
private StorageService service = null!;
public ValueTask InitializeAsync()
{
Address = fixture.S3ServiceUrl,
AccessKey = InfrastructureTestFixture.S3AccessKey,
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
ForcePathStyle = true,
UseHttp = true,
};
service = new StorageService(fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
return ValueTask.CompletedTask;
}
var storageConfiguration = new InfrastructureStorageConfiguration
{
Address = fixture.S3ServiceUrl,
AccessKey = InfrastructureTestFixture.S3AccessKey,
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
ForcePathStyle = true,
UseHttp = true,
};
service = new StorageService(fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
{
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
var content = "hello seaweedfs"u8.ToArray();
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8);
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Equal("hello seaweedfs", readBack);
}
[Fact]
public async Task GetFileAsync_MissingObject_ReturnsNull()
{
var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(stream);
}
[Fact]
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
{
var key = $"tests/{Guid.NewGuid():N}/head.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(1, metadata.ContentLength);
}
[Fact]
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
{
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(metadata);
}
[Fact]
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
{
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
}
[Fact]
public async Task CopyFileAsync_CopiesToTheDestinationKey()
{
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken);
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
var keys = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
keys.Add(item.Key);
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
}
[Fact]
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
{
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
var uploadUrl = await service.GetUploadUrlAsync(
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken);
using var httpClient = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
[Fact]
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken);
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
var content = "hello seaweedfs"u8.ToArray();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal("presigned content".Length, metadata.ContentLength);
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8);
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Equal("hello seaweedfs", readBack);
}
[Fact]
public async Task GetFileAsync_MissingObject_ReturnsNull()
{
var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(stream);
}
[Fact]
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
{
var key = $"tests/{Guid.NewGuid():N}/head.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(1, metadata.ContentLength);
}
[Fact]
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
{
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(metadata);
}
[Fact]
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
{
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
}
[Fact]
public async Task CopyFileAsync_CopiesToTheDestinationKey()
{
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken);
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
var keys = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
keys.Add(item.Key);
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
}
[Fact]
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
{
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
var uploadUrl = await service.GetUploadUrlAsync(
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken);
using var httpClient = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal("presigned content".Length, metadata.ContentLength);
}
}
}
@@ -5,104 +5,105 @@ using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Services;
using Xunit;
namespace Musify.Infrastructure.Tests.Services;
public sealed class StreamTicketServiceTests : IDisposable
namespace Musify.Infrastructure.Tests.Services
{
private readonly string privateKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
public void Dispose()
public sealed class StreamTicketServiceTests : IDisposable
{
rsa.Dispose();
File.Delete(privateKeyPath);
}
private readonly string privateKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
{
PrivateKeyPath = privateKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
TicketTtlSeconds = ticketTtlSeconds
});
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
[Fact]
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
{
using var service = CreateService(ticketTtlSeconds: 120);
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
Assert.Equal(120, ticket.ExpiresInSeconds);
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.Equal("42", jwt.GetClaim("sub").Value);
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
Assert.Equal("musify-webapi", jwt.Issuer);
Assert.Contains("media-gateway", jwt.Audiences);
}
[Fact]
public void IssueTicket_AnonymousUser_OmitsTheSubClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/");
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub");
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
}
[Fact]
public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000);
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.Equal("480000", jwt.GetClaim("maxBytes").Value);
}
[Fact]
public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/");
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes");
}
[Fact]
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
using var verifyingRsa = RSA.Create();
verifyingRsa.ImportFromPem(publicKeyPem);
var handler = new JsonWebTokenHandler();
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
public void Dispose()
{
ValidIssuer = "musify-webapi",
ValidAudience = "media-gateway",
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
rsa.Dispose();
File.Delete(privateKeyPath);
}
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
{
PrivateKeyPath = privateKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
TicketTtlSeconds = ticketTtlSeconds
});
Assert.True(result.IsValid);
[Fact]
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
{
using var service = CreateService(ticketTtlSeconds: 120);
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
Assert.Equal(120, ticket.ExpiresInSeconds);
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.Equal("42", jwt.GetClaim("sub").Value);
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
Assert.Equal("musify-webapi", jwt.Issuer);
Assert.Contains("media-gateway", jwt.Audiences);
}
[Fact]
public void IssueTicket_AnonymousUser_OmitsTheSubClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/");
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub");
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
}
[Fact]
public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000);
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.Equal("480000", jwt.GetClaim("maxBytes").Value);
}
[Fact]
public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/");
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(ticket.Token);
Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes");
}
[Fact]
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
{
using var service = CreateService();
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
using var verifyingRsa = RSA.Create();
verifyingRsa.ImportFromPem(publicKeyPem);
var handler = new JsonWebTokenHandler();
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
{
ValidIssuer = "musify-webapi",
ValidAudience = "media-gateway",
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
});
Assert.True(result.IsValid);
}
}
}
@@ -10,97 +10,98 @@ using Musify.Infrastructure.Persistence;
using Testcontainers.PostgreSql;
using Xunit;
namespace Musify.Infrastructure.Tests.TestSupport;
public sealed class InfrastructureTestFixture : IAsyncLifetime
namespace Musify.Infrastructure.Tests.TestSupport
{
public const string S3AccessKey = "musify-test";
public const string S3SecretKey = "musify-test-secret";
public const string S3Bucket = "musify-test-bucket";
private static readonly string[] S3Actions = ["Admin", "Read", "Write"];
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
.WithEntrypoint("/bin/sh", "-c")
.WithCommand(BuildSeaweedFsStartupScript())
.WithPortBinding(9333, assignRandomHostPort: true)
.WithPortBinding(8333, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
.Build();
public string ConnectionString => postgres.GetConnectionString();
public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
public async ValueTask InitializeAsync()
public sealed class InfrastructureTestFixture : IAsyncLifetime
{
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync());
public const string S3AccessKey = "musify-test";
public const string S3SecretKey = "musify-test-secret";
public const string S3Bucket = "musify-test-bucket";
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString });
await database.Database.MigrateAsync();
private static readonly string[] S3Actions = ["Admin", "Read", "Write"];
using var s3 = CreateS3Client();
await s3.PutBucketAsync(S3Bucket);
}
public async ValueTask DisposeAsync()
{
await postgres.DisposeAsync();
await seaweedFs.DisposeAsync();
}
public Database CreateDatabase()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)])
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
var services = new ServiceCollection();
services.AddDatabase(configuration);
return services.BuildServiceProvider().GetRequiredService<Database>();
}
private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
.WithEntrypoint("/bin/sh", "-c")
.WithCommand(BuildSeaweedFsStartupScript())
.WithPortBinding(9333, assignRandomHostPort: true)
.WithPortBinding(8333, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
.Build();
public IAmazonS3 CreateS3Client() =>
new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config
{
ServiceURL = S3ServiceUrl,
ForcePathStyle = true,
UseHttp = true,
});
public string ConnectionString => postgres.GetConnectionString();
private static string BuildSeaweedFsStartupScript()
{
var identity = JsonSerializer.Serialize(new
public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
public async ValueTask InitializeAsync()
{
identities = new[]
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync());
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString });
await database.Database.MigrateAsync();
using var s3 = CreateS3Client();
await s3.PutBucketAsync(S3Bucket);
}
public async ValueTask DisposeAsync()
{
await postgres.DisposeAsync();
await seaweedFs.DisposeAsync();
}
public Database CreateDatabase()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)])
.Build();
var services = new ServiceCollection();
services.AddDatabase(configuration);
return services.BuildServiceProvider().GetRequiredService<Database>();
}
public IAmazonS3 CreateS3Client() =>
new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config
{
new
{
name = "musify-test",
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
actions = S3Actions
}
}
});
ServiceURL = S3ServiceUrl,
ForcePathStyle = true,
UseHttp = true,
});
return $"""
private static string BuildSeaweedFsStartupScript()
{
var identity = JsonSerializer.Serialize(new
{
identities = new[]
{
new
{
name = "musify-test",
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
actions = S3Actions
}
}
});
return $"""
cat > /tmp/s3.conf <<'JSON'
{identity}
JSON
exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf
""";
}
}
[CollectionDefinition(Name)]
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
{
public const string Name = "Infrastructure";
}
}
[CollectionDefinition(Name)]
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
{
public const string Name = "Infrastructure";
}
@@ -6,159 +6,160 @@ using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration;
using Xunit;
namespace Musify.StreamingGateway.Tests.Authentication;
public sealed class TicketValidatorTests : IDisposable
namespace Musify.StreamingGateway.Tests.Authentication
{
private const string Issuer = "musify-webapi";
private const string Audience = "media-gateway";
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly JsonWebTokenHandler tokenHandler = new();
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
public void Dispose()
public sealed class TicketValidatorTests : IDisposable
{
rsa.Dispose();
File.Delete(publicKeyPath);
}
private const string Issuer = "musify-webapi";
private const string Audience = "media-gateway";
private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration
{
PublicKeyPath = publicKeyPath,
Audience = Audience,
Issuer = Issuer
});
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly JsonWebTokenHandler tokenHandler = new();
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null)
{
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
if (maxBytes != null)
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
var descriptor = new SecurityTokenDescriptor
public void Dispose()
{
Issuer = issuer,
Audience = audience,
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
return tokenHandler.CreateToken(descriptor);
}
rsa.Dispose();
File.Delete(publicKeyPath);
}
[Fact]
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
{
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.True(result.IsValid);
Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix);
Assert.Null(result.MaxBytes);
}
[Fact]
public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt()
{
var token = IssueToken(maxBytes: 480_000);
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.True(result.IsValid);
Assert.Equal(480_000, result.MaxBytes);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token)
{
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
Assert.Equal(string.Empty, result.Prefix);
}
[Fact]
public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid()
{
var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid()
{
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid()
{
var token = IssueToken(issuer: "someone-else");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid()
{
var token = IssueToken(audience: "someone-else");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid()
{
using var otherKey = RSA.Create(2048);
var descriptor = new SecurityTokenDescriptor
private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration
{
Issuer = Issuer,
PublicKeyPath = publicKeyPath,
Audience = Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
};
var token = tokenHandler.CreateToken(descriptor);
Issuer = Issuer
});
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid()
{
var descriptor = new SecurityTokenDescriptor
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null)
{
Issuer = Issuer,
Audience = Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
var token = tokenHandler.CreateToken(descriptor);
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
if (maxBytes != null)
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
var result = await CreateValidator().ValidateTicketAsync(token);
var descriptor = new SecurityTokenDescriptor
{
Issuer = issuer,
Audience = audience,
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
return tokenHandler.CreateToken(descriptor);
}
Assert.False(result.IsValid);
[Fact]
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
{
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.True(result.IsValid);
Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix);
Assert.Null(result.MaxBytes);
}
[Fact]
public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt()
{
var token = IssueToken(maxBytes: 480_000);
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.True(result.IsValid);
Assert.Equal(480_000, result.MaxBytes);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token)
{
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
Assert.Equal(string.Empty, result.Prefix);
}
[Fact]
public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid()
{
var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid()
{
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid()
{
var token = IssueToken(issuer: "someone-else");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid()
{
var token = IssueToken(audience: "someone-else");
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid()
{
using var otherKey = RSA.Create(2048);
var descriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
};
var token = tokenHandler.CreateToken(descriptor);
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
[Fact]
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid()
{
var descriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
var token = tokenHandler.CreateToken(descriptor);
var result = await CreateValidator().ValidateTicketAsync(token);
Assert.False(result.IsValid);
}
}
}
@@ -9,244 +9,245 @@ using Musify.StreamingGateway.Configuration;
using Musify.StreamingGateway.Middleware;
using Xunit;
namespace Musify.StreamingGateway.Tests.Middleware;
public sealed class TicketValidationMiddlewareTests : IDisposable
namespace Musify.StreamingGateway.Tests.Middleware
{
private const string Prefix = "Tracks/ProcessedAudios/abc";
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly StreamTicketValidationConfiguration options;
public TicketValidationMiddlewareTests()
public sealed class TicketValidationMiddlewareTests : IDisposable
{
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
options = new StreamTicketValidationConfiguration
private const string Prefix = "Tracks/ProcessedAudios/abc";
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly StreamTicketValidationConfiguration options;
public TicketValidationMiddlewareTests()
{
PublicKeyPath = publicKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
};
}
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
options = new StreamTicketValidationConfiguration
{
PublicKeyPath = publicKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
};
}
public void Dispose()
{
rsa.Dispose();
File.Delete(publicKeyPath);
}
private string IssueValidToken(string prefix = Prefix, long? maxBytes = null)
{
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
if (maxBytes != null)
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
public void Dispose()
{
Issuer = options.Issuer,
Audience = options.Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
});
}
rsa.Dispose();
File.Delete(publicKeyPath);
}
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
{
var nextCalled = false;
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
var validator = new TicketValidator(options);
var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger<TicketValidationMiddleware>.Instance);
return (middleware, () => nextCalled);
}
private string IssueValidToken(string prefix = Prefix, long? maxBytes = null)
{
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
if (maxBytes != null)
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
[Fact]
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = "/health";
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = options.Issuer,
Audience = options.Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5),
Claims = claims,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
});
}
await middleware.InvokeAsync(context);
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
{
var nextCalled = false;
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
var validator = new TicketValidator(options);
var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger<TicketValidationMiddleware>.Instance);
return (middleware, () => nextCalled);
}
Assert.True(wasNextCalled());
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = "/health";
[Fact]
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
[Fact]
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
Assert.True(wasNextCalled());
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
}
[Fact]
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
[Fact]
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
}
Assert.True(wasNextCalled());
}
[Fact]
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
[Fact]
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
}
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
[Theory]
[InlineData($"/media/{Prefix}/../../etc/passwd")]
[InlineData($"/media/{Prefix}/./audio.m4a")]
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
}
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Theory]
[InlineData($"/media/{Prefix}/../../etc/passwd")]
[InlineData($"/media/{Prefix}/./audio.m4a")]
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
[Fact]
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
Assert.True(wasNextCalled());
}
[Fact]
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
}
Assert.True(wasNextCalled());
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
}
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=1000-";
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
}
Assert.True(wasNextCalled());
Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString());
}
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=1000-";
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=480000-";
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString());
}
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=480000-";
[Fact]
public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
context.Request.Headers.Range = "bytes=1000-2000";
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
}
Assert.True(wasNextCalled());
Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString());
}
[Fact]
public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
context.Request.Headers.Range = "bytes=1000-2000";
[Theory]
[InlineData("bytes=0-10,20-30")]
[InlineData("garbage")]
[InlineData("items=0-10")]
public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange)
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = requestedRange;
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString());
}
Assert.True(wasNextCalled());
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
}
[Theory]
[InlineData("bytes=0-10,20-30")]
[InlineData("garbage")]
[InlineData("items=0-10")]
public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange)
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = requestedRange;
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=-500";
await middleware.InvokeAsync(context);
await middleware.InvokeAsync(context);
Assert.True(wasNextCalled());
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
}
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
[Fact]
public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable()
{
var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}");
context.Request.Headers.Range = "bytes=-500";
await middleware.InvokeAsync(context);
Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode);
}
}
}