Merge branch 'backend-refactor-rev' into audit-merge-three
Adapt the refactored web player to the new backend API (id path params, bulk track adds, flat list items, mix kind) and port AddAlbumToPlayList to the backend conventions.
This commit is contained in:
@@ -63,23 +63,5 @@ namespace Musify.Api.Tests.Authentication
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
|
||||
{
|
||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
|
||||
|
||||
Assert.True(user.HasClaim("role"));
|
||||
Assert.False(user.HasClaim("missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
|
||||
{
|
||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
|
||||
|
||||
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
|
||||
Assert.Null(user.FindClaim("missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Musify.Api.DataTransferObjects;
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Pictures.Responses;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@@ -13,19 +14,12 @@ namespace Musify.Api.Tests.Endpoints
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private async Task<long> CreateUserAsync()
|
||||
{
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
var response = await fixture.CreateAnonymousClient()
|
||||
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
|
||||
response.EnsureSuccessStatusCode();
|
||||
return userId;
|
||||
}
|
||||
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
|
||||
|
||||
private async Task<Guid> CreatePictureIntentAsync(HttpClient client)
|
||||
{
|
||||
fixture.StorageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.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>())
|
||||
@@ -33,11 +27,11 @@ namespace Musify.Api.Tests.Endpoints
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/albums/upload-picture",
|
||||
new RequestAlbumPictureUploadRequest("webp", "image/webp"),
|
||||
new PictureUploadRequest("webp", "image/webp"),
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<AlbumPictureUploadResponse>(TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadFromJsonAsync<PictureUploadResponse>(TestContext.Current.CancellationToken);
|
||||
return body!.IntentId;
|
||||
}
|
||||
|
||||
@@ -103,7 +97,7 @@ namespace Musify.Api.Tests.Endpoints
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
|
||||
public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = await CreateUserAsync();
|
||||
var stranger = await CreateUserAsync();
|
||||
@@ -114,7 +108,7 @@ namespace Musify.Api.Tests.Endpoints
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,7 +4,8 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Genres.Responses;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
@@ -39,7 +40,6 @@ namespace Musify.Api.Tests.Endpoints
|
||||
tag.TrackId = track.Id;
|
||||
database.Users.Add(owner);
|
||||
database.Tracks.Add(track);
|
||||
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return title;
|
||||
}
|
||||
@@ -85,8 +85,8 @@ namespace Musify.Api.Tests.Endpoints
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
var body = await response.Content.ReadFromJsonAsync<PaginatedResponse<TrackApplicationResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Title == title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -98,9 +98,9 @@ namespace Musify.Api.Tests.Endpoints
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,17 @@ using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private async Task<long> CreateUserAsync()
|
||||
{
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
|
||||
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return userId;
|
||||
}
|
||||
private Task<long> CreateUserAsync() => fixture.SeedUserAsync();
|
||||
|
||||
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Api.DataTransferObjects.PlayLists;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class PlayListEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
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))!;
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
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);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
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);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync(
|
||||
$"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct);
|
||||
|
||||
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);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlayListById_NotAGuid_ReturnsNotFound()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.Users.Responses;
|
||||
@@ -39,46 +38,20 @@ namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = track.Id,
|
||||
ListenedAt = listenedAt
|
||||
ListenedAt = listenedAt,
|
||||
IsCounted = true
|
||||
});
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
|
||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("Jane Doe", body.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUsers_BlankName_ReturnsValidationProblem()
|
||||
public async Task PostUsers_IsNotExposed()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
|
||||
var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUsers_DuplicateId_ReturnsConflict()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,7 +59,7 @@ namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
|
||||
await fixture.SeedUserAsync(userId, "Findable User");
|
||||
|
||||
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -110,7 +83,7 @@ namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||
await fixture.SeedUserAsync(userId);
|
||||
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
|
||||
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
|
||||
|
||||
@@ -122,15 +95,48 @@ namespace Musify.Api.Tests.Endpoints
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound()
|
||||
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||
await fixture.SeedUserAsync(userId);
|
||||
|
||||
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, 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();
|
||||
|
||||
var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken);
|
||||
|
||||
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);
|
||||
|
||||
var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken);
|
||||
|
||||
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);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
@@ -44,10 +45,27 @@ namespace Musify.Api.Tests.TestSupport
|
||||
|
||||
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
|
||||
{
|
||||
Id = id,
|
||||
Name = userName,
|
||||
NormalizedName = userName.ToUpperInvariant()
|
||||
});
|
||||
await database.SaveChangesAsync();
|
||||
return id;
|
||||
}
|
||||
|
||||
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
|
||||
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture));
|
||||
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
using Musify.Api.Validators;
|
||||
using Musify.Api.Validators.Albums;
|
||||
using Xunit;
|
||||
|
||||
@@ -44,7 +45,7 @@ namespace Musify.Api.Tests.Validators
|
||||
[Fact]
|
||||
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid()));
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid()));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
@@ -52,7 +53,7 @@ namespace Musify.Api.Tests.Validators
|
||||
[Fact]
|
||||
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid()));
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid()));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
@@ -60,7 +61,7 @@ namespace Musify.Api.Tests.Validators
|
||||
[Fact]
|
||||
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid()));
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid()));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
@@ -68,7 +69,7 @@ namespace Musify.Api.Tests.Validators
|
||||
[Fact]
|
||||
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid()));
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid()));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Validators.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Validators
|
||||
{
|
||||
public sealed class CreateUserRequestValidatorTests
|
||||
{
|
||||
private readonly CreateUserRequestValidator validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRequest_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroId_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BlankName_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameOverMaxLength_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OptionalNamesOmitted_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.Validators.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Validators
|
||||
{
|
||||
public sealed class TrackRequestValidatorTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTrack_NoTags_Fails()
|
||||
{
|
||||
var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []);
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordListeningProgress_ZeroSeconds_Passes()
|
||||
{
|
||||
Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Musify.Api.DataTransferObjects;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.Validators;
|
||||
using Musify.Api.Validators.Tracks;
|
||||
using Musify.Application.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Validators
|
||||
{
|
||||
public sealed class UploadRequestValidatorTests
|
||||
{
|
||||
private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 };
|
||||
|
||||
[Fact]
|
||||
public void PictureUpload_ValidRequest_Passes()
|
||||
{
|
||||
var validator = new PictureUploadRequestValidator(configuration);
|
||||
|
||||
var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size));
|
||||
|
||||
Assert.False(result.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-15
@@ -7,9 +7,9 @@ using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums
|
||||
{
|
||||
public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
||||
public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>());
|
||||
private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FirstTrack_AddsItAtNumberOne()
|
||||
@@ -19,7 +19,7 @@ namespace Musify.Application.Tests.Albums
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, album, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||
@@ -33,13 +33,13 @@ namespace Musify.Application.Tests.Albums
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -47,9 +47,9 @@ namespace Musify.Application.Tests.Albums
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, stranger, album, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,13 +59,13 @@ namespace Musify.Application.Tests.Albums
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
|
||||
public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var otherOwner = TestEntities.User(2, "other");
|
||||
@@ -73,13 +73,13 @@ namespace Musify.Application.Tests.Albums
|
||||
var foreignTrack = TestEntities.Track(otherOwner);
|
||||
await SeedAsync(owner, otherOwner, album, foreignTrack);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
|
||||
public async Task Handle_AlreadyInAlbum_IsSkipped()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
@@ -87,9 +87,10 @@ namespace Musify.Application.Tests.Albums
|
||||
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
||||
await SeedAsync(owner, album, track, link);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
Assert.False(result.IsError);
|
||||
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,7 +103,7 @@ namespace Musify.Application.Tests.Albums
|
||||
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
|
||||
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken);
|
||||
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);
|
||||
@@ -16,11 +16,9 @@ namespace Musify.Application.Tests.Albums
|
||||
private CreateAlbumCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
|
||||
NoOpLogger<CreateAlbumCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Album(),
|
||||
TestConfigurations.UploadIntent());
|
||||
TestConfigurations.Album());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent()
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums
|
||||
{
|
||||
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
|
||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||
|
||||
private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteAlbumCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_RemovesAlbum()
|
||||
public async Task Handle_Owner_MarksAlbumAsRemoving()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
@@ -19,7 +24,10 @@ namespace Musify.Application.Tests.Albums
|
||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
||||
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]
|
||||
@@ -31,7 +39,7 @@ namespace Musify.Application.Tests.Albums
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
|
||||
public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -40,7 +48,7 @@ namespace Musify.Application.Tests.Albums
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Albums
|
||||
{
|
||||
public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetAlbumCoverQueryHandler CreateHandler() =>
|
||||
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album());
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", "small.webp")]
|
||||
[InlineData("large", "large.webp")]
|
||||
[InlineData("medium", "medium.webp")]
|
||||
public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
await SeedAsync(owner, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,8 @@ namespace Musify.Application.Tests.Albums
|
||||
await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.All(result.Value.Items, item => Assert.NotNull(item.Album));
|
||||
Assert.Equal(2, result.TotalItemCount);
|
||||
Assert.All(result.Items, item => Assert.NotNull(item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -28,10 +26,8 @@ namespace Musify.Application.Tests.Albums
|
||||
await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Rock Classics", item.Album.Title);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("Rock Classics", item.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,10 +37,8 @@ namespace Musify.Application.Tests.Albums
|
||||
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.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Items.Count);
|
||||
Assert.True(result.Value.HasNextPage);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
Assert.True(result.HasNextPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-10
@@ -25,9 +25,7 @@ namespace Musify.Application.Tests.Albums
|
||||
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
|
||||
Assert.Equal(["Newer", "Older"], result.Select(album => album.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -37,9 +35,7 @@ namespace Musify.Application.Tests.Albums
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,9 +49,7 @@ namespace Musify.Application.Tests.Albums
|
||||
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +69,7 @@ namespace Musify.Application.Tests.Albums
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
|
||||
Assert.Equal(["Counted album"], result.Select(album => album.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ namespace Musify.Application.Tests.Albums
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -55,7 +55,7 @@ namespace Musify.Application.Tests.Albums
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+11
-8
@@ -17,17 +17,20 @@ namespace Musify.Application.Tests.Albums
|
||||
private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||
{
|
||||
storageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.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");
|
||||
|
||||
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
|
||||
|
||||
return new RequestAlbumPictureUploadCommandHandler(
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
storageService,
|
||||
NoOpLogger<RequestAlbumPictureUploadCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Album(),
|
||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||
new UploadIntentIssuer(
|
||||
Database,
|
||||
storageService,
|
||||
new UploadIntentValidator(Database, storageService, config),
|
||||
TestConfigurations.Storage(),
|
||||
config,
|
||||
NoOpLogger<UploadIntentIssuer>()),
|
||||
TestConfigurations.Album());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -16,11 +16,9 @@ namespace Musify.Application.Tests.Albums
|
||||
private UpdateAlbumCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
|
||||
NoOpLogger<UpdateAlbumCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Album(),
|
||||
TestConfigurations.UploadIntent());
|
||||
TestConfigurations.Album());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
|
||||
@@ -77,7 +75,7 @@ namespace Musify.Application.Tests.Albums
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -88,7 +86,7 @@ namespace Musify.Application.Tests.Albums
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Musify.Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Musify.Application\Application.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Musify.Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Musify.Application.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Configuration
|
||||
{
|
||||
public sealed class PicturesConfigurationTests
|
||||
{
|
||||
[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"));
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Genres.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
@@ -26,10 +25,7 @@ namespace Musify.Application.Tests.Genres
|
||||
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,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
owner, rockA, rockB, jazz);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -38,32 +34,5 @@ namespace Musify.Application.Tests.Genres
|
||||
result.Select(r => (r.Genre, r.TrackCount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var other = TestEntities.User(id: 2, name: "other-user");
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
|
||||
await SeedAsync(
|
||||
owner, other, track,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, Assert.Single(result).TrackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ namespace Musify.Application.Tests.Likes
|
||||
new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title));
|
||||
Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,9 +31,7 @@ namespace Musify.Application.Tests.Likes
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value.Items);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,9 +43,7 @@ namespace Musify.Application.Tests.Likes
|
||||
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.False(result.IsError);
|
||||
Assert.Empty(result.Value.Items);
|
||||
Assert.Empty(result.Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-21
@@ -1,7 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes
|
||||
@@ -17,9 +17,7 @@ namespace Musify.Application.Tests.Mixes
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
@@ -30,15 +28,12 @@ namespace Musify.Application.Tests.Mixes
|
||||
var track = TestEntities.Track(user);
|
||||
await SeedAsync(
|
||||
user, track,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = track.Id },
|
||||
TestEntities.ListeningHistory(user.Id, track.Id));
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Single(mixes);
|
||||
Assert.Equal("Tu mezcla diaria", mixes[0].Title);
|
||||
Assert.Equal(MixKind.Daily, mixes[0].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,25 +43,21 @@ namespace Musify.Application.Tests.Mixes
|
||||
var listenedTrack = TestEntities.Track(user, "Listened Track");
|
||||
var unheardTrack = TestEntities.Track(user, "Unheard Track");
|
||||
|
||||
var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
|
||||
var staleMix = TestEntities.Mix(user.Id, MixKind.Daily);
|
||||
var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id);
|
||||
|
||||
await SeedAsync(
|
||||
user, listenedTrack, unheardTrack, staleMix, staleItem,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id },
|
||||
new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id },
|
||||
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
||||
|
||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria");
|
||||
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));
|
||||
|
||||
var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento");
|
||||
var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery);
|
||||
var discoveryItems = await Database.MixItems
|
||||
.Where(item => item.MixId == discoveryMix.Id)
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
@@ -80,13 +71,12 @@ namespace Musify.Application.Tests.Mixes
|
||||
var skipped = TestEntities.Track(user, "Only skipped");
|
||||
await SeedAsync(
|
||||
user, skipped,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
|
||||
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes
|
||||
@@ -15,7 +16,7 @@ namespace Musify.Application.Tests.Mixes
|
||||
var owner = TestEntities.User();
|
||||
var firstTrack = TestEntities.Track(owner, "First");
|
||||
var secondTrack = TestEntities.Track(owner, "Second");
|
||||
var mix = TestEntities.Mix(owner.Id, "Discovery");
|
||||
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);
|
||||
@@ -23,7 +24,7 @@ namespace Musify.Application.Tests.Mixes
|
||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Discovery", result.Value.Title);
|
||||
Assert.Equal(MixKind.Daily, result.Value.Kind);
|
||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Mixes
|
||||
@@ -13,15 +14,13 @@ namespace Musify.Application.Tests.Mixes
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var other = TestEntities.User(2, "other");
|
||||
var second = TestEntities.Mix(owner.Id, "Second", position: 1);
|
||||
var first = TestEntities.Mix(owner.Id, "First", position: 0);
|
||||
var theirs = TestEntities.Mix(other.Id, "Theirs");
|
||||
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);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
|
||||
Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -31,9 +30,7 @@ namespace Musify.Application.Tests.Mixes
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Pictures;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Pictures
|
||||
{
|
||||
public sealed class GetCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
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)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
Assert.Equal("image/webp", result.Value.ContentType);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ProcessedPlayList_ReturnsRequestedSize()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.EndsWith("large.webp", result.Value.Key);
|
||||
}
|
||||
|
||||
[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.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -63,7 +63,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -73,7 +73,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+49
-12
@@ -7,9 +7,9 @@ using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists
|
||||
{
|
||||
public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
||||
public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>());
|
||||
private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTracksToPlayListCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FirstTrack_AddsItAtPositionZero()
|
||||
@@ -19,7 +19,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, playList, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||
@@ -37,7 +37,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
owner, playList, existingTrack, newTrack,
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
|
||||
@@ -47,22 +47,22 @@ namespace Musify.Application.Tests.PlayLists
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
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 AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,22 +72,59 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict()
|
||||
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 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
Assert.False(result.IsError);
|
||||
Assert.Single(await Database.PlayListHasTracks.ToListAsync(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 });
|
||||
|
||||
var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("Test.Duplicate", result.FirstError.Code);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -16,11 +16,9 @@ namespace Musify.Application.Tests.PlayLists
|
||||
private CreatePlayListCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
|
||||
NoOpLogger<CreatePlayListCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
TestConfigurations.UploadIntent());
|
||||
TestConfigurations.PlayList());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
|
||||
@@ -71,7 +69,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility);
|
||||
Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -80,12 +78,12 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var user = TestEntities.User();
|
||||
await SeedAsync(user);
|
||||
|
||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
|
||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
|
||||
|
||||
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(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -48,7 +48,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -14,7 +14,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
|
||||
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 });
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||
@@ -50,7 +50,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
|
||||
await SeedAsync(owner, stranger, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken);
|
||||
@@ -62,7 +62,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
|
||||
@@ -74,7 +74,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists
|
||||
{
|
||||
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListCoverQueryHandler CreateHandler() =>
|
||||
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", "small.webp")]
|
||||
[InlineData("large", "large.webp")]
|
||||
[InlineData("medium", "medium.webp")]
|
||||
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListWithoutPictures_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
playList.Pictures = null;
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -2,6 +2,7 @@ using ErrorOr;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists
|
||||
@@ -22,7 +23,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||
@@ -35,5 +36,29 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-15
@@ -20,39 +20,46 @@ namespace Musify.Application.Tests.PlayLists
|
||||
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
||||
TestEntities.PlayList(other.Id, "Theirs"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var playList = Assert.Single(result.Value.Items);
|
||||
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_OnlyPublic_ReturnsOnlyPublicPlayLists()
|
||||
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));
|
||||
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, OnlyPublic: true), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var playList = Assert.Single(result.Value.Items);
|
||||
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_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: owner.Id), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
}
|
||||
|
||||
[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), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var playList = Assert.Single(result.Value.Items);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists
|
||||
{
|
||||
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetPlayListsQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryPlayListPaged()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoPlayLists_ReturnsEmptyPage()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Empty(result.Value.Items);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -33,7 +33,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -42,7 +42,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,5 +56,27 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[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 });
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -17,17 +17,20 @@ namespace Musify.Application.Tests.PlayLists
|
||||
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||
{
|
||||
storageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.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");
|
||||
|
||||
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
|
||||
|
||||
return new RequestPlayListPictureUploadCommandHandler(
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
storageService,
|
||||
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||
new UploadIntentIssuer(
|
||||
Database,
|
||||
storageService,
|
||||
new UploadIntentValidator(Database, storageService, config),
|
||||
TestConfigurations.Storage(),
|
||||
config,
|
||||
NoOpLogger<UploadIntentIssuer>()),
|
||||
TestConfigurations.PlayList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+11
-13
@@ -16,11 +16,9 @@ namespace Musify.Application.Tests.PlayLists
|
||||
private UpdatePlayListCommandHandler CreateHandler() => new(
|
||||
eventBus,
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
|
||||
NoOpLogger<UpdatePlayListCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.PlayList(),
|
||||
TestConfigurations.UploadIntent());
|
||||
TestConfigurations.PlayList());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Owner_RenamesPlayList()
|
||||
@@ -48,7 +46,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -59,7 +57,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,19 +67,19 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public);
|
||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public);
|
||||
|
||||
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(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
|
||||
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
|
||||
@@ -89,11 +87,11 @@ namespace Musify.Application.Tests.PlayLists
|
||||
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(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
|
||||
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
||||
@@ -105,7 +103,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Original Name", result.Value.Name);
|
||||
Assert.Equal("Original description", result.Value.Description);
|
||||
Assert.Null(result.Value.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,7 +124,7 @@ namespace Musify.Application.Tests.PlayLists
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName);
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
@@ -8,90 +7,56 @@ using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Services
|
||||
{
|
||||
public sealed class TrackStreamIssuerTests : HandlerTestBase
|
||||
public sealed class TrackStreamIssuerTests
|
||||
{
|
||||
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(
|
||||
Database,
|
||||
ticketService,
|
||||
TestConfigurations.Track(),
|
||||
TestConfigurations.StreamGateway("https://stream.musify.test/"),
|
||||
playback ?? TestConfigurations.Playback());
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
||||
public void Issue_BuildsManifestUrlAndCarriesTheListenId()
|
||||
{
|
||||
ticketService.IssueTicket(7, Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("signed-token", 120));
|
||||
var owner = TestEntities.User(7);
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
var listenId = Guid.NewGuid();
|
||||
|
||||
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken);
|
||||
var response = CreateIssuer().Issue("folder-42", userId: 7, listenId);
|
||||
|
||||
Assert.Equal("signed-token", response.Ticket);
|
||||
Assert.Equal(120, response.ExpiresInSeconds);
|
||||
Assert.Equal(
|
||||
"https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a",
|
||||
response.ManifestUrl);
|
||||
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(7, history.UserId);
|
||||
Assert.Equal(track.Id, history.TrackId);
|
||||
Assert.Equal(listenId, response.ListenId);
|
||||
Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
||||
public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||
var owner = TestEntities.User(1);
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
||||
CreateIssuer().Issue("folder-42", userId: 1, listenId: null);
|
||||
|
||||
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows()
|
||||
public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||
var owner = TestEntities.User(1);
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true))
|
||||
.IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("token", response.Ticket);
|
||||
Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||
var owner = TestEntities.User(1);
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||
await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken);
|
||||
|
||||
CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null);
|
||||
|
||||
ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap()
|
||||
public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap()
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("token", 60));
|
||||
var owner = TestEntities.User(1);
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||
await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
||||
|
||||
CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null);
|
||||
|
||||
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
@@ -13,7 +14,8 @@ namespace Musify.Application.Tests.Services
|
||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||
private readonly Domain.Entities.User owner = TestEntities.User();
|
||||
|
||||
private UploadIntentValidator CreateValidator() => new(Database, storageService);
|
||||
private UploadIntentValidator CreateValidator(UploadIntentConfiguration? config = null) =>
|
||||
new(Database, storageService, config ?? TestConfigurations.UploadIntent());
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
|
||||
@@ -25,16 +27,27 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, 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);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
@@ -46,7 +59,7 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, stranger, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
@@ -57,7 +70,7 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
@@ -68,7 +81,7 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
@@ -83,7 +96,7 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
@@ -101,7 +114,7 @@ namespace Musify.Application.Tests.Services
|
||||
var intent = TestEntities.UploadIntent(owner.Id);
|
||||
await SeedAsync(owner, intent);
|
||||
|
||||
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
@@ -111,7 +124,7 @@ namespace Musify.Application.Tests.Services
|
||||
{
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
||||
var result = await CreateValidator().CheckQuotaAsync(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
}
|
||||
@@ -123,7 +136,7 @@ namespace Musify.Application.Tests.Services
|
||||
config.MaxActiveUploadIntentsPerUser = 1;
|
||||
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(config, 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);
|
||||
}
|
||||
@@ -138,7 +151,7 @@ namespace Musify.Application.Tests.Services
|
||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
|
||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
|
||||
|
||||
var result = await CreateValidator().CheckQuotaAsync(config, 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.False(result.IsError);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace Musify.Application.Tests.TestSupport
|
||||
|
||||
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
|
||||
|
||||
public ValueTask DisposeAsync() => database.DisposeAsync();
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
return database.DisposeAsync();
|
||||
}
|
||||
|
||||
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
|
||||
namespace Musify.Application.Tests.TestSupport
|
||||
{
|
||||
@@ -33,99 +35,7 @@ namespace Musify.Application.Tests.TestSupport
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures);
|
||||
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
|
||||
|
||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
|
||||
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
|
||||
|
||||
modelBuilder.Entity<Track>()
|
||||
.HasOne(track => track.Owner)
|
||||
.WithMany()
|
||||
.HasForeignKey(track => track.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures, pictures =>
|
||||
{
|
||||
pictures.Property(p => p.OriginalName).IsRequired(false);
|
||||
pictures.Property(p => p.SmallName).IsRequired(false);
|
||||
pictures.Property(p => p.MediumName).IsRequired(false);
|
||||
pictures.Property(p => p.LargeName).IsRequired(false);
|
||||
});
|
||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<Album>()
|
||||
.HasOne(album => album.Owner)
|
||||
.WithMany()
|
||||
.HasForeignKey(album => album.OwnerUserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasOne(albumTrack => albumTrack.Album)
|
||||
.WithMany(album => album.AlbumTracks)
|
||||
.HasForeignKey(albumTrack => albumTrack.AlbumId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasOne(albumTrack => albumTrack.Track)
|
||||
.WithMany(track => track.AlbumTracks)
|
||||
.HasForeignKey(albumTrack => albumTrack.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<AlbumHasTrack>()
|
||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<Mix>()
|
||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasOne(item => item.Mix)
|
||||
.WithMany(mix => mix.Items)
|
||||
.HasForeignKey(item => item.MixId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasOne(item => item.Track)
|
||||
.WithMany()
|
||||
.HasForeignKey(item => item.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<MixItem>()
|
||||
.HasIndex(item => new { item.MixId, item.Position });
|
||||
|
||||
modelBuilder.Entity<TrackLike>()
|
||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasOne(trackTag => trackTag.Track)
|
||||
.WithMany(track => track.Tags)
|
||||
.HasForeignKey(trackTag => trackTag.TrackId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TrackTag>()
|
||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<UserFollow>()
|
||||
.HasOne(follow => follow.Follower)
|
||||
.WithMany()
|
||||
.HasForeignKey(follow => follow.FollowerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<UserFollow>()
|
||||
.HasOne(follow => follow.Followed)
|
||||
.WithMany()
|
||||
.HasForeignKey(follow => follow.FollowedId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<UserFollow>()
|
||||
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
|
||||
.IsUnique();
|
||||
ModelConfiguration.Apply(modelBuilder);
|
||||
}
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
@@ -138,16 +48,12 @@ namespace Musify.Application.Tests.TestSupport
|
||||
|
||||
public DbSet<Track> Tracks => Set<Track>();
|
||||
|
||||
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
||||
|
||||
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
||||
|
||||
public DbSet<Mix> Mixes => Set<Mix>();
|
||||
|
||||
public DbSet<MixItem> MixItems => Set<MixItem>();
|
||||
|
||||
public DbSet<Upload> Uploads => Set<Upload>();
|
||||
|
||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||
|
||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
||||
@@ -164,6 +70,19 @@ namespace Musify.Application.Tests.TestSupport
|
||||
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();
|
||||
|
||||
@@ -79,7 +79,7 @@ namespace Musify.Application.Tests.TestSupport
|
||||
return track;
|
||||
}
|
||||
|
||||
public static PlayListPictures PlayListPictures() => new()
|
||||
public static EntityPictures PlayListPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
@@ -91,12 +91,12 @@ namespace Musify.Application.Tests.TestSupport
|
||||
long userId,
|
||||
string name = "Test Playlist",
|
||||
string? description = null,
|
||||
PlayListPictures? pictures = null,
|
||||
EntityPictures? pictures = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
||||
PlaylistVisibility visibility = PlaylistVisibility.Private) =>
|
||||
PlayListVisibility visibility = PlayListVisibility.Private) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
OwnerUserId = userId,
|
||||
Name = name,
|
||||
NormalizedName = name.ToUpperInvariant(),
|
||||
Description = description,
|
||||
@@ -105,7 +105,7 @@ namespace Musify.Application.Tests.TestSupport
|
||||
Visibility = visibility
|
||||
};
|
||||
|
||||
public static AlbumPictures AlbumPictures() => new()
|
||||
public static EntityPictures AlbumPictures() => new()
|
||||
{
|
||||
OriginalName = "original.webp",
|
||||
SmallName = "small.webp",
|
||||
@@ -118,7 +118,7 @@ namespace Musify.Application.Tests.TestSupport
|
||||
string title = "Test Album",
|
||||
string? description = null,
|
||||
int? releaseYear = 2024,
|
||||
AlbumPictures? pictures = null) =>
|
||||
EntityPictures? pictures = null) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
@@ -154,14 +154,12 @@ namespace Musify.Application.Tests.TestSupport
|
||||
|
||||
public static Mix Mix(
|
||||
long userId,
|
||||
string title = "Test Mix",
|
||||
string? subtitle = null,
|
||||
MixKind kind = MixKind.Discovery,
|
||||
int position = 0) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Kind = kind,
|
||||
Position = position
|
||||
};
|
||||
|
||||
|
||||
@@ -24,17 +24,28 @@ namespace Musify.Application.Tests.Tracks
|
||||
return new CreateTrackCommandHandler(
|
||||
Database,
|
||||
eventBus,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()),
|
||||
NoOpLogger<CreateTrackCommandHandler>(),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Track(),
|
||||
TestConfigurations.UploadIntent());
|
||||
TestConfigurations.Track());
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]);
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
||||
{
|
||||
@@ -78,34 +89,6 @@ namespace Musify.Application.Tests.Tracks
|
||||
Assert.Equal(isExplicit, stored.IsExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTags_ReturnsValidationError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IncompatibleTags_ReturnsValidationError()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
@@ -48,7 +48,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-5
@@ -1,4 +1,3 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
@@ -22,19 +21,19 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Newer listen", result.Value.Title);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Newer listen", result.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoHistory_ReturnsNotFound()
|
||||
public async Task Handle_NoHistory_ReturnsNull()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
await SeedAsync(owner);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTrackCoverQueryHandler CreateHandler() =>
|
||||
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
|
||||
|
||||
[Theory]
|
||||
[InlineData("small", "small.webp")]
|
||||
[InlineData("medium", "medium.webp")]
|
||||
[InlineData("large", "large.webp")]
|
||||
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
|
||||
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||
Assert.Equal("image/webp", result.Value.ContentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures());
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-19
@@ -1,6 +1,5 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
@@ -17,9 +16,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var mine = TestEntities.Track(owner, "Mine");
|
||||
var theirs = TestEntities.Track(other, "Theirs");
|
||||
await SeedAsync(
|
||||
owner, other, mine, theirs,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
|
||||
owner, other, mine, theirs);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -27,20 +24,5 @@ namespace Musify.Application.Tests.Tracks
|
||||
var track = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Mine", track.Title);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-5, 1)]
|
||||
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,15 +16,11 @@ namespace Musify.Application.Tests.Tracks
|
||||
var trackA = TestEntities.Track(owner, "Alpha");
|
||||
var trackB = TestEntities.Track(owner, "Beta");
|
||||
await SeedAsync(
|
||||
owner, trackA, trackB,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id });
|
||||
owner, trackA, trackB);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.Equal(2, result.Value.Items.Count);
|
||||
Assert.Equal(2, result.TotalItemCount);
|
||||
Assert.Equal(2, result.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -34,15 +30,11 @@ namespace Musify.Application.Tests.Tracks
|
||||
var match = TestEntities.Track(owner, "Bohemian Rhapsody");
|
||||
var other = TestEntities.Track(owner, "Imagine");
|
||||
await SeedAsync(
|
||||
owner, match, other,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
|
||||
owner, match, other);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,15 +44,11 @@ namespace Musify.Application.Tests.Tracks
|
||||
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,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
owner, rock, jazz);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Loud", item.Track.Title);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("Loud", item.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-13
@@ -110,18 +110,5 @@ namespace Musify.Application.Tests.Tracks
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds)
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -18,17 +18,22 @@ namespace Musify.Application.Tests.Tracks
|
||||
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||
{
|
||||
storageService
|
||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||
.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");
|
||||
|
||||
var config = uploadIntentConfig ?? TestConfigurations.UploadIntent();
|
||||
|
||||
return new RequestTrackUploadUrlsCommandHandler(
|
||||
Database,
|
||||
new UploadIntentValidator(Database, storageService),
|
||||
storageService,
|
||||
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
|
||||
new UploadIntentIssuer(
|
||||
Database,
|
||||
storageService,
|
||||
new UploadIntentValidator(Database, storageService, config),
|
||||
TestConfigurations.Storage(),
|
||||
config,
|
||||
NoOpLogger<UploadIntentIssuer>()),
|
||||
TestConfigurations.Storage(),
|
||||
TestConfigurations.Track(),
|
||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||
config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+8
-9
@@ -10,22 +10,21 @@ using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
|
||||
public sealed class StartListeningCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||
|
||||
private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null)
|
||||
private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null)
|
||||
{
|
||||
ticketService.IssueTicket(Arg.Any<long?>(), Arg.Any<string>(), Arg.Any<long?>()).Returns(new StreamTicket("ticket-token", 60));
|
||||
|
||||
var issuer = new TrackStreamIssuer(
|
||||
Database,
|
||||
ticketService,
|
||||
TestConfigurations.Track(),
|
||||
TestConfigurations.StreamGateway("https://stream.musify.test"),
|
||||
playback ?? TestConfigurations.Playback());
|
||||
|
||||
return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger<GetTrackStreamQueryHandler>());
|
||||
return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger<StartListeningCommandHandler>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -35,7 +34,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("ticket-token", result.Value.Ticket);
|
||||
@@ -53,7 +52,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
[Fact]
|
||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
@@ -65,7 +64,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio());
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||
var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
@@ -79,7 +78,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false));
|
||||
|
||||
var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken);
|
||||
var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
@@ -93,7 +92,7 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true));
|
||||
|
||||
var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), 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);
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.UploadIntents;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.UploadIntents
|
||||
{
|
||||
public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
[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);
|
||||
|
||||
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>());
|
||||
|
||||
await handler.Handle(new ExpireUploadIntentsCommand(), 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>());
|
||||
|
||||
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>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StorageObject> Objects(params StorageObject[] objects)
|
||||
{
|
||||
foreach (var item in objects)
|
||||
yield return item;
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users
|
||||
{
|
||||
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NewId_CreatesUser()
|
||||
{
|
||||
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal("Jane Doe", result.Value.Name);
|
||||
Assert.Equal(1, result.Value.Id);
|
||||
|
||||
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("JANE DOE", stored.NormalizedName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_IdAlreadyExists_ReturnsConflict()
|
||||
{
|
||||
var existing = TestEntities.User(1);
|
||||
await SeedAsync(existing);
|
||||
|
||||
var command = new CreateUserCommand(1, "Someone Else", null, null);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ namespace Musify.Application.Tests.Users
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoListenToday_StreakIsZero()
|
||||
public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user);
|
||||
@@ -112,6 +112,20 @@ namespace Musify.Application.Tests.Users
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, result.StreakDays);
|
||||
}
|
||||
|
||||
[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)));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(0, result.StreakDays);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ namespace Musify.Application.Tests.Users
|
||||
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.Equal(2, result.TotalItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,9 +24,7 @@ namespace Musify.Application.Tests.Users
|
||||
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var user = Assert.Single(result.Value.Items);
|
||||
var user = Assert.Single(result.Items);
|
||||
Assert.Equal(1, user.Id);
|
||||
}
|
||||
|
||||
@@ -40,9 +36,7 @@ namespace Musify.Application.Tests.Users
|
||||
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.False(result.IsError);
|
||||
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
||||
Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Musify.Infrastructure.Jobs;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Jobs
|
||||
{
|
||||
public sealed class RecurringJobsRegistrarTests
|
||||
{
|
||||
[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.MassTransit.RoutingSlip;
|
||||
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)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow()
|
||||
{
|
||||
var album = await SeedAlbumAsync(new EntityPictures
|
||||
{
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.MassTransit.Activities.LifeCycle;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.MassTransit
|
||||
{
|
||||
[Collection(InfrastructureCollection.Name)]
|
||||
public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task MarkTrack_ExistingTrack_SetsTheStatus()
|
||||
{
|
||||
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));
|
||||
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using MassTransit;
|
||||
using MassTransit.Courier.Contracts;
|
||||
using MassTransit.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Infrastructure.MassTransit.Consumers;
|
||||
using Musify.Infrastructure.MassTransit.RoutingSlip;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.MassTransit
|
||||
{
|
||||
public sealed class ProcessingSlipFaultConsumerTests
|
||||
{
|
||||
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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using MassTransit;
|
||||
using MassTransit.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Infrastructure.MassTransit.Sagas;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.MassTransit
|
||||
{
|
||||
public sealed class ProcessingStateMachineTests
|
||||
{
|
||||
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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using MassTransit.Courier.Contracts;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.MassTransit.Consumers;
|
||||
using Musify.Infrastructure.MassTransit.RoutingSlip;
|
||||
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.MassTransit
|
||||
{
|
||||
public sealed class RoutingSlipBuilderTests
|
||||
{
|
||||
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()
|
||||
{
|
||||
var slip = RoutingSlips.Create(CorrelationId).Build();
|
||||
|
||||
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 Create_WithoutCorrelationId_UsesEmptyGuid()
|
||||
{
|
||||
var slip = RoutingSlips.Create(null).Build();
|
||||
|
||||
Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription()
|
||||
{
|
||||
var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder()
|
||||
{
|
||||
var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes);
|
||||
|
||||
var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build();
|
||||
|
||||
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent);
|
||||
}
|
||||
|
||||
[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();
|
||||
|
||||
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent);
|
||||
}
|
||||
|
||||
[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();
|
||||
|
||||
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent);
|
||||
}
|
||||
|
||||
[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();
|
||||
|
||||
AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent);
|
||||
}
|
||||
|
||||
[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 AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build();
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using MassTransit;
|
||||
using MassTransit.Courier.Contracts;
|
||||
using MassTransit.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Events;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.MassTransit.Activities.Pictures;
|
||||
using Musify.Infrastructure.MassTransit.Arguments;
|
||||
using Musify.Infrastructure.MassTransit.Logs;
|
||||
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)
|
||||
{
|
||||
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()
|
||||
{
|
||||
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");
|
||||
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));
|
||||
}
|
||||
|
||||
[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 () =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed()
|
||||
{
|
||||
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");
|
||||
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 () =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services
|
||||
{
|
||||
public sealed class SingleFlightCacheTests
|
||||
{
|
||||
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||
|
||||
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||
|
||||
Assert.Equal(1, first.Value);
|
||||
Assert.Equal(1, second.Value);
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
var gate = new TaskCompletionSource();
|
||||
|
||||
async Task<ErrorOr<int>> Factory()
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
await gate.Task;
|
||||
return 7;
|
||||
}
|
||||
|
||||
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||
gate.SetResult();
|
||||
|
||||
var results = await Task.WhenAll(first, second);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.All(results, result => Assert.Equal(7, result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory()
|
||||
{
|
||||
calls++;
|
||||
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom"));
|
||||
}
|
||||
|
||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||
|
||||
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using Xunit;
|
||||
@@ -13,12 +12,10 @@ namespace Musify.Infrastructure.Tests.Services
|
||||
[Collection(InfrastructureCollection.Name)]
|
||||
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
|
||||
{
|
||||
private Database database = null!;
|
||||
private StorageService service = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
database = fixture.CreateDatabase();
|
||||
var storageConfiguration = new InfrastructureStorageConfiguration
|
||||
{
|
||||
Address = fixture.S3ServiceUrl,
|
||||
@@ -27,11 +24,11 @@ namespace Musify.Infrastructure.Tests.Services
|
||||
ForcePathStyle = true,
|
||||
UseHttp = true,
|
||||
};
|
||||
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
||||
service = new StorageService(fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => database.DisposeAsync();
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
||||
@@ -42,12 +39,21 @@ namespace Musify.Infrastructure.Tests.Services
|
||||
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()
|
||||
{
|
||||
@@ -105,28 +111,13 @@ namespace Musify.Infrastructure.Tests.Services
|
||||
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
|
||||
{
|
||||
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}/nested/b.txt", TestContext.Current.CancellationToken);
|
||||
|
||||
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken);
|
||||
|
||||
var remaining = new List<string>();
|
||||
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
|
||||
remaining.Add(item.Key);
|
||||
Assert.Empty(remaining);
|
||||
}
|
||||
|
||||
[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), TestContext.Current.CancellationToken);
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using Amazon.S3;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.TestSupport
|
||||
@@ -18,6 +18,8 @@ namespace Musify.Infrastructure.Tests.TestSupport
|
||||
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")
|
||||
@@ -83,17 +85,17 @@ namespace Musify.Infrastructure.Tests.TestSupport
|
||||
{
|
||||
name = "musify-test",
|
||||
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
|
||||
actions = new[] { "Admin", "Read", "Write" }
|
||||
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
|
||||
""";
|
||||
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
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Musify.StreamingGateway.Authentication;
|
||||
@@ -25,19 +25,19 @@ namespace Musify.StreamingGateway.Tests.Authentication
|
||||
File.Delete(publicKeyPath);
|
||||
}
|
||||
|
||||
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
|
||||
private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration
|
||||
{
|
||||
PublicKeyPath = publicKeyPath,
|
||||
Audience = Audience,
|
||||
Issuer = Issuer
|
||||
}));
|
||||
});
|
||||
|
||||
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();
|
||||
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var descriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
|
||||
+39
-6
@@ -1,7 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Musify.StreamingGateway.Authentication;
|
||||
@@ -17,12 +17,12 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
||||
|
||||
private readonly string publicKeyPath = Path.GetTempFileName();
|
||||
private readonly RSA rsa = RSA.Create(2048);
|
||||
private readonly StreamTicketValidationOptions options;
|
||||
private readonly StreamTicketValidationConfiguration options;
|
||||
|
||||
public TicketValidationMiddlewareTests()
|
||||
{
|
||||
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
||||
options = new StreamTicketValidationOptions
|
||||
options = new StreamTicketValidationConfiguration
|
||||
{
|
||||
PublicKeyPath = publicKeyPath,
|
||||
Audience = "media-gateway",
|
||||
@@ -40,7 +40,7 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
||||
{
|
||||
var claims = new Dictionary<string, object> { ["prefix"] = prefix };
|
||||
if (maxBytes != null)
|
||||
claims["maxBytes"] = maxBytes.Value.ToString();
|
||||
claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
@@ -57,8 +57,8 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
||||
{
|
||||
var nextCalled = false;
|
||||
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
|
||||
var validator = new TicketValidator(Options.Create(options));
|
||||
var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance);
|
||||
var validator = new TicketValidator(options);
|
||||
var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger<TicketValidationMiddleware>.Instance);
|
||||
return (middleware, () => nextCalled);
|
||||
}
|
||||
|
||||
@@ -216,5 +216,38 @@ namespace Musify.StreamingGateway.Tests.Middleware
|
||||
Assert.True(wasNextCalled());
|
||||
Assert.Equal("bytes=1000-2000", 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;
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(wasNextCalled());
|
||||
Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString());
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user