Design Prot Merge #1
@@ -3,82 +3,83 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Musify.Api.Authentication;
|
using Musify.Api.Authentication;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Authentication;
|
namespace Musify.Api.Tests.Authentication
|
||||||
|
|
||||||
public sealed class CurrentUserTests
|
|
||||||
{
|
{
|
||||||
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
|
public sealed class CurrentUserTests
|
||||||
{
|
{
|
||||||
var context = new DefaultHttpContext { User = principal };
|
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
|
||||||
var bound = await CurrentUser.BindAsync(context, parameter: null!);
|
{
|
||||||
return bound!;
|
var context = new DefaultHttpContext { User = principal };
|
||||||
}
|
var bound = await CurrentUser.BindAsync(context, parameter: null!);
|
||||||
|
return bound!;
|
||||||
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
|
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
|
||||||
new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
|
new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
|
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
|
||||||
{
|
{
|
||||||
var principal = AuthenticatedPrincipal(
|
var principal = AuthenticatedPrincipal(
|
||||||
new Claim(ClaimTypes.NameIdentifier, "42"),
|
new Claim(ClaimTypes.NameIdentifier, "42"),
|
||||||
new Claim(ClaimTypes.Name, "jdoe"),
|
new Claim(ClaimTypes.Name, "jdoe"),
|
||||||
new Claim(ClaimTypes.GivenName, "Jane"),
|
new Claim(ClaimTypes.GivenName, "Jane"),
|
||||||
new Claim(ClaimTypes.Surname, "Doe"));
|
new Claim(ClaimTypes.Surname, "Doe"));
|
||||||
|
|
||||||
var user = await BindAsync(principal);
|
var user = await BindAsync(principal);
|
||||||
|
|
||||||
Assert.True(user.IsAuthenticated);
|
Assert.True(user.IsAuthenticated);
|
||||||
Assert.Equal(42, user.Id);
|
Assert.Equal(42, user.Id);
|
||||||
Assert.Equal(42, user.RequiredId);
|
Assert.Equal(42, user.RequiredId);
|
||||||
Assert.Equal("jdoe", user.Username);
|
Assert.Equal("jdoe", user.Username);
|
||||||
Assert.Equal("Jane", user.FirstName);
|
Assert.Equal("Jane", user.FirstName);
|
||||||
Assert.Equal("Doe", user.LastName);
|
Assert.Equal("Doe", user.LastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
|
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
|
||||||
{
|
{
|
||||||
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
|
||||||
Assert.False(user.IsAuthenticated);
|
Assert.False(user.IsAuthenticated);
|
||||||
Assert.Null(user.Id);
|
Assert.Null(user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("not-a-number")]
|
[InlineData("not-a-number")]
|
||||||
[InlineData("0")]
|
[InlineData("0")]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
|
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
|
||||||
{
|
{
|
||||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
|
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
|
||||||
|
|
||||||
Assert.Null(user.Id);
|
Assert.Null(user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequiredId_MissingId_Throws()
|
public async Task RequiredId_MissingId_Throws()
|
||||||
{
|
{
|
||||||
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
|
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
|
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
|
||||||
{
|
{
|
||||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
|
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
|
||||||
|
|
||||||
Assert.True(user.HasClaim("role"));
|
Assert.True(user.HasClaim("role"));
|
||||||
Assert.False(user.HasClaim("missing"));
|
Assert.False(user.HasClaim("missing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
|
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
|
||||||
{
|
{
|
||||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
|
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
|
||||||
|
|
||||||
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
|
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
|
||||||
Assert.Null(user.FindClaim("missing"));
|
Assert.Null(user.FindClaim("missing"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,93 +6,94 @@ using Musify.Api.Tests.TestSupport;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Endpoints;
|
namespace Musify.Api.Tests.Endpoints
|
||||||
|
|
||||||
[Collection(ApiCollection.Name)]
|
|
||||||
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
|
||||||
{
|
{
|
||||||
private async Task<long> CreateUserAsync()
|
[Collection(ApiCollection.Name)]
|
||||||
|
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
||||||
{
|
{
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
private async Task<long> CreateUserAsync()
|
||||||
var response = await fixture.CreateAnonymousClient()
|
{
|
||||||
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
response.EnsureSuccessStatusCode();
|
var response = await fixture.CreateAnonymousClient()
|
||||||
return userId;
|
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
|
||||||
}
|
response.EnsureSuccessStatusCode();
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
|
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
|
||||||
{
|
{
|
||||||
var response = await fixture.CreateAnonymousClient()
|
var response = await fixture.CreateAnonymousClient()
|
||||||
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken);
|
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
|
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
|
||||||
{
|
{
|
||||||
var userId = await CreateUserAsync();
|
var userId = await CreateUserAsync();
|
||||||
var client = fixture.CreateAuthenticatedClient(userId);
|
var client = fixture.CreateAuthenticatedClient(userId);
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(body);
|
Assert.NotNull(body);
|
||||||
Assert.Equal("My Album", body.Title);
|
Assert.Equal("My Album", body.Title);
|
||||||
Assert.Equal(userId, body.OwnerUserId);
|
Assert.Equal(userId, body.OwnerUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
|
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
|
||||||
{
|
{
|
||||||
var userId = await CreateUserAsync();
|
var userId = await CreateUserAsync();
|
||||||
var client = fixture.CreateAuthenticatedClient(userId);
|
var client = fixture.CreateAuthenticatedClient(userId);
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAlbumById_AfterCreate_ReturnsIt()
|
public async Task GetAlbumById_AfterCreate_ReturnsIt()
|
||||||
{
|
{
|
||||||
var userId = await CreateUserAsync();
|
var userId = await CreateUserAsync();
|
||||||
var client = fixture.CreateAuthenticatedClient(userId);
|
var client = fixture.CreateAuthenticatedClient(userId);
|
||||||
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken))
|
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken))
|
||||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
|
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
|
||||||
{
|
{
|
||||||
var owner = await CreateUserAsync();
|
var owner = await CreateUserAsync();
|
||||||
var stranger = await CreateUserAsync();
|
var stranger = await CreateUserAsync();
|
||||||
var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken))
|
var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken))
|
||||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
|
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
|
||||||
{
|
{
|
||||||
var userId = await CreateUserAsync();
|
var userId = await CreateUserAsync();
|
||||||
var client = fixture.CreateAuthenticatedClient(userId);
|
var client = fixture.CreateAuthenticatedClient(userId);
|
||||||
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken))
|
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken))
|
||||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
||||||
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
|
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,69 +5,70 @@ using Musify.Api.Tests.TestSupport;
|
|||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Endpoints;
|
namespace Musify.Api.Tests.Endpoints
|
||||||
|
|
||||||
[Collection(ApiCollection.Name)]
|
|
||||||
public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|
||||||
{
|
{
|
||||||
[Fact]
|
[Collection(ApiCollection.Name)]
|
||||||
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
[Fact]
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
||||||
|
{
|
||||||
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||||
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
|
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
|
||||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(body);
|
Assert.NotNull(body);
|
||||||
Assert.Equal("Jane Doe", body.Name);
|
Assert.Equal("Jane Doe", body.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostUsers_BlankName_ReturnsValidationProblem()
|
public async Task PostUsers_BlankName_ReturnsValidationProblem()
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostUsers_DuplicateId_ReturnsConflict()
|
public async Task PostUsers_DuplicateId_ReturnsConflict()
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
|
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
|
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserById_ExistingUser_ReturnsIt()
|
public async Task GetUserById_ExistingUser_ReturnsIt()
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
|
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
|
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Findable User", body?.Name);
|
Assert.Equal("Findable User", body?.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserById_MissingUser_ReturnsNotFound()
|
public async Task GetUserById_MissingUser_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
|
||||||
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken);
|
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,134 +4,135 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Musify.Api.Extensions;
|
using Musify.Api.Extensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Extensions;
|
namespace Musify.Api.Tests.Extensions
|
||||||
|
|
||||||
public sealed class ErrorOrHttpExtensionsTests
|
|
||||||
{
|
{
|
||||||
// The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem-
|
public sealed class ErrorOrHttpExtensionsTests
|
||||||
// details services from HttpContext.RequestServices when they execute, so it can't be empty.
|
|
||||||
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
|
|
||||||
|
|
||||||
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
|
|
||||||
{
|
{
|
||||||
var context = new DefaultHttpContext { RequestServices = Services };
|
// The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem-
|
||||||
var bodyStream = new MemoryStream();
|
// details services from HttpContext.RequestServices when they execute, so it can't be empty.
|
||||||
context.Response.Body = bodyStream;
|
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
|
||||||
|
|
||||||
await result.ExecuteAsync(context);
|
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
|
||||||
|
|
||||||
bodyStream.Position = 0;
|
|
||||||
using var reader = new StreamReader(bodyStream);
|
|
||||||
return (context.Response.StatusCode, await reader.ReadToEndAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_Success_ReturnsNoContent()
|
|
||||||
{
|
|
||||||
ErrorOr<Success> result = new Success();
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = "hello";
|
|
||||||
|
|
||||||
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status200OK, statusCode);
|
|
||||||
Assert.Contains("hello", body);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_NotFoundError_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = Error.NotFound(description: "not here");
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = Error.Unauthorized();
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_ForbiddenError_ReturnsForbidden()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = Error.Forbidden();
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status403Forbidden, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_ConflictError_ReturnsConflict()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = Error.Conflict();
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_FailureError_ReturnsProblem500()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = Error.Failure();
|
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status500InternalServerError, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem()
|
|
||||||
{
|
|
||||||
ErrorOr<string> result = new List<Error>
|
|
||||||
{
|
{
|
||||||
Error.Validation("Name", "Name is required"),
|
var context = new DefaultHttpContext { RequestServices = Services };
|
||||||
Error.Validation("Name", "Name is too long"),
|
var bodyStream = new MemoryStream();
|
||||||
Error.Validation("Age", "Age must be positive"),
|
context.Response.Body = bodyStream;
|
||||||
};
|
|
||||||
|
|
||||||
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
await result.ExecuteAsync(context);
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
|
bodyStream.Position = 0;
|
||||||
Assert.Contains("Name is required", body);
|
using var reader = new StreamReader(bodyStream);
|
||||||
Assert.Contains("Age must be positive", body);
|
return (context.Response.StatusCode, await reader.ReadToEndAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
|
public async Task ToHttpResult_Success_ReturnsNoContent()
|
||||||
{
|
{
|
||||||
ErrorOr<string> result = "the-id";
|
ErrorOr<Success> result = new Success();
|
||||||
|
|
||||||
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
|
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
|
||||||
Assert.Equal("/things/the-id", context.Response.Headers.Location);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
|
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
|
||||||
{
|
{
|
||||||
ErrorOr<string> result = Error.Conflict();
|
ErrorOr<string> result = "hello";
|
||||||
|
|
||||||
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
|
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
Assert.Equal(StatusCodes.Status200OK, statusCode);
|
||||||
|
Assert.Contains("hello", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_NotFoundError_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.NotFound(description: "not here");
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.Unauthorized();
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_ForbiddenError_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.Forbidden();
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status403Forbidden, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_ConflictError_ReturnsConflict()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.Conflict();
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_FailureError_ReturnsProblem500()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.Failure();
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status500InternalServerError, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = new List<Error>
|
||||||
|
{
|
||||||
|
Error.Validation("Name", "Name is required"),
|
||||||
|
Error.Validation("Name", "Name is too long"),
|
||||||
|
Error.Validation("Age", "Age must be positive"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
|
||||||
|
Assert.Contains("Name is required", body);
|
||||||
|
Assert.Contains("Age must be positive", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = "the-id";
|
||||||
|
|
||||||
|
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
|
||||||
|
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||||
|
Assert.Equal("/things/the-id", context.Response.Headers.Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
|
||||||
|
{
|
||||||
|
ErrorOr<string> result = Error.Conflict();
|
||||||
|
|
||||||
|
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,61 +6,62 @@ using Musify.Api.Filters;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Filters;
|
namespace Musify.Api.Tests.Filters
|
||||||
|
|
||||||
public sealed class ValidationFilterTests
|
|
||||||
{
|
{
|
||||||
public sealed record Payload(string Name);
|
public sealed class ValidationFilterTests
|
||||||
|
|
||||||
// EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only
|
|
||||||
// Create<T0>, Create<T0,T1>, etc. Passing an object[] to a params-object[] call site binds
|
|
||||||
// to Create<T0> with T0 inferred as object[] itself, so .Arguments ends up with one element
|
|
||||||
// (the array, boxed) instead of the array's contents — OfType<Payload>() then never matches
|
|
||||||
// and the filter silently falls through to "no argument, skip validation" for every case.
|
|
||||||
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
|
|
||||||
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task InvokeAsync_ValidPayload_CallsNext()
|
|
||||||
{
|
{
|
||||||
var validator = Substitute.For<IValidator<Payload>>();
|
public sealed record Payload(string Name);
|
||||||
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()).Returns(new ValidationResult());
|
|
||||||
var filter = new ValidationFilter<Payload>(validator);
|
|
||||||
var context = CreateContext(new Payload("ok"));
|
|
||||||
|
|
||||||
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
// EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only
|
||||||
|
// Create<T0>, Create<T0,T1>, etc. Passing an object[] to a params-object[] call site binds
|
||||||
|
// to Create<T0> with T0 inferred as object[] itself, so .Arguments ends up with one element
|
||||||
|
// (the array, boxed) instead of the array's contents — OfType<Payload>() then never matches
|
||||||
|
// and the filter silently falls through to "no argument, skip validation" for every case.
|
||||||
|
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
|
||||||
|
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
|
||||||
|
|
||||||
Assert.Equal("next-called", result);
|
[Fact]
|
||||||
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
|
public async Task InvokeAsync_ValidPayload_CallsNext()
|
||||||
}
|
{
|
||||||
|
var validator = Substitute.For<IValidator<Payload>>();
|
||||||
|
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()).Returns(new ValidationResult());
|
||||||
|
var filter = new ValidationFilter<Payload>(validator);
|
||||||
|
var context = CreateContext(new Payload("ok"));
|
||||||
|
|
||||||
[Fact]
|
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
||||||
public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
|
|
||||||
{
|
|
||||||
var validator = Substitute.For<IValidator<Payload>>();
|
|
||||||
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")]));
|
|
||||||
var filter = new ValidationFilter<Payload>(validator);
|
|
||||||
var context = CreateContext(new Payload(""));
|
|
||||||
var nextCalled = false;
|
|
||||||
|
|
||||||
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
|
Assert.Equal("next-called", result);
|
||||||
|
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(nextCalled);
|
[Fact]
|
||||||
var problem = Assert.IsType<ProblemHttpResult>(result);
|
public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
|
||||||
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
|
{
|
||||||
}
|
var validator = Substitute.For<IValidator<Payload>>();
|
||||||
|
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")]));
|
||||||
|
var filter = new ValidationFilter<Payload>(validator);
|
||||||
|
var context = CreateContext(new Payload(""));
|
||||||
|
var nextCalled = false;
|
||||||
|
|
||||||
[Fact]
|
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
|
||||||
public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating()
|
|
||||||
{
|
|
||||||
var validator = Substitute.For<IValidator<Payload>>();
|
|
||||||
var filter = new ValidationFilter<Payload>(validator);
|
|
||||||
var context = CreateContext(42);
|
|
||||||
|
|
||||||
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
Assert.False(nextCalled);
|
||||||
|
var problem = Assert.IsType<ProblemHttpResult>(result);
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal("next-called", result);
|
[Fact]
|
||||||
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
|
public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating()
|
||||||
|
{
|
||||||
|
var validator = Substitute.For<IValidator<Payload>>();
|
||||||
|
var filter = new ValidationFilter<Payload>(validator);
|
||||||
|
var context = CreateContext(42);
|
||||||
|
|
||||||
|
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
||||||
|
|
||||||
|
Assert.Equal("next-called", result);
|
||||||
|
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,111 +11,112 @@ using NSubstitute;
|
|||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.TestSupport;
|
namespace Musify.Api.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Boots the real <c>Musify.Api</c> host — real routing, real auth/authorization pipeline,
|
|
||||||
/// real validators, real Mediator handlers — against a private Postgres container (so EF
|
|
||||||
/// queries behave like production, not like a stand-in). The services this API talks to over
|
|
||||||
/// the network (object storage, YouTube, the message bus, stream-ticket signing) are replaced
|
|
||||||
/// with NSubstitute fakes: they are Infrastructure's job to verify against the real thing (see
|
|
||||||
/// Musify.Infrastructure.Tests), not the HTTP layer's.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
|
||||||
{
|
{
|
||||||
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
/// <summary>
|
||||||
.WithDatabase("musify_api_test")
|
/// Boots the real <c>Musify.Api</c> host — real routing, real auth/authorization pipeline,
|
||||||
.WithUsername("postgres")
|
/// real validators, real Mediator handlers — against a private Postgres container (so EF
|
||||||
.WithPassword("postgres")
|
/// queries behave like production, not like a stand-in). The services this API talks to over
|
||||||
.Build();
|
/// the network (object storage, YouTube, the message bus, stream-ticket signing) are replaced
|
||||||
|
/// with NSubstitute fakes: they are Infrastructure's job to verify against the real thing (see
|
||||||
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
|
/// Musify.Infrastructure.Tests), not the HTTP layer's.
|
||||||
|
/// </summary>
|
||||||
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
|
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||||
|
|
||||||
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
|
|
||||||
|
|
||||||
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
|
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
{
|
||||||
await postgres.StartAsync();
|
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
||||||
|
.WithDatabase("musify_api_test")
|
||||||
|
.WithUsername("postgres")
|
||||||
|
.WithPassword("postgres")
|
||||||
|
.Build();
|
||||||
|
|
||||||
using var scope = Services.CreateScope();
|
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
|
||||||
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
|
|
||||||
await database.Database.MigrateAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own
|
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
|
||||||
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
|
|
||||||
// override the one WebApplicationFactory already provides instead of implementing the
|
|
||||||
// interface explicitly alongside it.
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await base.DisposeAsync();
|
|
||||||
await postgres.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary>
|
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
|
||||||
public HttpClient CreateAnonymousClient() => CreateClient();
|
|
||||||
|
|
||||||
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary>
|
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
|
||||||
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
|
||||||
{
|
|
||||||
var client = CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
|
|
||||||
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
public async ValueTask InitializeAsync()
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
|
||||||
{
|
{
|
||||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
await postgres.StartAsync();
|
||||||
|
|
||||||
|
using var scope = Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
|
||||||
|
await database.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own
|
||||||
|
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
|
||||||
|
// override the one WebApplicationFactory already provides instead of implementing the
|
||||||
|
// interface explicitly alongside it.
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await base.DisposeAsync();
|
||||||
|
await postgres.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary>
|
||||||
|
public HttpClient CreateAnonymousClient() => CreateClient();
|
||||||
|
|
||||||
|
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary>
|
||||||
|
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
|
||||||
|
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||||
{
|
{
|
||||||
["Database:ConnectionString"] = postgres.GetConnectionString(),
|
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
|
{
|
||||||
["Authentication:IssuerAddress"] = "https://auth.musify.test",
|
["Database:ConnectionString"] = postgres.GetConnectionString(),
|
||||||
["Authentication:AudienceAddress"] = "musify-api-test",
|
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
|
||||||
["Authentication:ClientId"] = "musify-api-test",
|
["Authentication:IssuerAddress"] = "https://auth.musify.test",
|
||||||
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
|
["Authentication:AudienceAddress"] = "musify-api-test",
|
||||||
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
|
["Authentication:ClientId"] = "musify-api-test",
|
||||||
["Authentication:Scopes:0"] = "openid",
|
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
|
||||||
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
|
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
|
||||||
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
|
["Authentication:Scopes:0"] = "openid",
|
||||||
["MassTransit:Address"] = "rabbitmq://localhost:5672",
|
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
|
||||||
["MassTransit:Username"] = "guest",
|
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
|
||||||
["MassTransit:Password"] = "guest",
|
["MassTransit:Address"] = "rabbitmq://localhost:5672",
|
||||||
["InfrastructureStorage:Address"] = "http://localhost:8333",
|
["MassTransit:Username"] = "guest",
|
||||||
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
|
["MassTransit:Password"] = "guest",
|
||||||
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
|
["InfrastructureStorage:Address"] = "http://localhost:8333",
|
||||||
["InfrastructureStorage:ForcePathStyle"] = "true",
|
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
|
||||||
["InfrastructureStorage:UseHttp"] = "true",
|
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
|
||||||
["ApplicationStorage:Bucket"] = "musify-api-test",
|
["InfrastructureStorage:ForcePathStyle"] = "true",
|
||||||
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
["InfrastructureStorage:UseHttp"] = "true",
|
||||||
["PlayList:SeedPresetPictures"] = "false",
|
["ApplicationStorage:Bucket"] = "musify-api-test",
|
||||||
|
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
||||||
|
["PlayList:SeedPresetPictures"] = "false",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
services.Replace(ServiceDescriptor.Singleton(StorageService));
|
services.Replace(ServiceDescriptor.Singleton(StorageService));
|
||||||
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
|
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
|
||||||
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
|
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
|
||||||
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
|
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
|
||||||
|
|
||||||
// Overrides the real JwtBearer scheme as the default, so requests authenticate via
|
// Overrides the real JwtBearer scheme as the default, so requests authenticate via
|
||||||
// FakeAuthenticationHandler instead of needing a real token and a reachable IdP.
|
// FakeAuthenticationHandler instead of needing a real token and a reachable IdP.
|
||||||
services
|
services
|
||||||
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
|
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
|
||||||
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
|
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "Api";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CollectionDefinition(Name)]
|
|
||||||
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
|
|
||||||
{
|
|
||||||
public const string Name = "Api";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,34 +4,35 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.TestSupport;
|
namespace Musify.Api.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replaces the real JwtBearer handler in the test host: a request carrying the
|
|
||||||
/// <see cref="UserIdHeader"/> header authenticates as that user id, with no real token,
|
|
||||||
/// issuer or JWKS endpoint involved. A request without the header stays anonymous, so
|
|
||||||
/// endpoints under <c>.RequireAuthorization()</c> correctly answer 401.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FakeAuthenticationHandler(
|
|
||||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
||||||
ILoggerFactory logger,
|
|
||||||
UrlEncoder encoder)
|
|
||||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
|
||||||
{
|
{
|
||||||
public const string SchemeName = "Test";
|
/// <summary>
|
||||||
public const string UserIdHeader = "X-Test-User-Id";
|
/// Replaces the real JwtBearer handler in the test host: a request carrying the
|
||||||
|
/// <see cref="UserIdHeader"/> header authenticates as that user id, with no real token,
|
||||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
/// issuer or JWKS endpoint involved. A request without the header stays anonymous, so
|
||||||
|
/// endpoints under <c>.RequireAuthorization()</c> correctly answer 401.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
|
public const string SchemeName = "Test";
|
||||||
return Task.FromResult(AuthenticateResult.NoResult());
|
public const string UserIdHeader = "X-Test-User-Id";
|
||||||
|
|
||||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
|
{
|
||||||
claims.Add(new Claim(ClaimTypes.Name, userName!));
|
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
|
||||||
|
return Task.FromResult(AuthenticateResult.NoResult());
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
|
||||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
|
||||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
claims.Add(new Claim(ClaimTypes.Name, userName!));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||||
|
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,74 +2,75 @@ using Musify.Api.DataTransferObjects.Albums;
|
|||||||
using Musify.Api.Validators.Albums;
|
using Musify.Api.Validators.Albums;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Validators;
|
namespace Musify.Api.Tests.Validators
|
||||||
|
|
||||||
public sealed class CreateAlbumRequestValidatorTests
|
|
||||||
{
|
{
|
||||||
private readonly CreateAlbumRequestValidator validator = new();
|
public sealed class CreateAlbumRequestValidatorTests
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_ValidRequest_Passes()
|
|
||||||
{
|
{
|
||||||
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024));
|
private readonly CreateAlbumRequestValidator validator = new();
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ValidRequest_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024));
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsValid);
|
||||||
public void Validate_ValidRequestWithoutOptionalFields_Passes()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ValidRequestWithoutOptionalFields_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsValid);
|
||||||
public void Validate_BlankTitle_Fails()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("", null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
|
public void Validate_BlankTitle_Fails()
|
||||||
}
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("", null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
public void Validate_TitleOverMaxLength_Fails()
|
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
|
||||||
{
|
}
|
||||||
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_TitleOverMaxLength_Fails()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest));
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsValid);
|
||||||
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest));
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsValid);
|
||||||
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
|
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,50 +2,51 @@ using Musify.Api.DataTransferObjects.Users;
|
|||||||
using Musify.Api.Validators.Users;
|
using Musify.Api.Validators.Users;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Validators;
|
namespace Musify.Api.Tests.Validators
|
||||||
|
|
||||||
public sealed class CreateUserRequestValidatorTests
|
|
||||||
{
|
{
|
||||||
private readonly CreateUserRequestValidator validator = new();
|
public sealed class CreateUserRequestValidatorTests
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_ValidRequest_Passes()
|
|
||||||
{
|
{
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
|
private readonly CreateUserRequestValidator validator = new();
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_ValidRequest_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsValid);
|
||||||
public void Validate_ZeroId_Fails()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
|
public void Validate_ZeroId_Fails()
|
||||||
}
|
{
|
||||||
|
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
public void Validate_BlankName_Fails()
|
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
|
||||||
{
|
}
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
|
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
[Fact]
|
||||||
}
|
public void Validate_BlankName_Fails()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
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_NameOverMaxLength_Fails()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsValid);
|
||||||
public void Validate_OptionalNamesOmitted_Passes()
|
}
|
||||||
{
|
|
||||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
|
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
|
public void Validate_OptionalNamesOmitted_Passes()
|
||||||
|
{
|
||||||
|
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-80
@@ -5,107 +5,108 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>());
|
public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_FirstTrack_AddsItAtNumberOne()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>());
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, album, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_FirstTrack_AddsItAtNumberOne()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, album, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
Assert.Equal(1, link.TrackNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
{
|
Assert.Equal(1, link.TrackNumber);
|
||||||
var owner = TestEntities.User();
|
}
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, stranger, album, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, stranger, album, track);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, album);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, album);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var otherOwner = TestEntities.User(2, "other");
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var foreignTrack = TestEntities.LocalTrack(otherOwner);
|
|
||||||
await SeedAsync(owner, otherOwner, album, foreignTrack);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var otherOwner = TestEntities.User(2, "other");
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var foreignTrack = TestEntities.LocalTrack(otherOwner);
|
||||||
|
await SeedAsync(owner, otherOwner, album, foreignTrack);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
||||||
|
await SeedAsync(owner, album, track, link);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var firstTrack = TestEntities.LocalTrack(owner, "First");
|
|
||||||
var secondTrack = TestEntities.LocalTrack(owner, "Second");
|
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var firstTrack = TestEntities.LocalTrack(owner, "First");
|
||||||
|
var secondTrack = TestEntities.LocalTrack(owner, "Second");
|
||||||
|
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
|
||||||
|
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken);
|
||||||
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal(4, newLink.TrackNumber);
|
Assert.False(result.IsError);
|
||||||
|
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(4, newLink.TrackNumber);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,41 +3,42 @@ using Musify.Application.Albums;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>());
|
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>());
|
||||||
await SeedAsync(owner);
|
|
||||||
|
|
||||||
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024);
|
[Fact]
|
||||||
|
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("My Album", result.Value.Title);
|
|
||||||
Assert.Equal("A description", result.Value.Description);
|
|
||||||
Assert.Equal(2024, result.Value.ReleaseYear);
|
|
||||||
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
|
||||||
Assert.Equal(0, result.Value.TrackCount);
|
|
||||||
|
|
||||||
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
Assert.NotNull(stored);
|
Assert.Equal("My Album", result.Value.Title);
|
||||||
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
|
Assert.Equal("A description", result.Value.Description);
|
||||||
}
|
Assert.Equal(2024, result.Value.ReleaseYear);
|
||||||
|
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
||||||
|
Assert.Equal(0, result.Value.TrackCount);
|
||||||
|
|
||||||
[Fact]
|
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
|
Assert.NotNull(stored);
|
||||||
{
|
Assert.Equal("MY ALBUM", stored.NormalizedTitle);
|
||||||
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null);
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,45 @@ using Musify.Application.Albums;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
|
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_Owner_RemovesAlbum()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, album);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_Owner_RemovesAlbum()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, album);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, stranger, album);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, album);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,33 +4,34 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(album.Id, result.Value.Id);
|
|
||||||
Assert.Equal(1, result.Value.TrackCount);
|
|
||||||
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
Assert.Equal(album.Id, result.Value.Id);
|
||||||
{
|
Assert.Equal(1, result.Value.TrackCount);
|
||||||
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
|
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
var second = TestEntities.LocalTrack(owner, "Second");
|
|
||||||
var first = TestEntities.LocalTrack(owner, "First");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, album, second, first,
|
|
||||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
|
||||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var second = TestEntities.LocalTrack(owner, "Second");
|
||||||
|
var first = TestEntities.LocalTrack(owner, "First");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, album, second, first,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-20
@@ -3,31 +3,32 @@ using Musify.Application.Albums;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
|
||||||
var other = TestEntities.User(2, "other");
|
|
||||||
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var other = TestEntities.User(2, "other");
|
||||||
|
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var album = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Mine", album.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
var album = Assert.Single(result.Value.Items);
|
||||||
{
|
Assert.Equal("Mine", album.Title);
|
||||||
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,47 +2,48 @@ using Musify.Application.Albums;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetAlbumsQueryHandler CreateHandler() => new(Database);
|
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetAlbumsQueryHandler CreateHandler() => new(Database);
|
||||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums()
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
{
|
}
|
||||||
var owner = TestEntities.User();
|
|
||||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var album = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Rock Classics", album.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
|
var album = Assert.Single(result.Value.Items);
|
||||||
{
|
Assert.Equal("Rock Classics", album.Title);
|
||||||
var owner = TestEntities.User();
|
}
|
||||||
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.Items.Count);
|
|
||||||
Assert.True(result.Value.HasNextPage);
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.Items.Count);
|
||||||
|
Assert.True(result.Value.HasNextPage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-43
@@ -3,58 +3,59 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
|
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
|
||||||
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
|
|
||||||
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
|
|
||||||
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
|
|
||||||
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
|
|
||||||
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
|
|
||||||
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
|
|
||||||
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
|
|
||||||
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
|
||||||
|
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
|
||||||
|
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
|
||||||
|
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
|
||||||
|
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
|
||||||
|
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
|
||||||
|
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
|
||||||
|
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
|
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
|
||||||
{
|
}
|
||||||
var owner = TestEntities.User();
|
|
||||||
await SeedAsync(owner);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Empty(result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
|
Assert.Empty(result.Value);
|
||||||
{
|
}
|
||||||
var owner = TestEntities.User();
|
|
||||||
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
|
|
||||||
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
|
|
||||||
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
|
|
||||||
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
|
|
||||||
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
|
||||||
|
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
|
||||||
|
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
|
||||||
|
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
|
||||||
|
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.Count);
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-51
@@ -5,68 +5,69 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
|
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
|
||||||
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
|
||||||
var first = TestEntities.LocalTrack(owner, "First");
|
|
||||||
var second = TestEntities.LocalTrack(owner, "Second");
|
|
||||||
var third = TestEntities.LocalTrack(owner, "Third");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, album, first, second, third,
|
|
||||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
|
|
||||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
|
||||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
var first = TestEntities.LocalTrack(owner, "First");
|
||||||
|
var second = TestEntities.LocalTrack(owner, "Second");
|
||||||
|
var third = TestEntities.LocalTrack(owner, "Third");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, album, first, second, third,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken);
|
||||||
var remaining = await Database.AlbumHasTracks
|
|
||||||
.Where(link => link.AlbumId == album.Id)
|
|
||||||
.OrderBy(link => link.TrackNumber)
|
|
||||||
.ToListAsync(TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
|
|
||||||
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
var remaining = await Database.AlbumHasTracks
|
||||||
{
|
.Where(link => link.AlbumId == album.Id)
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
.OrderBy(link => link.TrackNumber)
|
||||||
|
.ToListAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
|
||||||
|
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, stranger, album);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, album);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, album);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, album);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,55 +3,56 @@ using Musify.Application.Albums;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums
|
||||||
|
|
||||||
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>());
|
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>());
|
||||||
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
|
|
||||||
await SeedAsync(owner, album);
|
|
||||||
|
|
||||||
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
|
[Fact]
|
||||||
|
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
|
||||||
|
await SeedAsync(owner, album);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("New Title", result.Value.Title);
|
|
||||||
Assert.Equal("New description", result.Value.Description);
|
|
||||||
Assert.Equal(2025, result.Value.ReleaseYear);
|
|
||||||
|
|
||||||
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
Assert.NotNull(stored);
|
Assert.Equal("New Title", result.Value.Title);
|
||||||
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
|
Assert.Equal("New description", result.Value.Description);
|
||||||
}
|
Assert.Equal(2025, result.Value.ReleaseYear);
|
||||||
|
|
||||||
[Fact]
|
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
Assert.NotNull(stored);
|
||||||
{
|
Assert.Equal("NEW TITLE", stored.NormalizedTitle);
|
||||||
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var album = TestEntities.UserAlbum(owner.Id);
|
|
||||||
await SeedAsync(owner, stranger, album);
|
|
||||||
|
|
||||||
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var album = TestEntities.UserAlbum(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, album);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-76
@@ -8,99 +8,100 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Mixes;
|
namespace Musify.Application.Tests.Mixes
|
||||||
|
|
||||||
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private GenerateMixesForUserCommandHandler CreateHandler() => new(
|
|
||||||
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
|
|
||||||
{
|
{
|
||||||
var user = TestEntities.User();
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
await SeedAsync(user);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
private GenerateMixesForUserCommandHandler CreateHandler() => new(
|
||||||
|
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
|
public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
|
||||||
}
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
|
|
||||||
{
|
|
||||||
var user = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(user);
|
|
||||||
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
|
public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
|
||||||
}
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(user);
|
||||||
|
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
|
|
||||||
{
|
|
||||||
youTubeMusicService
|
|
||||||
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new YouTubeSearchResult(
|
|
||||||
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )],
|
|
||||||
ContinuationToken: string.Empty));
|
|
||||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
|
||||||
|
|
||||||
var user = TestEntities.User();
|
Assert.False(result.IsError);
|
||||||
var artist = TestEntities.Artist("Seed Artist");
|
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
|
}
|
||||||
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
|
|
||||||
|
|
||||||
var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
|
[Fact]
|
||||||
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item");
|
public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
|
||||||
|
{
|
||||||
|
youTubeMusicService
|
||||||
|
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new YouTubeSearchResult(
|
||||||
|
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )],
|
||||||
|
ContinuationToken: string.Empty));
|
||||||
|
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||||
|
|
||||||
await SeedAsync(
|
var user = TestEntities.User();
|
||||||
user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem,
|
var artist = TestEntities.Artist("Seed Artist");
|
||||||
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
|
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
|
||||||
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
|
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
|
||||||
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
|
||||||
|
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
await SeedAsync(
|
||||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem,
|
||||||
Assert.NotEmpty(mixes);
|
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
|
||||||
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
|
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
|
||||||
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
|
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly()
|
|
||||||
{
|
|
||||||
// Simulates IYouTubeMusicService.SearchSongsAsync as it behaves when the YouTube source is
|
|
||||||
// disabled (DisabledYouTubeMusicService, see Infrastructure): every call fails. Mix
|
|
||||||
// generation must degrade to local-only candidates instead of failing the whole command.
|
|
||||||
youTubeMusicService
|
|
||||||
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
|
|
||||||
|
|
||||||
var user = TestEntities.User();
|
Assert.False(result.IsError);
|
||||||
var artist = TestEntities.Artist("Seed Artist");
|
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||||
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
|
Assert.NotEmpty(mixes);
|
||||||
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
|
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
|
||||||
|
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
await SeedAsync(
|
[Fact]
|
||||||
user, artist, listenedTrack, otherTrackByArtist,
|
public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly()
|
||||||
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
|
{
|
||||||
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
|
// Simulates IYouTubeMusicService.SearchSongsAsync as it behaves when the YouTube source is
|
||||||
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
// disabled (DisabledYouTubeMusicService, see Infrastructure): every call fails. Mix
|
||||||
|
// generation must degrade to local-only candidates instead of failing the whole command.
|
||||||
|
youTubeMusicService
|
||||||
|
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
var user = TestEntities.User();
|
||||||
|
var artist = TestEntities.Artist("Seed Artist");
|
||||||
|
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
|
||||||
|
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
await SeedAsync(
|
||||||
var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken);
|
user, artist, listenedTrack, otherTrackByArtist,
|
||||||
Assert.NotEmpty(items);
|
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
|
||||||
Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source));
|
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
|
||||||
|
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotEmpty(items);
|
||||||
|
Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,45 +3,46 @@ using Musify.Application.Mixes;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Mixes;
|
namespace Musify.Application.Tests.Mixes
|
||||||
|
|
||||||
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetMixByIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetMixByIdQueryHandler CreateHandler() => new(Database);
|
||||||
var mix = TestEntities.Mix(owner.Id, "Discovery");
|
|
||||||
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
|
|
||||||
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
|
|
||||||
await SeedAsync(owner, mix, second, first);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var mix = TestEntities.Mix(owner.Id, "Discovery");
|
||||||
|
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
|
||||||
|
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
|
||||||
|
await SeedAsync(owner, mix, second, first);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Discovery", result.Value.Title);
|
|
||||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_MixMissing_ReturnsNotFound()
|
Assert.Equal("Discovery", result.Value.Title);
|
||||||
{
|
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
|
||||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_MixMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var mix = TestEntities.Mix(owner.Id);
|
|
||||||
await SeedAsync(owner, mix);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var mix = TestEntities.Mix(owner.Id);
|
||||||
|
await SeedAsync(owner, mix);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,38 @@ using Musify.Application.Mixes;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Mixes;
|
namespace Musify.Application.Tests.Mixes
|
||||||
|
|
||||||
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
|
||||||
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");
|
|
||||||
await SeedAsync(owner, other, second, first, theirs);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
await SeedAsync(owner, other, second, first, theirs);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_NoMixes_ReturnsEmptyList()
|
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
|
||||||
{
|
}
|
||||||
var owner = TestEntities.User();
|
|
||||||
await SeedAsync(owner);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoMixes_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Empty(result.Value);
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Empty(result.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-65
@@ -5,88 +5,89 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>());
|
public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_FirstTrack_AddsItAtPositionZero()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>());
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, playList, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_FirstTrack_AddsItAtPositionZero()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, playList, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
Assert.Equal(0, link.Position);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
|
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
{
|
Assert.Equal(0, link.Position);
|
||||||
var owner = TestEntities.User();
|
}
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
|
|
||||||
var newTrack = TestEntities.LocalTrack(owner, "New");
|
|
||||||
await SeedAsync(
|
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
|
||||||
|
var newTrack = TestEntities.LocalTrack(owner, "New");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, existingTrack, newTrack,
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken);
|
||||||
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal(1, newLink.Position);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
|
||||||
{
|
Assert.Equal(1, newLink.Position);
|
||||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(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);
|
[Fact]
|
||||||
|
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-73
@@ -8,97 +8,98 @@ using Musify.Domain.Entities;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
|
||||||
|
|
||||||
private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
|
|
||||||
{
|
{
|
||||||
var provisioner = new YouTubeTrackProvisioner(
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
|
|
||||||
return new AddYouTubeTrackToPlayListCommandHandler(
|
private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
|
||||||
Database,
|
{
|
||||||
eventBus,
|
var provisioner = new YouTubeTrackProvisioner(
|
||||||
youTubeMusicService,
|
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||||
provisioner,
|
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.Track(),
|
|
||||||
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static YouTubeSongResult BuildSong(string videoId) => new(
|
return new AddYouTubeTrackToPlayListCommandHandler(
|
||||||
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
|
Database,
|
||||||
[new YouTubeArtistRef("artist-id", "Song Artist")]);
|
eventBus,
|
||||||
|
youTubeMusicService,
|
||||||
|
provisioner,
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.Track(),
|
||||||
|
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
private static YouTubeSongResult BuildSong(string videoId) => new(
|
||||||
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload()
|
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
|
||||||
{
|
[new YouTubeArtistRef("artist-id", "Song Artist")]);
|
||||||
var owner = TestEntities.User();
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
|
||||||
await SeedAsync(owner, playList, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||||
|
await SeedAsync(owner, playList, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
||||||
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload()
|
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
{
|
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
||||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
|
}
|
||||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
|
||||||
|
|
||||||
var owner = TestEntities.User();
|
[Fact]
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload()
|
||||||
await SeedAsync(owner, playList);
|
{
|
||||||
|
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
|
||||||
|
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken);
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Song Title", result.Value.Title);
|
|
||||||
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(track.Audio.DownloadRequested);
|
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
Assert.Equal("Song Title", result.Value.Title);
|
||||||
{
|
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
|
||||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken);
|
Assert.True(track.Audio.DownloadRequested);
|
||||||
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
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 AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_TrackAlreadyLinked_ReturnsConflict()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
|
||||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackAlreadyLinked_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||||
|
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-58
@@ -6,80 +6,81 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
|
||||||
|
|
||||||
private CreatePlayListCommandHandler CreateHandler() => new(
|
|
||||||
eventBus,
|
|
||||||
Database,
|
|
||||||
new UploadIntentValidator(Database, storageService),
|
|
||||||
NoOpLogger<CreatePlayListCommandHandler>(),
|
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.PlayList(),
|
|
||||||
TestConfigurations.UploadIntent());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
|
|
||||||
{
|
{
|
||||||
var user = TestEntities.User();
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
await SeedAsync(user);
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
|
|
||||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
|
private CreatePlayListCommandHandler CreateHandler() => new(
|
||||||
|
eventBus,
|
||||||
|
Database,
|
||||||
|
new UploadIntentValidator(Database, storageService),
|
||||||
|
NoOpLogger<CreatePlayListCommandHandler>(),
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.PlayList(),
|
||||||
|
TestConfigurations.UploadIntent());
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
|
||||||
Assert.Equal("My Playlist", result.Value.Name);
|
|
||||||
Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
|
|
||||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
|
|
||||||
{
|
|
||||||
storageService
|
|
||||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new ObjectMetaData("image/webp", 1024));
|
|
||||||
|
|
||||||
var user = TestEntities.User();
|
Assert.False(result.IsError);
|
||||||
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
|
Assert.Equal("My Playlist", result.Value.Name);
|
||||||
await SeedAsync(user, intent);
|
Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
|
||||||
|
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
|
[Fact]
|
||||||
|
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
|
||||||
|
{
|
||||||
|
storageService
|
||||||
|
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ObjectMetaData("image/webp", 1024));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var user = TestEntities.User();
|
||||||
|
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
|
||||||
|
await SeedAsync(user, intent);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
|
||||||
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(stored);
|
|
||||||
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stored);
|
||||||
|
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
||||||
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_PictureIntentNotFound_ReturnsError()
|
|
||||||
{
|
|
||||||
var user = TestEntities.User();
|
|
||||||
await SeedAsync(user);
|
|
||||||
|
|
||||||
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_PictureIntentNotFound_ReturnsError()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
Assert.True(result.IsError);
|
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-34
@@ -6,48 +6,49 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_Owner_MarksPlayListAsRemoving()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
await SeedAsync(owner, playList);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken);
|
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
|
public async Task Handle_Owner_MarksPlayListAsRemoving()
|
||||||
Assert.NotNull(stored);
|
{
|
||||||
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
var owner = TestEntities.User();
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
}
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.False(result.IsError);
|
||||||
}
|
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stored);
|
||||||
|
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
||||||
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
await SeedAsync(owner, stranger, playList);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-21
@@ -4,32 +4,33 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(playList.Name, result.Value.Name);
|
|
||||||
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
Assert.Equal(playList.Name, result.Value.Name);
|
||||||
{
|
Assert.Equal([track.Id], result.Value.CoverTrackIds);
|
||||||
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-24
@@ -3,35 +3,36 @@ using Musify.Application.PlayLists;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetPlayListCoverQueryHandler CreateHandler() =>
|
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
|
||||||
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();
|
private GetPlayListCoverQueryHandler CreateHandler() =>
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
|
||||||
await SeedAsync(owner, playList);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
|
[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);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
|
||||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||||
{
|
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||||
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_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-24
@@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
|
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var second = TestEntities.LocalTrack(owner, "Second");
|
|
||||||
var first = TestEntities.LocalTrack(owner, "First");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, playList, second, first,
|
|
||||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
|
||||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var second = TestEntities.LocalTrack(owner, "Second");
|
||||||
|
var first = TestEntities.LocalTrack(owner, "First");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, second, first,
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-28
@@ -3,40 +3,41 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
|
||||||
var other = TestEntities.User(2, "other");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, other,
|
|
||||||
TestEntities.PlayList(owner.Id, "Mine"),
|
|
||||||
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
|
||||||
TestEntities.PlayList(other.Id, "Theirs"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var other = TestEntities.User(2, "other");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, other,
|
||||||
|
TestEntities.PlayList(owner.Id, "Mine"),
|
||||||
|
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
|
||||||
|
TestEntities.PlayList(other.Id, "Theirs"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var playList = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Mine", playList.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
|
var playList = Assert.Single(result.Value.Items);
|
||||||
{
|
Assert.Equal("Mine", playList.Name);
|
||||||
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);
|
[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"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var playList = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Road Trip", playList.Name);
|
Assert.False(result.IsError);
|
||||||
|
var playList = Assert.Single(result.Value.Items);
|
||||||
|
Assert.Equal("Road Trip", playList.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,31 @@ using Musify.Application.PlayLists;
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetPlayListsQueryHandler CreateHandler() => new(Database);
|
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsEveryPlayListPaged()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetPlayListsQueryHandler CreateHandler() => new(Database);
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsEveryPlayListPaged()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_NoPlayLists_ReturnsEmptyPage()
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Empty(result.Value.Items);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-39
@@ -4,56 +4,57 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
|
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_TrackInPlayList_RemovesTheLink()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackInPlayList_RemovesTheLink()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Empty(Database.PlayListHasTracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
Assert.Empty(Database.PlayListHasTracks);
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
}
|
||||||
{
|
|
||||||
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 RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
public async Task Handle_TrackNotLinked_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
await SeedAsync(owner, playList);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_TrackNotLinked_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-47
@@ -8,67 +8,68 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
|
||||||
{
|
{
|
||||||
storageService
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns("https://storage.musify.test/presigned-upload");
|
|
||||||
|
|
||||||
return new RequestPlayListPictureUploadCommandHandler(
|
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||||
Database,
|
{
|
||||||
new UploadIntentValidator(Database, storageService),
|
storageService
|
||||||
storageService,
|
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||||
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
|
.Returns("https://storage.musify.test/presigned-upload");
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.PlayList(),
|
|
||||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
return new RequestPlayListPictureUploadCommandHandler(
|
||||||
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
|
Database,
|
||||||
{
|
new UploadIntentValidator(Database, storageService),
|
||||||
var user = TestEntities.User();
|
storageService,
|
||||||
await SeedAsync(user);
|
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.PlayList(),
|
||||||
|
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||||
|
}
|
||||||
|
|
||||||
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
|
[Fact]
|
||||||
|
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
|
|
||||||
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
|
||||||
{
|
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
|
}
|
||||||
{
|
|
||||||
var config = TestConfigurations.UploadIntent();
|
|
||||||
config.MaxActiveUploadBytesPerUser = 100;
|
|
||||||
|
|
||||||
var user = TestEntities.User();
|
[Fact]
|
||||||
await SeedAsync(user);
|
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
|
||||||
|
{
|
||||||
|
var config = TestConfigurations.UploadIntent();
|
||||||
|
config.MaxActiveUploadBytesPerUser = 100;
|
||||||
|
|
||||||
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
|
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-54
@@ -6,75 +6,76 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.PlayLists;
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
|
||||||
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
|
||||||
|
|
||||||
private UpdatePlayListCommandHandler CreateHandler() => new(
|
|
||||||
eventBus,
|
|
||||||
Database,
|
|
||||||
new UploadIntentValidator(Database, storageService),
|
|
||||||
NoOpLogger<UpdatePlayListCommandHandler>(),
|
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.PlayList(),
|
|
||||||
TestConfigurations.UploadIntent());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_Owner_RenamesPlayList()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
var playList = TestEntities.PlayList(owner.Id, "Old Name");
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
await SeedAsync(owner, playList);
|
|
||||||
|
|
||||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
|
private UpdatePlayListCommandHandler CreateHandler() => new(
|
||||||
|
eventBus,
|
||||||
|
Database,
|
||||||
|
new UploadIntentValidator(Database, storageService),
|
||||||
|
NoOpLogger<UpdatePlayListCommandHandler>(),
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.PlayList(),
|
||||||
|
TestConfigurations.UploadIntent());
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_Owner_RenamesPlayList()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id, "Old Name");
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
|
||||||
Assert.Equal("New Name", result.Value.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("New Name", result.Value.Name);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
var owner = TestEntities.User(1, "owner");
|
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var playList = TestEntities.PlayList(owner.Id);
|
|
||||||
await SeedAsync(owner, stranger, playList);
|
|
||||||
|
|
||||||
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
|
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
|
||||||
await SeedAsync(owner, playList);
|
|
||||||
|
|
||||||
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
|
||||||
Assert.Equal("Original Name", result.Value.Name);
|
|
||||||
Assert.Equal("Original description", result.Value.Description);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("Original Name", result.Value.Name);
|
||||||
|
Assert.Equal("Original description", result.Value.Description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,46 +5,47 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Services;
|
namespace Musify.Application.Tests.Services
|
||||||
|
|
||||||
public sealed class TrackStreamIssuerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
public sealed class TrackStreamIssuerTests : HandlerTestBase
|
||||||
|
|
||||||
private TrackStreamIssuer CreateIssuer() => new(
|
|
||||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120));
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
var owner = TestEntities.User(7);
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken);
|
private TrackStreamIssuer CreateIssuer() => new(
|
||||||
|
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
|
||||||
|
|
||||||
Assert.Equal("signed-token", response.Ticket);
|
[Fact]
|
||||||
Assert.Equal(120, response.ExpiresInSeconds);
|
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
|
||||||
Assert.Equal(
|
{
|
||||||
"https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a",
|
ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120));
|
||||||
response.ManifestUrl);
|
var owner = TestEntities.User(7);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(7, history.UserId);
|
|
||||||
Assert.Equal(track.Id, history.TrackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal("signed-token", response.Ticket);
|
||||||
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
Assert.Equal(120, response.ExpiresInSeconds);
|
||||||
{
|
Assert.Equal(
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
"https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a",
|
||||||
var owner = TestEntities.User(1);
|
response.ManifestUrl);
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal(7, history.UserId);
|
||||||
|
Assert.Equal(track.Id, history.TrackId);
|
||||||
|
}
|
||||||
|
|
||||||
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/");
|
[Fact]
|
||||||
|
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
|
||||||
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
||||||
|
var owner = TestEntities.User(1);
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,140 +6,141 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Services;
|
namespace Musify.Application.Tests.Services
|
||||||
|
|
||||||
public sealed class UploadIntentValidatorTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
public sealed class UploadIntentValidatorTests : HandlerTestBase
|
||||||
private readonly Domain.Entities.User owner = TestEntities.User();
|
|
||||||
|
|
||||||
private UploadIntentValidator CreateValidator() => new(Database, storageService);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
|
|
||||||
{
|
{
|
||||||
storageService
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
|
private readonly Domain.Entities.User owner = TestEntities.User();
|
||||||
.Returns(new ObjectMetaData("image/webp", 2048));
|
|
||||||
|
|
||||||
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
private UploadIntentValidator CreateValidator() => new(Database, storageService);
|
||||||
await SeedAsync(owner, intent);
|
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
|
||||||
|
{
|
||||||
|
storageService
|
||||||
|
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ObjectMetaData("image/webp", 2048));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
|
||||||
Assert.Equal(intent.Id, result.Value.Id);
|
await SeedAsync(owner, intent);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.False(result.IsError);
|
||||||
}
|
Assert.Equal(intent.Id, result.Value.Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
|
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
|
||||||
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);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, intent);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
|
|
||||||
{
|
|
||||||
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);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
|
||||||
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
|
|
||||||
{
|
|
||||||
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
|
||||||
await SeedAsync(owner, intent);
|
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
|
||||||
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
storageService
|
|
||||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns((ObjectMetaData?)null);
|
|
||||||
|
|
||||||
var intent = TestEntities.UploadIntent(owner.Id);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
await SeedAsync(owner, intent);
|
}
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
storageService
|
||||||
|
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns((ObjectMetaData?)null);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
}
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
|
|
||||||
{
|
|
||||||
var config = TestConfigurations.UploadIntent();
|
|
||||||
config.MaxUploadBytes = 1024;
|
|
||||||
|
|
||||||
storageService
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
}
|
||||||
.Returns(new ObjectMetaData("image/webp", 2048));
|
|
||||||
|
|
||||||
var intent = TestEntities.UploadIntent(owner.Id);
|
[Fact]
|
||||||
await SeedAsync(owner, intent);
|
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var config = TestConfigurations.UploadIntent();
|
||||||
|
config.MaxUploadBytes = 1024;
|
||||||
|
|
||||||
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
storageService
|
||||||
|
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ObjectMetaData("image/webp", 2048));
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
var intent = TestEntities.UploadIntent(owner.Id);
|
||||||
}
|
await SeedAsync(owner, intent);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken);
|
||||||
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
|
|
||||||
{
|
|
||||||
await SeedAsync(owner);
|
|
||||||
|
|
||||||
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
}
|
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
|
||||||
|
{
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
||||||
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
|
|
||||||
{
|
|
||||||
var config = TestConfigurations.UploadIntent();
|
|
||||||
config.MaxActiveUploadIntentsPerUser = 1;
|
|
||||||
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
|
|
||||||
|
|
||||||
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var config = TestConfigurations.UploadIntent();
|
||||||
|
config.MaxActiveUploadIntentsPerUser = 1;
|
||||||
|
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
||||||
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
|
|
||||||
{
|
|
||||||
var config = TestConfigurations.UploadIntent();
|
|
||||||
config.MaxActiveUploadIntentsPerUser = 1;
|
|
||||||
await SeedAsync(
|
|
||||||
owner,
|
|
||||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
|
|
||||||
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
|
|
||||||
|
|
||||||
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
|
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
|
||||||
|
{
|
||||||
|
var config = TestConfigurations.UploadIntent();
|
||||||
|
config.MaxActiveUploadIntentsPerUser = 1;
|
||||||
|
await SeedAsync(
|
||||||
|
owner,
|
||||||
|
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
|
||||||
|
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
|
||||||
|
|
||||||
|
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,99 +5,100 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Services;
|
namespace Musify.Application.Tests.Services
|
||||||
|
|
||||||
public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
|
||||||
|
|
||||||
private YouTubeTrackProvisioner CreateProvisioner() =>
|
|
||||||
new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task FindAsync_UnknownVideo_ReturnsNull()
|
|
||||||
{
|
{
|
||||||
var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken);
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
|
|
||||||
Assert.Null(track);
|
private YouTubeTrackProvisioner CreateProvisioner() =>
|
||||||
}
|
new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService()
|
public async Task FindAsync_UnknownVideo_ReturnsNull()
|
||||||
{
|
{
|
||||||
var existing = TestEntities.ExternalTrack(externalId: "known-video");
|
var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken);
|
||||||
await SeedAsync(existing);
|
|
||||||
|
|
||||||
var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken);
|
Assert.Null(track);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Equal(existing.Id, result.Value.Track.Id);
|
public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService()
|
||||||
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken);
|
{
|
||||||
}
|
var existing = TestEntities.ExternalTrack(externalId: "known-video");
|
||||||
|
await SeedAsync(existing);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken);
|
||||||
public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
|
|
||||||
{
|
|
||||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
|
|
||||||
new YouTubeSongResult(
|
|
||||||
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
|
|
||||||
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
|
|
||||||
|
|
||||||
var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(existing.Id, result.Value.Track.Id);
|
||||||
|
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Equal("New Song", result.Value.Track.Title);
|
public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
|
||||||
Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken));
|
{
|
||||||
Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken));
|
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
|
||||||
}
|
new YouTubeSongResult(
|
||||||
|
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
|
||||||
|
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken);
|
||||||
public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
|
|
||||||
{
|
|
||||||
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
|
|
||||||
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
|
|
||||||
|
|
||||||
var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("New Song", result.Value.Track.Title);
|
||||||
|
Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.True(result.IsError);
|
[Fact]
|
||||||
}
|
public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
|
||||||
|
{
|
||||||
|
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken);
|
||||||
public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
|
|
||||||
{
|
|
||||||
var detail = new YouTubeAlbumDetail(
|
|
||||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
|
|
||||||
[new YouTubeArtistRef(null, "Album Artist")]),
|
|
||||||
Description: "desc",
|
|
||||||
TotalDurationSeconds: 400,
|
|
||||||
Tracks:
|
|
||||||
[
|
|
||||||
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
|
|
||||||
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken));
|
[Fact]
|
||||||
Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken));
|
public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
|
||||||
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
{
|
||||||
}
|
var detail = new YouTubeAlbumDetail(
|
||||||
|
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
|
||||||
|
[new YouTubeArtistRef(null, "Album Artist")]),
|
||||||
|
Description: "desc",
|
||||||
|
TotalDurationSeconds: 400,
|
||||||
|
Tracks:
|
||||||
|
[
|
||||||
|
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
|
||||||
|
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
|
||||||
|
]);
|
||||||
|
|
||||||
[Fact]
|
await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
||||||
public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
|
|
||||||
{
|
|
||||||
var detail = new YouTubeAlbumDetail(
|
|
||||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
|
||||||
Description: "desc",
|
|
||||||
TotalDurationSeconds: 200,
|
|
||||||
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
|
||||||
|
|
||||||
var provisioner = CreateProvisioner();
|
Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken));
|
||||||
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken));
|
||||||
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken));
|
[Fact]
|
||||||
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
|
||||||
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
{
|
||||||
|
var detail = new YouTubeAlbumDetail(
|
||||||
|
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
||||||
|
Description: "desc",
|
||||||
|
TotalDurationSeconds: 200,
|
||||||
|
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
||||||
|
|
||||||
|
var provisioner = CreateProvisioner();
|
||||||
|
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
||||||
|
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Shared;
|
namespace Musify.Application.Tests.Shared
|
||||||
|
|
||||||
public sealed class StorageKeyTests
|
|
||||||
{
|
{
|
||||||
[Fact]
|
public sealed class StorageKeyTests
|
||||||
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
|
|
||||||
{
|
{
|
||||||
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
|
[Fact]
|
||||||
|
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
|
||||||
|
{
|
||||||
|
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
|
||||||
|
|
||||||
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
|
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
|
||||||
{
|
{
|
||||||
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
|
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
|
||||||
|
|
||||||
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
Assert.Equal("Tracks/SmallPictures/cover.webp", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
[InlineData(" ")]
|
[InlineData(" ")]
|
||||||
public void Combine_BlankSegment_IsSkipped(string? blank)
|
public void Combine_BlankSegment_IsSkipped(string? blank)
|
||||||
{
|
{
|
||||||
var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
|
var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
|
||||||
|
|
||||||
Assert.Equal("Tracks/cover.webp", key);
|
Assert.Equal("Tracks/cover.webp", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Combine_NoSegments_ReturnsEmptyString()
|
public void Combine_NoSegments_ReturnsEmptyString()
|
||||||
{
|
{
|
||||||
Assert.Equal(string.Empty, StorageKey.Combine());
|
Assert.Equal(string.Empty, StorageKey.Combine());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,49 +4,50 @@ using Musify.Domain.Entities;
|
|||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Shared;
|
namespace Musify.Application.Tests.Shared
|
||||||
|
|
||||||
public sealed class TrackApplicationResponseTests
|
|
||||||
{
|
{
|
||||||
[Fact]
|
public sealed class TrackApplicationResponseTests
|
||||||
public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "The Owner");
|
[Fact]
|
||||||
var track = TestEntities.LocalTrack(owner);
|
public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "The Owner");
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
|
||||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
|
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
|
||||||
|
|
||||||
Assert.Equal("The Owner", response.Artist);
|
Assert.Equal("The Owner", response.Artist);
|
||||||
Assert.Equal(TrackSource.Local, response.Source);
|
Assert.Equal(TrackSource.Local, response.Source);
|
||||||
Assert.Equal(owner.Id, response.OwnerUserId);
|
Assert.Equal(owner.Id, response.OwnerUserId);
|
||||||
Assert.Equal(3, response.ListensCount);
|
Assert.Equal(3, response.ListensCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder()
|
public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder()
|
||||||
{
|
{
|
||||||
var track = TestEntities.ExternalTrack();
|
var track = TestEntities.ExternalTrack();
|
||||||
var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" };
|
var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" };
|
||||||
var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" };
|
var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" };
|
||||||
track.TrackArtists =
|
track.TrackArtists =
|
||||||
[
|
[
|
||||||
new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track },
|
new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track },
|
||||||
new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track }
|
new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track }
|
||||||
];
|
];
|
||||||
|
|
||||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||||
|
|
||||||
Assert.Equal("First Artist, Second Artist", response.Artist);
|
Assert.Equal("First Artist, Second Artist", response.Artist);
|
||||||
Assert.Equal(track.ExternalId, response.ExternalId);
|
Assert.Equal(track.ExternalId, response.ExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist()
|
public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist()
|
||||||
{
|
{
|
||||||
var track = TestEntities.ExternalTrack();
|
var track = TestEntities.ExternalTrack();
|
||||||
|
|
||||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||||
|
|
||||||
Assert.Null(response.Artist);
|
Assert.Null(response.Artist);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,33 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.TestSupport;
|
namespace Musify.Application.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Base class for handler tests: gives every test method its own empty, isolated
|
|
||||||
/// <see cref="TestDatabase"/> (xUnit creates a new instance of the test class per test method,
|
|
||||||
/// so there is no state to reset between tests) and a no-op logger factory for handlers that
|
|
||||||
/// take an <see cref="ILogger{TCategoryName}"/>.
|
|
||||||
/// </summary>
|
|
||||||
public abstract class HandlerTestBase : IAsyncLifetime
|
|
||||||
{
|
{
|
||||||
private TestDatabase database = null!;
|
/// <summary>
|
||||||
|
/// Base class for handler tests: gives every test method its own empty, isolated
|
||||||
protected TestDatabase Database => database;
|
/// <see cref="TestDatabase"/> (xUnit creates a new instance of the test class per test method,
|
||||||
|
/// so there is no state to reset between tests) and a no-op logger factory for handlers that
|
||||||
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
|
/// take an <see cref="ILogger{TCategoryName}"/>.
|
||||||
|
/// </summary>
|
||||||
public ValueTask DisposeAsync() => database.DisposeAsync();
|
public abstract class HandlerTestBase : IAsyncLifetime
|
||||||
|
|
||||||
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
|
|
||||||
|
|
||||||
/// <summary>Adds the entities and persists them, so handlers under test see committed data.</summary>
|
|
||||||
protected async Task SeedAsync(params object[] entities)
|
|
||||||
{
|
{
|
||||||
foreach (var entity in entities)
|
private TestDatabase database = null!;
|
||||||
Database.Add(entity);
|
|
||||||
|
|
||||||
await Database.SaveChangesAsync(CancellationToken.None);
|
protected TestDatabase Database => database;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => database.DisposeAsync();
|
||||||
|
|
||||||
|
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
|
||||||
|
|
||||||
|
/// <summary>Adds the entities and persists them, so handlers under test see committed data.</summary>
|
||||||
|
protected async Task SeedAsync(params object[] entities)
|
||||||
|
{
|
||||||
|
foreach (var entity in entities)
|
||||||
|
Database.Add(entity);
|
||||||
|
|
||||||
|
await Database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.TestSupport;
|
namespace Musify.Application.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration POCOs with every <c>required</c> member filled and the rest left at their
|
|
||||||
/// production defaults, so handler tests don't have to know which fields matter for them.
|
|
||||||
/// </summary>
|
|
||||||
public static class TestConfigurations
|
|
||||||
{
|
{
|
||||||
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
|
/// <summary>
|
||||||
new() { Bucket = bucket };
|
/// Configuration POCOs with every <c>required</c> member filled and the rest left at their
|
||||||
|
/// production defaults, so handler tests don't have to know which fields matter for them.
|
||||||
|
/// </summary>
|
||||||
|
public static class TestConfigurations
|
||||||
|
{
|
||||||
|
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
|
||||||
|
new() { Bucket = bucket };
|
||||||
|
|
||||||
public static TrackConfiguration Track() => new();
|
public static TrackConfiguration Track() => new();
|
||||||
|
|
||||||
public static PlayListConfiguration PlayList() => new();
|
public static PlayListConfiguration PlayList() => new();
|
||||||
|
|
||||||
public static MixConfiguration Mix() => new();
|
public static MixConfiguration Mix() => new();
|
||||||
|
|
||||||
public static UploadIntentConfiguration UploadIntent() => new();
|
public static UploadIntentConfiguration UploadIntent() => new();
|
||||||
|
|
||||||
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
|
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
|
||||||
new() { PublicBaseUrl = publicBaseUrl };
|
new() { PublicBaseUrl = publicBaseUrl };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,209 +3,210 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.TestSupport;
|
namespace Musify.Application.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An <see cref="IDatabase"/> backed by a private, in-memory SQLite database.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="IDatabase"/> exposes raw <see cref="DbSet{TEntity}"/> properties, so handlers
|
|
||||||
/// compose real LINQ queries (<c>Where</c>, <c>Select</c>, <c>Include</c>, ...) against it.
|
|
||||||
/// Substituting <see cref="IDatabase"/> with a mock cannot express that — there is no
|
|
||||||
/// <see cref="IQueryable{T}"/> provider behind a mock. SQLite's in-memory mode gives us a real
|
|
||||||
/// relational engine instead: fast, no external dependency, and — unlike the EF Core InMemory
|
|
||||||
/// provider — it actually supports transactions, which two handlers rely on.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The model mirrors the parts of <c>Musify.Infrastructure.Persistence.Database</c> that affect
|
|
||||||
/// query shape (owned types, table-per-type inheritance, unique indexes). MassTransit outbox/saga
|
|
||||||
/// state and Postgres-specific configuration are left out — the Application layer never touches
|
|
||||||
/// them.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class TestDatabase : DbContext, IDatabase
|
|
||||||
{
|
{
|
||||||
private readonly SqliteConnection connection;
|
|
||||||
|
|
||||||
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
this.connection = connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a fresh, empty database with its own private connection. Call <see cref="Seed"/>
|
/// An <see cref="IDatabase"/> backed by a private, in-memory SQLite database.
|
||||||
/// (via <see cref="Seeding.EntitySeedExtensions"/>) to populate it, then <see cref="DisposeAsync"/>
|
|
||||||
/// when the test is done.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<TestDatabase> CreateAsync()
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="IDatabase"/> exposes raw <see cref="DbSet{TEntity}"/> properties, so handlers
|
||||||
|
/// compose real LINQ queries (<c>Where</c>, <c>Select</c>, <c>Include</c>, ...) against it.
|
||||||
|
/// Substituting <see cref="IDatabase"/> with a mock cannot express that — there is no
|
||||||
|
/// <see cref="IQueryable{T}"/> provider behind a mock. SQLite's in-memory mode gives us a real
|
||||||
|
/// relational engine instead: fast, no external dependency, and — unlike the EF Core InMemory
|
||||||
|
/// provider — it actually supports transactions, which two handlers rely on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The model mirrors the parts of <c>Musify.Infrastructure.Persistence.Database</c> that affect
|
||||||
|
/// query shape (owned types, table-per-type inheritance, unique indexes). MassTransit outbox/saga
|
||||||
|
/// state and Postgres-specific configuration are left out — the Application layer never touches
|
||||||
|
/// them.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TestDatabase : DbContext, IDatabase
|
||||||
{
|
{
|
||||||
var connection = new SqliteConnection("Filename=:memory:");
|
private readonly SqliteConnection connection;
|
||||||
await connection.OpenAsync();
|
|
||||||
|
|
||||||
var options = new DbContextOptionsBuilder<TestDatabase>()
|
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
|
||||||
.UseSqlite(connection)
|
: base(options)
|
||||||
.Options;
|
{
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
var database = new TestDatabase(connection, options);
|
/// <summary>
|
||||||
await database.Database.EnsureCreatedAsync();
|
/// Creates a fresh, empty database with its own private connection. Call <see cref="Seed"/>
|
||||||
return database;
|
/// (via <see cref="Seeding.EntitySeedExtensions"/>) to populate it, then <see cref="DisposeAsync"/>
|
||||||
}
|
/// when the test is done.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<TestDatabase> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Filename=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
var options = new DbContextOptionsBuilder<TestDatabase>()
|
||||||
{
|
.UseSqlite(connection)
|
||||||
base.OnModelCreating(modelBuilder);
|
.Options;
|
||||||
|
|
||||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures);
|
var database = new TestDatabase(connection, options);
|
||||||
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
|
await database.Database.EnsureCreatedAsync();
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks");
|
modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures);
|
||||||
|
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
|
||||||
|
|
||||||
modelBuilder.Entity<LocalTrack>()
|
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
|
||||||
.HasOne(track => track.Owner)
|
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(track => track.OwnerUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks");
|
modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks");
|
||||||
|
|
||||||
modelBuilder.Entity<ExternalTrack>()
|
modelBuilder.Entity<LocalTrack>()
|
||||||
.HasIndex(track => new { track.Source, track.ExternalId })
|
.HasOne(track => track.Owner)
|
||||||
.IsUnique();
|
.WithMany()
|
||||||
|
.HasForeignKey(track => track.OwnerUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<TrackArtist>()
|
modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks");
|
||||||
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
|
|
||||||
|
|
||||||
modelBuilder.Entity<TrackArtist>()
|
modelBuilder.Entity<ExternalTrack>()
|
||||||
.HasOne(trackArtist => trackArtist.Track)
|
.HasIndex(track => new { track.Source, track.ExternalId })
|
||||||
.WithMany(track => track.TrackArtists)
|
.IsUnique();
|
||||||
.HasForeignKey(trackArtist => trackArtist.TrackId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<TrackArtist>()
|
modelBuilder.Entity<TrackArtist>()
|
||||||
.HasOne(trackArtist => trackArtist.Artist)
|
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
|
||||||
.WithMany(artist => artist.TrackArtists)
|
|
||||||
.HasForeignKey(trackArtist => trackArtist.ArtistId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Artist>()
|
modelBuilder.Entity<TrackArtist>()
|
||||||
.HasIndex(artist => artist.ExternalId)
|
.HasOne(trackArtist => trackArtist.Track)
|
||||||
.IsUnique();
|
.WithMany(track => track.TrackArtists)
|
||||||
|
.HasForeignKey(trackArtist => trackArtist.TrackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
|
modelBuilder.Entity<TrackArtist>()
|
||||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
|
.HasOne(trackArtist => trackArtist.Artist)
|
||||||
|
.WithMany(artist => artist.TrackArtists)
|
||||||
|
.HasForeignKey(trackArtist => trackArtist.ArtistId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
modelBuilder.Entity<Artist>()
|
||||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
.HasIndex(artist => artist.ExternalId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums");
|
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
|
||||||
|
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
|
||||||
|
|
||||||
modelBuilder.Entity<UserAlbum>()
|
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
||||||
.HasOne(album => album.Owner)
|
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(album => album.OwnerUserId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums");
|
modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums");
|
||||||
|
|
||||||
modelBuilder.Entity<ExternalAlbum>()
|
modelBuilder.Entity<UserAlbum>()
|
||||||
.HasIndex(album => new { album.Source, album.ExternalId })
|
.HasOne(album => album.Owner)
|
||||||
.IsUnique();
|
.WithMany()
|
||||||
|
.HasForeignKey(album => album.OwnerUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums");
|
||||||
.HasOne(albumTrack => albumTrack.Album)
|
|
||||||
.WithMany(album => album.AlbumTracks)
|
|
||||||
.HasForeignKey(albumTrack => albumTrack.AlbumId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
modelBuilder.Entity<ExternalAlbum>()
|
||||||
.HasOne(albumTrack => albumTrack.Track)
|
.HasIndex(album => new { album.Source, album.ExternalId })
|
||||||
.WithMany(track => track.AlbumTracks)
|
.IsUnique();
|
||||||
.HasForeignKey(albumTrack => albumTrack.TrackId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
modelBuilder.Entity<AlbumHasTrack>()
|
||||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
.HasOne(albumTrack => albumTrack.Album)
|
||||||
.IsUnique();
|
.WithMany(album => album.AlbumTracks)
|
||||||
|
.HasForeignKey(albumTrack => albumTrack.AlbumId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<Mix>()
|
modelBuilder.Entity<AlbumHasTrack>()
|
||||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
.HasOne(albumTrack => albumTrack.Track)
|
||||||
|
.WithMany(track => track.AlbumTracks)
|
||||||
|
.HasForeignKey(albumTrack => albumTrack.TrackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
modelBuilder.Entity<AlbumHasTrack>()
|
||||||
.HasOne(item => item.Mix)
|
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||||
.WithMany(mix => mix.Items)
|
.IsUnique();
|
||||||
.HasForeignKey(item => item.MixId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
modelBuilder.Entity<Mix>()
|
||||||
.HasOne(item => item.Track)
|
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(item => item.TrackId)
|
|
||||||
.IsRequired(false)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
modelBuilder.Entity<MixItem>()
|
modelBuilder.Entity<MixItem>()
|
||||||
.HasIndex(item => new { item.MixId, item.Position });
|
.HasOne(item => item.Mix)
|
||||||
}
|
.WithMany(mix => mix.Items)
|
||||||
|
.HasForeignKey(item => item.MixId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
public DbSet<User> Users => Set<User>();
|
modelBuilder.Entity<MixItem>()
|
||||||
|
.HasOne(item => item.Track)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(item => item.TrackId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
public DbSet<PlayList> PlayLists => Set<PlayList>();
|
modelBuilder.Entity<MixItem>()
|
||||||
|
.HasIndex(item => new { item.MixId, item.Position });
|
||||||
|
}
|
||||||
|
|
||||||
public DbSet<Album> Albums => Set<Album>();
|
public DbSet<User> Users => Set<User>();
|
||||||
|
|
||||||
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
|
public DbSet<PlayList> PlayLists => Set<PlayList>();
|
||||||
|
|
||||||
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
|
public DbSet<Album> Albums => Set<Album>();
|
||||||
|
|
||||||
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
|
||||||
|
|
||||||
public DbSet<Track> Tracks => Set<Track>();
|
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
|
||||||
|
|
||||||
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
|
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
||||||
|
|
||||||
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
|
public DbSet<Track> Tracks => Set<Track>();
|
||||||
|
|
||||||
public DbSet<Artist> Artists => Set<Artist>();
|
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
|
||||||
|
|
||||||
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
|
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
|
||||||
|
|
||||||
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
public DbSet<Artist> Artists => Set<Artist>();
|
||||||
|
|
||||||
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
|
||||||
|
|
||||||
public DbSet<Mix> Mixes => Set<Mix>();
|
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
||||||
|
|
||||||
public DbSet<MixItem> MixItems => Set<MixItem>();
|
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
|
||||||
|
|
||||||
public DbSet<Upload> Uploads => Set<Upload>();
|
public DbSet<Mix> Mixes => Set<Mix>();
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
public DbSet<MixItem> MixItems => Set<MixItem>();
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
public DbSet<Upload> Uploads => Set<Upload>();
|
||||||
|
|
||||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
|
||||||
{
|
|
||||||
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
|
||||||
return new SqliteDatabaseTransaction(transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
|
||||||
{
|
|
||||||
await base.DisposeAsync();
|
|
||||||
await connection.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
|
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
||||||
: IDatabaseTransaction
|
{
|
||||||
{
|
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
||||||
public Task CommitAsync(CancellationToken cancellationToken = default) =>
|
return new SqliteDatabaseTransaction(transaction);
|
||||||
inner.CommitAsync(cancellationToken);
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => inner.DisposeAsync();
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await base.DisposeAsync();
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
|
||||||
|
: IDatabaseTransaction
|
||||||
|
{
|
||||||
|
public Task CommitAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
inner.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => inner.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,237 +1,238 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.TestSupport;
|
namespace Musify.Application.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds fully-populated domain entities for tests, with sensible defaults for every
|
|
||||||
/// <c>required</c> member. Every factory always returns a valid instance — never null — so
|
|
||||||
/// callers never need a null check on what this class hands back.
|
|
||||||
/// </summary>
|
|
||||||
public static class TestEntities
|
|
||||||
{
|
{
|
||||||
public static User User(
|
/// <summary>
|
||||||
long id = 1,
|
/// Builds fully-populated domain entities for tests, with sensible defaults for every
|
||||||
string name = "test-user",
|
/// <c>required</c> member. Every factory always returns a valid instance — never null — so
|
||||||
string? firstName = "Test",
|
/// callers never need a null check on what this class hands back.
|
||||||
string? secondName = "User",
|
/// </summary>
|
||||||
string? profilePictureUrl = null) =>
|
public static class TestEntities
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Name = name,
|
|
||||||
NormalizedName = name.ToUpperInvariant(),
|
|
||||||
FirstName = firstName,
|
|
||||||
SecondName = secondName,
|
|
||||||
ProfilePictureUrl = profilePictureUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
public static TrackPictures ProcessedPictures() => new()
|
|
||||||
{
|
{
|
||||||
OriginalName = "original.webp",
|
public static User User(
|
||||||
SmallName = "small.webp",
|
long id = 1,
|
||||||
MediumName = "medium.webp",
|
string name = "test-user",
|
||||||
LargeName = "large.webp",
|
string? firstName = "Test",
|
||||||
ProcessingStatus = ProcessingStatus.Completed
|
string? secondName = "User",
|
||||||
};
|
string? profilePictureUrl = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
NormalizedName = name.ToUpperInvariant(),
|
||||||
|
FirstName = firstName,
|
||||||
|
SecondName = secondName,
|
||||||
|
ProfilePictureUrl = profilePictureUrl
|
||||||
|
};
|
||||||
|
|
||||||
public static TrackPictures PendingPictures() => new()
|
public static TrackPictures ProcessedPictures() => new()
|
||||||
{
|
|
||||||
OriginalName = "original.webp",
|
|
||||||
ProcessingStatus = ProcessingStatus.Pending
|
|
||||||
};
|
|
||||||
|
|
||||||
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
|
|
||||||
{
|
|
||||||
OriginalName = "original.mp3",
|
|
||||||
FolderName = folderName,
|
|
||||||
TranscodeStatus = ProcessingStatus.Completed
|
|
||||||
};
|
|
||||||
|
|
||||||
public static TrackAudio PendingAudio() => new()
|
|
||||||
{
|
|
||||||
OriginalName = "original.mp3",
|
|
||||||
TranscodeStatus = ProcessingStatus.Pending
|
|
||||||
};
|
|
||||||
|
|
||||||
public static LocalTrack LocalTrack(
|
|
||||||
User owner,
|
|
||||||
string title = "Test Track",
|
|
||||||
double durationSeconds = 180,
|
|
||||||
TrackPictures? pictures = null,
|
|
||||||
TrackAudio? audio = null,
|
|
||||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
|
||||||
new()
|
|
||||||
{
|
{
|
||||||
Title = title,
|
OriginalName = "original.webp",
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
SmallName = "small.webp",
|
||||||
DurationSeconds = durationSeconds,
|
MediumName = "medium.webp",
|
||||||
LifeCycleStatus = lifeCycleStatus,
|
LargeName = "large.webp",
|
||||||
OwnerUserId = owner.Id,
|
ProcessingStatus = ProcessingStatus.Completed
|
||||||
Owner = owner,
|
|
||||||
Pictures = pictures ?? ProcessedPictures(),
|
|
||||||
Audio = audio ?? ProcessedAudio()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ExternalTrack ExternalTrack(
|
public static TrackPictures PendingPictures() => new()
|
||||||
string externalId = "video-id",
|
|
||||||
string title = "Test External Track",
|
|
||||||
double durationSeconds = 180,
|
|
||||||
TrackSource source = TrackSource.YouTube,
|
|
||||||
TrackPictures? pictures = null,
|
|
||||||
TrackAudio? audio = null,
|
|
||||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
|
||||||
new()
|
|
||||||
{
|
{
|
||||||
Title = title,
|
OriginalName = "original.webp",
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
ProcessingStatus = ProcessingStatus.Pending
|
||||||
DurationSeconds = durationSeconds,
|
|
||||||
LifeCycleStatus = lifeCycleStatus,
|
|
||||||
Source = source,
|
|
||||||
ExternalId = externalId,
|
|
||||||
Pictures = pictures ?? ProcessedPictures(),
|
|
||||||
Audio = audio ?? ProcessedAudio()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static Artist Artist(
|
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
|
||||||
string name = "Test Artist",
|
|
||||||
string externalId = "artist-id") =>
|
|
||||||
new()
|
|
||||||
{
|
{
|
||||||
Name = name,
|
OriginalName = "original.mp3",
|
||||||
NormalizedName = name.ToUpperInvariant(),
|
FolderName = folderName,
|
||||||
ExternalId = externalId
|
TranscodeStatus = ProcessingStatus.Completed
|
||||||
};
|
};
|
||||||
|
|
||||||
public static PlayListPictures PlayListPictures() => new()
|
public static TrackAudio PendingAudio() => new()
|
||||||
{
|
|
||||||
OriginalName = "original.webp",
|
|
||||||
SmallName = "small.webp",
|
|
||||||
MediumName = "medium.webp",
|
|
||||||
LargeName = "large.webp"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static PlayList PlayList(
|
|
||||||
long userId,
|
|
||||||
string name = "Test Playlist",
|
|
||||||
string? description = null,
|
|
||||||
PlayListPictures? pictures = null,
|
|
||||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
|
||||||
new()
|
|
||||||
{
|
{
|
||||||
UserId = userId,
|
OriginalName = "original.mp3",
|
||||||
Name = name,
|
TranscodeStatus = ProcessingStatus.Pending
|
||||||
NormalizedName = name.ToUpperInvariant(),
|
|
||||||
Description = description,
|
|
||||||
Pictures = pictures ?? PlayListPictures(),
|
|
||||||
LifeCycleStatus = lifeCycleStatus
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static AlbumPictures AlbumPictures() => new()
|
public static LocalTrack LocalTrack(
|
||||||
{
|
User owner,
|
||||||
OriginalName = "original.webp",
|
string title = "Test Track",
|
||||||
SmallName = "small.webp",
|
double durationSeconds = 180,
|
||||||
MediumName = "medium.webp",
|
TrackPictures? pictures = null,
|
||||||
LargeName = "large.webp"
|
TrackAudio? audio = null,
|
||||||
};
|
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
LifeCycleStatus = lifeCycleStatus,
|
||||||
|
OwnerUserId = owner.Id,
|
||||||
|
Owner = owner,
|
||||||
|
Pictures = pictures ?? ProcessedPictures(),
|
||||||
|
Audio = audio ?? ProcessedAudio()
|
||||||
|
};
|
||||||
|
|
||||||
public static UserAlbum UserAlbum(
|
public static ExternalTrack ExternalTrack(
|
||||||
long ownerUserId,
|
string externalId = "video-id",
|
||||||
string title = "Test Album",
|
string title = "Test External Track",
|
||||||
string? description = null,
|
double durationSeconds = 180,
|
||||||
int? releaseYear = 2024,
|
TrackSource source = TrackSource.YouTube,
|
||||||
AlbumPictures? pictures = null) =>
|
TrackPictures? pictures = null,
|
||||||
new()
|
TrackAudio? audio = null,
|
||||||
|
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
LifeCycleStatus = lifeCycleStatus,
|
||||||
|
Source = source,
|
||||||
|
ExternalId = externalId,
|
||||||
|
Pictures = pictures ?? ProcessedPictures(),
|
||||||
|
Audio = audio ?? ProcessedAudio()
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Artist Artist(
|
||||||
|
string name = "Test Artist",
|
||||||
|
string externalId = "artist-id") =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
NormalizedName = name.ToUpperInvariant(),
|
||||||
|
ExternalId = externalId
|
||||||
|
};
|
||||||
|
|
||||||
|
public static PlayListPictures PlayListPictures() => new()
|
||||||
{
|
{
|
||||||
Title = title,
|
OriginalName = "original.webp",
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
SmallName = "small.webp",
|
||||||
Description = description,
|
MediumName = "medium.webp",
|
||||||
ReleaseYear = releaseYear,
|
LargeName = "large.webp"
|
||||||
OwnerUserId = ownerUserId,
|
|
||||||
Pictures = pictures ?? AlbumPictures()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ExternalAlbum ExternalAlbum(
|
public static PlayList PlayList(
|
||||||
string externalId = "album-id",
|
long userId,
|
||||||
string title = "Test External Album",
|
string name = "Test Playlist",
|
||||||
TrackSource source = TrackSource.YouTube,
|
string? description = null,
|
||||||
string? thumbnailUrl = null) =>
|
PlayListPictures? pictures = null,
|
||||||
new()
|
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Name = name,
|
||||||
|
NormalizedName = name.ToUpperInvariant(),
|
||||||
|
Description = description,
|
||||||
|
Pictures = pictures ?? PlayListPictures(),
|
||||||
|
LifeCycleStatus = lifeCycleStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AlbumPictures AlbumPictures() => new()
|
||||||
{
|
{
|
||||||
Title = title,
|
OriginalName = "original.webp",
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
SmallName = "small.webp",
|
||||||
Source = source,
|
MediumName = "medium.webp",
|
||||||
ExternalId = externalId,
|
LargeName = "large.webp"
|
||||||
ThumbnailUrl = thumbnailUrl
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public static UploadIntent UploadIntent(
|
public static UserAlbum UserAlbum(
|
||||||
long userId,
|
long ownerUserId,
|
||||||
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
|
string title = "Test Album",
|
||||||
string bucket = "test-bucket",
|
string? description = null,
|
||||||
string key = "temp/object.webp",
|
int? releaseYear = 2024,
|
||||||
string objectName = "object.webp",
|
AlbumPictures? pictures = null) =>
|
||||||
string contentType = "image/webp",
|
new()
|
||||||
long? expectedSizeBytes = 1024,
|
{
|
||||||
UploadIntentStatus status = UploadIntentStatus.Issued,
|
Title = title,
|
||||||
DateTime? expiresAt = null) =>
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
new()
|
Description = description,
|
||||||
{
|
ReleaseYear = releaseYear,
|
||||||
UserId = userId,
|
OwnerUserId = ownerUserId,
|
||||||
Bucket = bucket,
|
Pictures = pictures ?? AlbumPictures()
|
||||||
Key = key,
|
};
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = contentType,
|
|
||||||
ExpectedSizeBytes = expectedSizeBytes,
|
|
||||||
Purpose = purpose,
|
|
||||||
Status = status,
|
|
||||||
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
|
|
||||||
};
|
|
||||||
|
|
||||||
public static Mix Mix(
|
public static ExternalAlbum ExternalAlbum(
|
||||||
long userId,
|
string externalId = "album-id",
|
||||||
string title = "Test Mix",
|
string title = "Test External Album",
|
||||||
string? subtitle = null,
|
TrackSource source = TrackSource.YouTube,
|
||||||
int position = 0) =>
|
string? thumbnailUrl = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
UserId = userId,
|
Title = title,
|
||||||
Title = title,
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
Subtitle = subtitle,
|
Source = source,
|
||||||
Position = position
|
ExternalId = externalId,
|
||||||
};
|
ThumbnailUrl = thumbnailUrl
|
||||||
|
};
|
||||||
|
|
||||||
public static MixItem MixItem(
|
public static UploadIntent UploadIntent(
|
||||||
Guid mixId,
|
long userId,
|
||||||
int position = 0,
|
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
|
||||||
MixItemSource source = MixItemSource.Musify,
|
string bucket = "test-bucket",
|
||||||
Guid? trackId = null,
|
string key = "temp/object.webp",
|
||||||
string? videoId = null,
|
string objectName = "object.webp",
|
||||||
string title = "Test Mix Item",
|
string contentType = "image/webp",
|
||||||
string? artist = "Test Artist",
|
long? expectedSizeBytes = 1024,
|
||||||
double durationSeconds = 180,
|
UploadIntentStatus status = UploadIntentStatus.Issued,
|
||||||
bool isExplicit = false) =>
|
DateTime? expiresAt = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
MixId = mixId,
|
UserId = userId,
|
||||||
Position = position,
|
Bucket = bucket,
|
||||||
Source = source,
|
Key = key,
|
||||||
TrackId = trackId,
|
ObjectName = objectName,
|
||||||
VideoId = videoId,
|
ContentType = contentType,
|
||||||
Title = title,
|
ExpectedSizeBytes = expectedSizeBytes,
|
||||||
Artist = artist,
|
Purpose = purpose,
|
||||||
DurationSeconds = durationSeconds,
|
Status = status,
|
||||||
IsExplicit = isExplicit
|
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ListeningHistory ListeningHistory(
|
public static Mix Mix(
|
||||||
long userId,
|
long userId,
|
||||||
Guid trackId,
|
string title = "Test Mix",
|
||||||
DateTime? listenedAt = null) =>
|
string? subtitle = null,
|
||||||
new()
|
int position = 0) =>
|
||||||
{
|
new()
|
||||||
UserId = userId,
|
{
|
||||||
TrackId = trackId,
|
UserId = userId,
|
||||||
ListenedAt = listenedAt ?? DateTime.UtcNow
|
Title = title,
|
||||||
};
|
Subtitle = subtitle,
|
||||||
|
Position = position
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MixItem MixItem(
|
||||||
|
Guid mixId,
|
||||||
|
int position = 0,
|
||||||
|
MixItemSource source = MixItemSource.Musify,
|
||||||
|
Guid? trackId = null,
|
||||||
|
string? videoId = null,
|
||||||
|
string title = "Test Mix Item",
|
||||||
|
string? artist = "Test Artist",
|
||||||
|
double durationSeconds = 180,
|
||||||
|
bool isExplicit = false) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
MixId = mixId,
|
||||||
|
Position = position,
|
||||||
|
Source = source,
|
||||||
|
TrackId = trackId,
|
||||||
|
VideoId = videoId,
|
||||||
|
Title = title,
|
||||||
|
Artist = artist,
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
IsExplicit = isExplicit
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ListeningHistory ListeningHistory(
|
||||||
|
long userId,
|
||||||
|
Guid trackId,
|
||||||
|
DateTime? listenedAt = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
TrackId = trackId,
|
||||||
|
ListenedAt = listenedAt ?? DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,92 +7,93 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
|
||||||
|
|
||||||
private CreateTrackCommandHandler CreateHandler()
|
|
||||||
{
|
{
|
||||||
storageService
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
.Returns(new ObjectMetaData("image/webp", 1024));
|
|
||||||
|
|
||||||
return new CreateTrackCommandHandler(
|
private CreateTrackCommandHandler CreateHandler()
|
||||||
Database,
|
{
|
||||||
eventBus,
|
storageService
|
||||||
new UploadIntentValidator(Database, storageService),
|
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
NoOpLogger<CreateTrackCommandHandler>(),
|
.Returns(new ObjectMetaData("image/webp", 1024));
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.Track(),
|
|
||||||
TestConfigurations.UploadIntent());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
|
return new CreateTrackCommandHandler(
|
||||||
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
|
Database,
|
||||||
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
|
eventBus,
|
||||||
|
new UploadIntentValidator(Database, storageService),
|
||||||
|
NoOpLogger<CreateTrackCommandHandler>(),
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.Track(),
|
||||||
|
TestConfigurations.UploadIntent());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
|
||||||
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
|
||||||
{
|
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var (picture, audio) = SeedIntents(user.Id);
|
||||||
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("My Song", result.Value.Title);
|
|
||||||
Assert.Equal(user.Id, result.Value.OwnerUserId);
|
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
|
|
||||||
var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
Assert.NotNull(stored);
|
Assert.Equal("My Song", result.Value.Title);
|
||||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
Assert.Equal(user.Id, result.Value.OwnerUserId);
|
||||||
}
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
[Fact]
|
var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
Assert.NotNull(stored);
|
||||||
{
|
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||||
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
|
}
|
||||||
{
|
|
||||||
var user = TestEntities.User(1, "user");
|
|
||||||
var otherUser = TestEntities.User(2, "other");
|
|
||||||
var (picture, audio) = SeedIntents(otherUser.Id);
|
|
||||||
await SeedAsync(user, otherUser, picture, audio);
|
|
||||||
|
|
||||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
[Fact]
|
||||||
|
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User(1, "user");
|
||||||
|
var otherUser = TestEntities.User(2, "other");
|
||||||
|
var (picture, audio) = SeedIntents(otherUser.Id);
|
||||||
|
await SeedAsync(user, otherUser, picture, audio);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||||
|
|
||||||
Assert.True(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.True(result.IsError);
|
||||||
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
|
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
{
|
}
|
||||||
var user = TestEntities.User();
|
|
||||||
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
|
|
||||||
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
|
||||||
await SeedAsync(user, picture, audio);
|
|
||||||
|
|
||||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
[Fact]
|
||||||
|
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
|
||||||
|
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
||||||
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,71 +6,72 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
|
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken);
|
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
|
||||||
Assert.NotNull(stored);
|
{
|
||||||
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
var owner = TestEntities.User();
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
|
var track = TestEntities.LocalTrack(owner);
|
||||||
}
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
|
||||||
{
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.False(result.IsError);
|
||||||
}
|
var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stored);
|
||||||
|
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
||||||
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, stranger, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, stranger, track);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_ExternalTrack_ReturnsUnauthorized()
|
|
||||||
{
|
|
||||||
var externalTrack = TestEntities.ExternalTrack();
|
|
||||||
await SeedAsync(externalTrack);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_ExternalTrack_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var externalTrack = TestEntities.ExternalTrack();
|
||||||
|
await SeedAsync(externalTrack);
|
||||||
|
|
||||||
[Fact]
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_AudioStillProcessing_ReturnsConflict()
|
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_AudioStillProcessing_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-25
@@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
|
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
|
||||||
var older = TestEntities.LocalTrack(owner, "Older listen");
|
|
||||||
var newer = TestEntities.LocalTrack(owner, "Newer listen");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, older, newer,
|
|
||||||
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
|
|
||||||
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
|
|
||||||
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var older = TestEntities.LocalTrack(owner, "Older listen");
|
||||||
|
var newer = TestEntities.LocalTrack(owner, "Newer listen");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, older, newer,
|
||||||
|
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
|
||||||
|
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
|
||||||
|
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
|
||||||
|
|
||||||
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
|
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
|
||||||
public async Task Handle_NoHistory_ReturnsEmpty()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
await SeedAsync(owner);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoHistory_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
Assert.Empty(result);
|
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,33 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetTrackByIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "artist-name");
|
private GetTrackByIdQueryHandler CreateHandler() => new(Database);
|
||||||
var track = TestEntities.LocalTrack(owner, "My Song");
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "artist-name");
|
||||||
|
var track = TestEntities.LocalTrack(owner, "My Song");
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("My Song", result.Value.Title);
|
|
||||||
Assert.Equal(owner.Name, result.Value.Artist);
|
|
||||||
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
Assert.Equal("My Song", result.Value.Title);
|
||||||
{
|
Assert.Equal(owner.Name, result.Value.Artist);
|
||||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
Assert.Equal(owner.Id, result.Value.OwnerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,49 +3,50 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetTrackCoverQueryHandler CreateHandler() =>
|
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
|
||||||
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();
|
private GetTrackCoverQueryHandler CreateHandler() =>
|
||||||
var track = TestEntities.LocalTrack(owner);
|
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
|
[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.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("covers-bucket", result.Value.Bucket);
|
|
||||||
Assert.EndsWith(expectedFileName, result.Value.Key);
|
|
||||||
Assert.Equal("image/webp", result.Value.ContentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
Assert.Equal("covers-bucket", result.Value.Bucket);
|
||||||
{
|
Assert.EndsWith(expectedFileName, result.Value.Key);
|
||||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
Assert.Equal("image/webp", result.Value.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,61 +7,62 @@ using Musify.Application.Tracks;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private GetTrackStreamQueryHandler CreateHandler()
|
|
||||||
{
|
{
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60));
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
|
|
||||||
var issuer = new TrackStreamIssuer(
|
private GetTrackStreamQueryHandler CreateHandler()
|
||||||
Database,
|
{
|
||||||
ticketService,
|
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60));
|
||||||
TestConfigurations.Track(),
|
|
||||||
TestConfigurations.StreamGateway("https://stream.musify.test"));
|
|
||||||
|
|
||||||
return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>());
|
var issuer = new TrackStreamIssuer(
|
||||||
}
|
Database,
|
||||||
|
ticketService,
|
||||||
|
TestConfigurations.Track(),
|
||||||
|
TestConfigurations.StreamGateway("https://stream.musify.test"));
|
||||||
|
|
||||||
[Fact]
|
return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>());
|
||||||
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(owner);
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("ticket-token", result.Value.Ticket);
|
|
||||||
Assert.Equal(60, result.Value.ExpiresInSeconds);
|
|
||||||
Assert.Contains("audio-folder", result.Value.ManifestUrl);
|
|
||||||
|
|
||||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
Assert.False(result.IsError);
|
||||||
Assert.Equal(owner.Id, history.UserId);
|
Assert.Equal("ticket-token", result.Value.Ticket);
|
||||||
Assert.Equal(track.Id, history.TrackId);
|
Assert.Equal(60, result.Value.ExpiresInSeconds);
|
||||||
}
|
Assert.Contains("audio-folder", result.Value.ManifestUrl);
|
||||||
|
|
||||||
[Fact]
|
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
public async Task Handle_TrackMissing_ReturnsNotFound()
|
Assert.Equal(owner.Id, history.UserId);
|
||||||
{
|
Assert.Equal(track.Id, history.TrackId);
|
||||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
|
}
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_TrackMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
|
||||||
await SeedAsync(owner, track);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
|
||||||
|
await SeedAsync(owner, track);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-31
@@ -3,43 +3,44 @@ using Musify.Application.Tracks;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
|
||||||
var other = TestEntities.User(2, "other");
|
|
||||||
var mine = TestEntities.LocalTrack(owner, "Mine");
|
|
||||||
var theirs = TestEntities.LocalTrack(other, "Theirs");
|
|
||||||
await SeedAsync(
|
|
||||||
owner, other, mine, theirs,
|
|
||||||
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
|
|
||||||
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var other = TestEntities.User(2, "other");
|
||||||
|
var mine = TestEntities.LocalTrack(owner, "Mine");
|
||||||
|
var theirs = TestEntities.LocalTrack(other, "Theirs");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, other, mine, theirs,
|
||||||
|
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
|
||||||
|
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var track = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Mine", track.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
Assert.False(result.IsError);
|
||||||
[InlineData(0, 1)]
|
var track = Assert.Single(result.Value.Items);
|
||||||
[InlineData(-5, 1)]
|
Assert.Equal("Mine", track.Title);
|
||||||
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
|
}
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.LocalTrack(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);
|
[Theory]
|
||||||
|
[InlineData(0, 1)]
|
||||||
|
[InlineData(-5, 1)]
|
||||||
|
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var track = TestEntities.LocalTrack(owner);
|
||||||
|
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,44 +2,45 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetTracksQueryHandler CreateHandler() => new(Database);
|
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
|
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
private GetTracksQueryHandler CreateHandler() => new(Database);
|
||||||
var trackA = TestEntities.LocalTrack(owner, "Alpha");
|
|
||||||
var trackB = TestEntities.LocalTrack(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 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var trackA = TestEntities.LocalTrack(owner, "Alpha");
|
||||||
|
var trackB = TestEntities.LocalTrack(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 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
{
|
}
|
||||||
var owner = TestEntities.User();
|
|
||||||
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
|
|
||||||
var other = TestEntities.LocalTrack(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 });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
|
||||||
|
var other = TestEntities.LocalTrack(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 });
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||||
var track = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal("Bohemian Rhapsody", track.Title);
|
Assert.False(result.IsError);
|
||||||
|
var track = Assert.Single(result.Value.Items);
|
||||||
|
Assert.Equal("Bohemian Rhapsody", track.Title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-51
@@ -9,72 +9,73 @@ using Musify.Domain.ValueObjects;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Tracks;
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
|
||||||
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
|
||||||
{
|
{
|
||||||
storageService
|
private readonly IStorageService storageService = Substitute.For<IStorageService>();
|
||||||
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
|
||||||
.Returns("https://storage.musify.test/presigned-upload");
|
|
||||||
|
|
||||||
return new RequestTrackUploadUrlsCommandHandler(
|
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
|
||||||
Database,
|
{
|
||||||
new UploadIntentValidator(Database, storageService),
|
storageService
|
||||||
storageService,
|
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
||||||
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
|
.Returns("https://storage.musify.test/presigned-upload");
|
||||||
TestConfigurations.Storage(),
|
|
||||||
TestConfigurations.Track(),
|
|
||||||
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
return new RequestTrackUploadUrlsCommandHandler(
|
||||||
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
|
Database,
|
||||||
{
|
new UploadIntentValidator(Database, storageService),
|
||||||
var user = TestEntities.User();
|
storageService,
|
||||||
await SeedAsync(user);
|
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
|
||||||
|
TestConfigurations.Storage(),
|
||||||
|
TestConfigurations.Track(),
|
||||||
|
uploadIntentConfig ?? TestConfigurations.UploadIntent());
|
||||||
|
}
|
||||||
|
|
||||||
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
[Fact]
|
||||||
|
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
|
|
||||||
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
|
|
||||||
|
|
||||||
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal(2, intents.Count);
|
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
|
||||||
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
|
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
|
||||||
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
Assert.Equal(2, intents.Count);
|
||||||
{
|
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
|
||||||
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
|
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
|
}
|
||||||
{
|
|
||||||
var config = TestConfigurations.UploadIntent();
|
|
||||||
config.MaxActiveUploadIntentsPerUser = 1;
|
|
||||||
|
|
||||||
var user = TestEntities.User();
|
[Fact]
|
||||||
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
|
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
|
||||||
|
{
|
||||||
|
var config = TestConfigurations.UploadIntent();
|
||||||
|
config.MaxActiveUploadIntentsPerUser = 1;
|
||||||
|
|
||||||
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
var user = TestEntities.User();
|
||||||
|
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
|
||||||
|
|
||||||
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
|
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,38 +3,39 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users;
|
namespace Musify.Application.Tests.Users
|
||||||
|
|
||||||
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
|
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NewId_CreatesUser()
|
|
||||||
{
|
{
|
||||||
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
|
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NewId_CreatesUser()
|
||||||
|
{
|
||||||
|
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Jane Doe", result.Value.Name);
|
|
||||||
Assert.Equal(1, result.Value.Id);
|
|
||||||
|
|
||||||
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
Assert.False(result.IsError);
|
||||||
Assert.NotNull(stored);
|
Assert.Equal("Jane Doe", result.Value.Name);
|
||||||
Assert.Equal("JANE DOE", stored.NormalizedName);
|
Assert.Equal(1, result.Value.Id);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
||||||
public async Task Handle_IdAlreadyExists_ReturnsConflict()
|
Assert.NotNull(stored);
|
||||||
{
|
Assert.Equal("JANE DOE", stored.NormalizedName);
|
||||||
var existing = TestEntities.User(1);
|
}
|
||||||
await SeedAsync(existing);
|
|
||||||
|
|
||||||
var command = new CreateUserCommand(1, "Someone Else", null, null);
|
[Fact]
|
||||||
|
public async Task Handle_IdAlreadyExists_ReturnsConflict()
|
||||||
|
{
|
||||||
|
var existing = TestEntities.User(1);
|
||||||
|
await SeedAsync(existing);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new CreateUserCommand(1, "Someone Else", null, null);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users;
|
namespace Musify.Application.Tests.Users
|
||||||
|
|
||||||
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetUserByIdQueryHandler CreateHandler() => new(Database);
|
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ExistingUser_ReturnsIt()
|
|
||||||
{
|
{
|
||||||
var user = TestEntities.User(1, "Jane Doe");
|
private GetUserByIdQueryHandler CreateHandler() => new(Database);
|
||||||
await SeedAsync(user);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ExistingUser_ReturnsIt()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User(1, "Jane Doe");
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("Jane Doe", result.Value.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
Assert.Equal("Jane Doe", result.Value.Name);
|
||||||
{
|
}
|
||||||
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,33 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users;
|
namespace Musify.Application.Tests.Users
|
||||||
|
|
||||||
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private GetUsersQueryHandler CreateHandler() => new(Database);
|
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_NoSearch_ReturnsEveryUser()
|
|
||||||
{
|
{
|
||||||
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
|
private GetUsersQueryHandler CreateHandler() => new(Database);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_NoSearch_ReturnsEveryUser()
|
||||||
|
{
|
||||||
|
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(2, result.Value.TotalItemCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
{
|
}
|
||||||
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);
|
[Fact]
|
||||||
|
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
|
||||||
|
{
|
||||||
|
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
|
||||||
var user = Assert.Single(result.Value.Items);
|
|
||||||
Assert.Equal(1, user.Id);
|
Assert.False(result.IsError);
|
||||||
|
var user = Assert.Single(result.Value.Items);
|
||||||
|
Assert.Equal(1, user.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,42 @@ using Musify.Application.Tests.TestSupport;
|
|||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users;
|
namespace Musify.Application.Tests.Users
|
||||||
|
|
||||||
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
|
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_UnknownUser_ProvisionsIt()
|
|
||||||
{
|
{
|
||||||
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
|
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UnknownUser_ProvisionsIt()
|
||||||
|
{
|
||||||
|
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(stored);
|
|
||||||
Assert.Equal("Jane Doe", stored.Name);
|
|
||||||
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_ExistingUser_UpdatesProfileFields()
|
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
||||||
{
|
Assert.NotNull(stored);
|
||||||
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
|
Assert.Equal("Jane Doe", stored.Name);
|
||||||
await SeedAsync(user);
|
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
|
||||||
|
}
|
||||||
|
|
||||||
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
|
[Fact]
|
||||||
|
public async Task Handle_ExistingUser_UpdatesProfileFields()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
|
||||||
|
await SeedAsync(user);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(stored);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal("New Name", stored.Name);
|
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("NEW NAME", stored.NormalizedName);
|
Assert.NotNull(stored);
|
||||||
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
|
Assert.Equal("New Name", stored.Name);
|
||||||
|
Assert.Equal("NEW NAME", stored.NormalizedName);
|
||||||
|
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-38
@@ -7,55 +7,56 @@ using Musify.Application.YouTube;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.YouTube;
|
namespace Musify.Application.Tests.YouTube
|
||||||
|
|
||||||
public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
|
||||||
|
|
||||||
private GetYouTubeAlbumQueryHandler CreateHandler()
|
|
||||||
{
|
{
|
||||||
var provisioner = new YouTubeTrackProvisioner(
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
|
||||||
|
|
||||||
return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>());
|
private GetYouTubeAlbumQueryHandler CreateHandler()
|
||||||
}
|
{
|
||||||
|
var provisioner = new YouTubeTrackProvisioner(
|
||||||
|
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||||
|
|
||||||
private static YouTubeAlbumDetail BuildDetail() => new(
|
return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>());
|
||||||
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
}
|
||||||
Description: "An album",
|
|
||||||
TotalDurationSeconds: 600,
|
|
||||||
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
|
||||||
|
|
||||||
[Fact]
|
private static YouTubeAlbumDetail BuildDetail() => new(
|
||||||
public async Task Handle_EmptyAlbumId_ReturnsValidationError()
|
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
|
||||||
{
|
Description: "An album",
|
||||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken);
|
TotalDurationSeconds: 600,
|
||||||
|
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task Handle_EmptyAlbumId_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail()
|
}
|
||||||
{
|
|
||||||
var detail = BuildDetail();
|
|
||||||
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail()
|
||||||
|
{
|
||||||
|
var detail = BuildDetail();
|
||||||
|
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken);
|
||||||
Assert.Same(detail, result.Value);
|
|
||||||
Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_ServiceFails_ReturnsError()
|
Assert.Same(detail, result.Value);
|
||||||
{
|
Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>())
|
}
|
||||||
.Returns(Error.NotFound(description: "Album not found"));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_ServiceFails_ReturnsError()
|
||||||
|
{
|
||||||
|
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Error.NotFound(description: "Album not found"));
|
||||||
|
|
||||||
Assert.True(result.IsError);
|
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-43
@@ -7,59 +7,60 @@ using Musify.Application.YouTube.Responses;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.YouTube;
|
namespace Musify.Application.Tests.YouTube
|
||||||
|
|
||||||
public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
|
||||||
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
|
||||||
|
|
||||||
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
|
|
||||||
{
|
{
|
||||||
var provisioner = new YouTubeTrackProvisioner(
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
|
||||||
var issuer = new TrackStreamIssuer(
|
|
||||||
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
|
|
||||||
|
|
||||||
return new ResolveYouTubeTrackStreamCommandHandler(
|
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
|
||||||
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
|
{
|
||||||
}
|
var provisioner = new YouTubeTrackProvisioner(
|
||||||
|
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
|
||||||
|
var issuer = new TrackStreamIssuer(
|
||||||
|
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
|
||||||
|
|
||||||
[Fact]
|
return new ResolveYouTubeTrackStreamCommandHandler(
|
||||||
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
|
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
|
||||||
{
|
}
|
||||||
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
|
||||||
|
|
||||||
var user = TestEntities.User(1);
|
[Fact]
|
||||||
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
|
||||||
await SeedAsync(user, track);
|
{
|
||||||
|
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken);
|
var user = TestEntities.User(1);
|
||||||
|
var track = TestEntities.ExternalTrack(externalId: "abc123");
|
||||||
|
await SeedAsync(user, track);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
|
|
||||||
Assert.Equal("token", result.Value.Ticket);
|
|
||||||
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
Assert.False(result.IsError);
|
||||||
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory()
|
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
|
||||||
{
|
Assert.Equal("token", result.Value.Ticket);
|
||||||
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
|
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
|
||||||
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
|
}
|
||||||
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
|
|
||||||
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
|
|
||||||
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
|
||||||
await SeedAsync(TestEntities.User(42));
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory()
|
||||||
|
{
|
||||||
|
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
|
||||||
|
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
|
||||||
|
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
|
||||||
|
await SeedAsync(TestEntities.User(42));
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
|
|
||||||
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
|
|
||||||
Assert.NotNull(result.Value.TrackId);
|
|
||||||
|
|
||||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
Assert.False(result.IsError);
|
||||||
Assert.Equal(42, history.UserId);
|
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
|
||||||
|
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
|
||||||
|
Assert.NotNull(result.Value.TrackId);
|
||||||
|
|
||||||
|
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal(42, history.UserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-22
@@ -4,33 +4,34 @@ using Musify.Application.YouTube;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.YouTube;
|
namespace Musify.Application.Tests.YouTube
|
||||||
|
|
||||||
public sealed class SearchYouTubeAlbumsQueryHandlerTests
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class SearchYouTubeAlbumsQueryHandlerTests
|
||||||
|
|
||||||
private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
|
||||||
{
|
{
|
||||||
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService);
|
||||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
|
||||||
.SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
||||||
{
|
{
|
||||||
var expected = new YouTubeAlbumSearchResult([], "next-token");
|
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
|
||||||
youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||||
|
.SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Same(expected, result.Value);
|
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
||||||
|
{
|
||||||
|
var expected = new YouTubeAlbumSearchResult([], "next-token");
|
||||||
|
youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Same(expected, result.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-22
@@ -4,33 +4,34 @@ using Musify.Application.YouTube;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.YouTube;
|
namespace Musify.Application.Tests.YouTube
|
||||||
|
|
||||||
public sealed class SearchYouTubeTracksQueryHandlerTests
|
|
||||||
{
|
{
|
||||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
public sealed class SearchYouTubeTracksQueryHandlerTests
|
||||||
|
|
||||||
private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
|
||||||
{
|
{
|
||||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
|
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
|
||||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
|
||||||
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
||||||
{
|
{
|
||||||
var expected = new YouTubeSearchResult([], "next-token");
|
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
|
||||||
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken);
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||||
|
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
[Fact]
|
||||||
Assert.Same(expected, result.Value);
|
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
||||||
|
{
|
||||||
|
var expected = new YouTubeSearchResult([], "next-token");
|
||||||
|
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Same(expected, result.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Domain.Tests.ValueObjects;
|
namespace Musify.Domain.Tests.ValueObjects
|
||||||
|
|
||||||
public sealed class TrackAudioTests
|
|
||||||
{
|
{
|
||||||
[Theory]
|
public sealed class TrackAudioTests
|
||||||
[InlineData(ProcessingStatus.Pending, false, false, true)]
|
|
||||||
[InlineData(ProcessingStatus.Processing, false, false, true)]
|
|
||||||
[InlineData(ProcessingStatus.Completed, true, false, false)]
|
|
||||||
[InlineData(ProcessingStatus.Failed, false, true, false)]
|
|
||||||
public void ComputedFlags_ReflectTranscodeStatus(
|
|
||||||
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
|
|
||||||
{
|
{
|
||||||
var audio = new TrackAudio { TranscodeStatus = status };
|
[Theory]
|
||||||
|
[InlineData(ProcessingStatus.Pending, false, false, true)]
|
||||||
|
[InlineData(ProcessingStatus.Processing, false, false, true)]
|
||||||
|
[InlineData(ProcessingStatus.Completed, true, false, false)]
|
||||||
|
[InlineData(ProcessingStatus.Failed, false, true, false)]
|
||||||
|
public void ComputedFlags_ReflectTranscodeStatus(
|
||||||
|
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
|
||||||
|
{
|
||||||
|
var audio = new TrackAudio { TranscodeStatus = status };
|
||||||
|
|
||||||
Assert.Equal(expectedProcessed, audio.IsProcessed);
|
Assert.Equal(expectedProcessed, audio.IsProcessed);
|
||||||
Assert.Equal(expectedFailed, audio.IsFailed);
|
Assert.Equal(expectedFailed, audio.IsFailed);
|
||||||
Assert.Equal(expectedInProgress, audio.IsInProgress);
|
Assert.Equal(expectedInProgress, audio.IsInProgress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Domain.Tests.ValueObjects;
|
namespace Musify.Domain.Tests.ValueObjects
|
||||||
|
|
||||||
public sealed class TrackPicturesTests
|
|
||||||
{
|
{
|
||||||
[Theory]
|
public sealed class TrackPicturesTests
|
||||||
[InlineData(ProcessingStatus.Pending, false)]
|
|
||||||
[InlineData(ProcessingStatus.Processing, false)]
|
|
||||||
[InlineData(ProcessingStatus.Completed, true)]
|
|
||||||
[InlineData(ProcessingStatus.Failed, false)]
|
|
||||||
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
|
|
||||||
{
|
{
|
||||||
var pictures = new TrackPictures { ProcessingStatus = status };
|
[Theory]
|
||||||
|
[InlineData(ProcessingStatus.Pending, false)]
|
||||||
|
[InlineData(ProcessingStatus.Processing, false)]
|
||||||
|
[InlineData(ProcessingStatus.Completed, true)]
|
||||||
|
[InlineData(ProcessingStatus.Failed, false)]
|
||||||
|
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
|
||||||
|
{
|
||||||
|
var pictures = new TrackPictures { ProcessingStatus = status };
|
||||||
|
|
||||||
Assert.Equal(expected, pictures.IsProcessed);
|
Assert.Equal(expected, pictures.IsProcessed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-29
@@ -2,44 +2,45 @@ using Musify.Domain.Entities;
|
|||||||
using Musify.Infrastructure.Tests.TestSupport;
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Persistence;
|
namespace Musify.Infrastructure.Tests.Persistence
|
||||||
|
|
||||||
[Collection(InfrastructureCollection.Name)]
|
|
||||||
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
|
|
||||||
{
|
{
|
||||||
[Fact]
|
[Collection(InfrastructureCollection.Name)]
|
||||||
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow()
|
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
|
||||||
{
|
{
|
||||||
await using var database = fixture.CreateDatabase();
|
[Fact]
|
||||||
var before = DateTime.UtcNow;
|
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
|
||||||
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" };
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" };
|
||||||
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var after = DateTime.UtcNow;
|
var after = DateTime.UtcNow;
|
||||||
|
|
||||||
Assert.InRange(user.CreatedAt, before, after);
|
Assert.InRange(user.CreatedAt, before, after);
|
||||||
Assert.InRange(user.UpdatedAt, before, after);
|
Assert.InRange(user.UpdatedAt, before, after);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
|
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
|
||||||
{
|
{
|
||||||
await using var database = fixture.CreateDatabase();
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" };
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" };
|
||||||
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
var originalCreatedAt = user.CreatedAt;
|
var originalCreatedAt = user.CreatedAt;
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
user.Name = "audit-user-2-renamed";
|
user.Name = "audit-user-2-renamed";
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(originalCreatedAt, user.CreatedAt);
|
Assert.Equal(originalCreatedAt, user.CreatedAt);
|
||||||
Assert.True(user.UpdatedAt > originalCreatedAt,
|
Assert.True(user.UpdatedAt > originalCreatedAt,
|
||||||
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
|
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,88 +4,89 @@ using Musify.Domain.ValueObjects;
|
|||||||
using Musify.Infrastructure.Tests.TestSupport;
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Persistence;
|
namespace Musify.Infrastructure.Tests.Persistence
|
||||||
|
|
||||||
[Collection(InfrastructureCollection.Name)]
|
|
||||||
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
|
|
||||||
{
|
{
|
||||||
[Fact]
|
[Collection(InfrastructureCollection.Name)]
|
||||||
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres()
|
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
|
||||||
{
|
{
|
||||||
await using var database = fixture.CreateDatabase();
|
[Fact]
|
||||||
|
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres()
|
||||||
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" };
|
|
||||||
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
var track = new LocalTrack
|
|
||||||
{
|
{
|
||||||
Title = "Round Trip Track",
|
await using var database = fixture.CreateDatabase();
|
||||||
NormalizedTitle = "ROUND TRIP TRACK",
|
|
||||||
DurationSeconds = 123.4,
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" };
|
||||||
OwnerUserId = user.Id,
|
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
|
||||||
Owner = user,
|
|
||||||
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
|
var track = new LocalTrack
|
||||||
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
|
{
|
||||||
|
Title = "Round Trip Track",
|
||||||
|
NormalizedTitle = "ROUND TRIP TRACK",
|
||||||
|
DurationSeconds = 123.4,
|
||||||
|
OwnerUserId = user.Id,
|
||||||
|
Owner = user,
|
||||||
|
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
|
||||||
|
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
|
||||||
|
};
|
||||||
|
await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken);
|
||||||
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var reloaded = fixture.CreateDatabase();
|
||||||
|
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.IsType<LocalTrack>(stored);
|
||||||
|
Assert.Equal("Round Trip Track", stored.Title);
|
||||||
|
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
||||||
|
Assert.Equal("song.mp3", stored.Audio.OriginalName);
|
||||||
|
Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var videoId = $"dup-{Guid.NewGuid():N}";
|
||||||
|
await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
|
||||||
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var second = fixture.CreateDatabase();
|
||||||
|
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "cascade-user", NormalizedName = "CASCADE-USER" };
|
||||||
|
var album = new UserAlbum { Title = "Cascade Album", NormalizedTitle = "CASCADE ALBUM", OwnerUserId = user.Id };
|
||||||
|
var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}");
|
||||||
|
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
||||||
|
|
||||||
|
await database.AddRangeAsync(user, album, track, link);
|
||||||
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var deleter = fixture.CreateDatabase();
|
||||||
|
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
|
||||||
|
deleter.UserAlbums.Remove(albumToDelete);
|
||||||
|
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var verifier = fixture.CreateDatabase();
|
||||||
|
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
|
||||||
|
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExternalTrack BuildExternalTrack(string externalId) => new()
|
||||||
|
{
|
||||||
|
Title = "External",
|
||||||
|
NormalizedTitle = "EXTERNAL",
|
||||||
|
DurationSeconds = 100,
|
||||||
|
Source = TrackSource.YouTube,
|
||||||
|
ExternalId = externalId,
|
||||||
|
Pictures = new TrackPictures(),
|
||||||
|
Audio = new TrackAudio()
|
||||||
};
|
};
|
||||||
await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken);
|
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await using var reloaded = fixture.CreateDatabase();
|
|
||||||
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.IsType<LocalTrack>(stored);
|
|
||||||
Assert.Equal("Round Trip Track", stored.Title);
|
|
||||||
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
|
||||||
Assert.Equal("song.mp3", stored.Audio.OriginalName);
|
|
||||||
Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates()
|
|
||||||
{
|
|
||||||
await using var database = fixture.CreateDatabase();
|
|
||||||
|
|
||||||
var videoId = $"dup-{Guid.NewGuid():N}";
|
|
||||||
await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
|
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await using var second = fixture.CreateDatabase();
|
|
||||||
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
|
||||||
{
|
|
||||||
await using var database = fixture.CreateDatabase();
|
|
||||||
|
|
||||||
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "cascade-user", NormalizedName = "CASCADE-USER" };
|
|
||||||
var album = new UserAlbum { Title = "Cascade Album", NormalizedTitle = "CASCADE ALBUM", OwnerUserId = user.Id };
|
|
||||||
var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}");
|
|
||||||
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
|
||||||
|
|
||||||
await database.AddRangeAsync(user, album, track, link);
|
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await using var deleter = fixture.CreateDatabase();
|
|
||||||
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
|
|
||||||
deleter.UserAlbums.Remove(albumToDelete);
|
|
||||||
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await using var verifier = fixture.CreateDatabase();
|
|
||||||
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
|
|
||||||
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ExternalTrack BuildExternalTrack(string externalId) => new()
|
|
||||||
{
|
|
||||||
Title = "External",
|
|
||||||
NormalizedTitle = "EXTERNAL",
|
|
||||||
DurationSeconds = 100,
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = externalId,
|
|
||||||
Pictures = new TrackPictures(),
|
|
||||||
Audio = new TrackAudio()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-39
@@ -2,57 +2,58 @@ using ErrorOr;
|
|||||||
using Musify.Infrastructure.Services;
|
using Musify.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
public sealed class DisabledYouTubeMusicServiceTests
|
|
||||||
{
|
{
|
||||||
private readonly DisabledYouTubeMusicService service = new();
|
public sealed class DisabledYouTubeMusicServiceTests
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SearchSongsAsync_ReturnsForbidden()
|
|
||||||
{
|
{
|
||||||
var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken);
|
private readonly DisabledYouTubeMusicService service = new();
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task SearchSongsAsync_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
public async Task SearchAlbumsAsync_ReturnsForbidden()
|
}
|
||||||
{
|
|
||||||
var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task SearchAlbumsAsync_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
public async Task GetAlbumAsync_ReturnsForbidden()
|
}
|
||||||
{
|
|
||||||
var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task GetAlbumAsync_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
public async Task GetAudioStreamAsync_ReturnsForbidden()
|
}
|
||||||
{
|
|
||||||
var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task GetAudioStreamAsync_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
public async Task GetSongAsync_ReturnsForbidden()
|
}
|
||||||
{
|
|
||||||
var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
[Fact]
|
||||||
}
|
public async Task GetSongAsync_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
public void ResolveArtworkUrl_ReturnsTheUrlUnchanged()
|
}
|
||||||
{
|
|
||||||
var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg");
|
|
||||||
|
|
||||||
Assert.Equal("https://img.example/thumb.jpg", result);
|
[Fact]
|
||||||
|
public void ResolveArtworkUrl_ReturnsTheUrlUnchanged()
|
||||||
|
{
|
||||||
|
var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg");
|
||||||
|
|
||||||
|
Assert.Equal("https://img.example/thumb.jpg", result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,54 +4,55 @@ using SixLabors.ImageSharp;
|
|||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
public sealed class PictureServiceTests
|
|
||||||
{
|
{
|
||||||
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
|
public sealed class PictureServiceTests
|
||||||
|
|
||||||
private static MemoryStream CreateSourceImage(int width, int height)
|
|
||||||
{
|
{
|
||||||
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
|
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
|
||||||
var stream = new MemoryStream();
|
|
||||||
image.SaveAsPng(stream);
|
|
||||||
stream.Position = 0;
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
private static MemoryStream CreateSourceImage(int width, int height)
|
||||||
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
|
{
|
||||||
{
|
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
|
||||||
using var source = CreateSourceImage(800, 600);
|
var stream = new MemoryStream();
|
||||||
|
image.SaveAsPng(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(800, 600);
|
||||||
|
|
||||||
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken);
|
||||||
Assert.InRange(result.Width, 1, 200);
|
|
||||||
Assert.InRange(result.Height, 1, 200);
|
|
||||||
Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
|
||||||
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
|
Assert.InRange(result.Width, 1, 200);
|
||||||
{
|
Assert.InRange(result.Height, 1, 200);
|
||||||
using var source = CreateSourceImage(100, 100);
|
Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
|
||||||
|
}
|
||||||
|
|
||||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(100, 100);
|
||||||
|
|
||||||
var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken);
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken);
|
||||||
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
|
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
|
||||||
{
|
}
|
||||||
using var source = CreateSourceImage(400, 200);
|
|
||||||
|
|
||||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken);
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(400, 200);
|
||||||
|
|
||||||
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(100, result.Width);
|
|
||||||
Assert.Equal(50, result.Height);
|
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(100, result.Width);
|
||||||
|
Assert.Equal(50, result.Height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,96 +7,97 @@ using Musify.Infrastructure.Services;
|
|||||||
using Musify.Infrastructure.Tests.TestSupport;
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
[Collection(InfrastructureCollection.Name)]
|
|
||||||
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
|
|
||||||
{
|
{
|
||||||
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
|
[Collection(InfrastructureCollection.Name)]
|
||||||
|
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
|
||||||
{
|
{
|
||||||
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
|
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
|
||||||
SeedPresetPictures = true,
|
|
||||||
OverwritePresetPictures = overwritePresetPictures
|
|
||||||
};
|
|
||||||
|
|
||||||
private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration)
|
|
||||||
{
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddSingleton(configuration);
|
|
||||||
services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket });
|
|
||||||
services.AddSingleton<IStorageService>(_ => new StorageService(
|
|
||||||
fixture.CreateDatabase(),
|
|
||||||
fixture.CreateS3Client(),
|
|
||||||
NullLogger<StorageService>.Instance,
|
|
||||||
new InfrastructureStorageConfiguration
|
|
||||||
{
|
|
||||||
Address = fixture.S3ServiceUrl,
|
|
||||||
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
|
||||||
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
|
||||||
ForcePathStyle = true,
|
|
||||||
UseHttp = true,
|
|
||||||
}));
|
|
||||||
services.AddSingleton<IPictureService>(new PictureService(NullLogger<PictureService>.Instance));
|
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
|
||||||
var seeder = new PlayListPresetSeeder(provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<PlayListPresetSeeder>.Instance);
|
|
||||||
return (seeder, provider.GetRequiredService<IStorageService>());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder)
|
|
||||||
{
|
|
||||||
await seeder.StartAsync(TestContext.Current.CancellationToken);
|
|
||||||
await (seeder.ExecuteTask ?? Task.CompletedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_SeedsAllFourPresetPictures()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
var (seeder, storage) = Build(configuration);
|
|
||||||
|
|
||||||
await RunToCompletionAsync(seeder);
|
|
||||||
|
|
||||||
var routes = configuration.Routes;
|
|
||||||
foreach (var key in new[]
|
|
||||||
{
|
|
||||||
routes.BuildOriginalPicturePath(routes.PresetOriginalPicture),
|
|
||||||
routes.PresetSmallPicturePath,
|
|
||||||
routes.PresetMediumPicturePath,
|
|
||||||
routes.PresetLargePicturePath
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
|
||||||
Assert.True(metadata is not null, $"expected {key} to have been seeded");
|
SeedPresetPictures = true,
|
||||||
|
OverwritePresetPictures = overwritePresetPictures
|
||||||
|
};
|
||||||
|
|
||||||
|
private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton(configuration);
|
||||||
|
services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket });
|
||||||
|
services.AddSingleton<IStorageService>(_ => new StorageService(
|
||||||
|
fixture.CreateDatabase(),
|
||||||
|
fixture.CreateS3Client(),
|
||||||
|
NullLogger<StorageService>.Instance,
|
||||||
|
new InfrastructureStorageConfiguration
|
||||||
|
{
|
||||||
|
Address = fixture.S3ServiceUrl,
|
||||||
|
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||||
|
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||||
|
ForcePathStyle = true,
|
||||||
|
UseHttp = true,
|
||||||
|
}));
|
||||||
|
services.AddSingleton<IPictureService>(new PictureService(NullLogger<PictureService>.Instance));
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var seeder = new PlayListPresetSeeder(provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<PlayListPresetSeeder>.Instance);
|
||||||
|
return (seeder, provider.GetRequiredService<IStorageService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder)
|
||||||
|
{
|
||||||
|
await seeder.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
await (seeder.ExecuteTask ?? Task.CompletedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_SeedsAllFourPresetPictures()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
var (seeder, storage) = Build(configuration);
|
||||||
|
|
||||||
|
await RunToCompletionAsync(seeder);
|
||||||
|
|
||||||
|
var routes = configuration.Routes;
|
||||||
|
foreach (var key in new[]
|
||||||
|
{
|
||||||
|
routes.BuildOriginalPicturePath(routes.PresetOriginalPicture),
|
||||||
|
routes.PresetSmallPicturePath,
|
||||||
|
routes.PresetMediumPicturePath,
|
||||||
|
routes.PresetLargePicturePath
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(metadata is not null, $"expected {key} to have been seeded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
var (firstSeeder, storage) = Build(configuration);
|
||||||
|
await RunToCompletionAsync(firstSeeder);
|
||||||
|
|
||||||
|
var (secondSeeder, _) = Build(configuration);
|
||||||
|
await RunToCompletionAsync(secondSeeder);
|
||||||
|
|
||||||
|
var metadata = await storage.HeadObjectAsync(
|
||||||
|
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
configuration.SeedPresetPictures = false;
|
||||||
|
var (seeder, storage) = Build(configuration);
|
||||||
|
|
||||||
|
await RunToCompletionAsync(seeder);
|
||||||
|
|
||||||
|
var metadata = await storage.HeadObjectAsync(
|
||||||
|
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Null(metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
var (firstSeeder, storage) = Build(configuration);
|
|
||||||
await RunToCompletionAsync(firstSeeder);
|
|
||||||
|
|
||||||
var (secondSeeder, _) = Build(configuration);
|
|
||||||
await RunToCompletionAsync(secondSeeder);
|
|
||||||
|
|
||||||
var metadata = await storage.HeadObjectAsync(
|
|
||||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
configuration.SeedPresetPictures = false;
|
|
||||||
var (seeder, storage) = Build(configuration);
|
|
||||||
|
|
||||||
await RunToCompletionAsync(seeder);
|
|
||||||
|
|
||||||
var metadata = await storage.HeadObjectAsync(
|
|
||||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
|
||||||
Assert.Null(metadata);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,78 +3,79 @@ using Microsoft.Extensions.Caching.Memory;
|
|||||||
using Musify.Infrastructure.Services;
|
using Musify.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
public sealed class SingleFlightCacheTests
|
|
||||||
{
|
{
|
||||||
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
|
public sealed class SingleFlightCacheTests
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
|
|
||||||
{
|
{
|
||||||
var cache = CreateCache();
|
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
|
||||||
var calls = 0;
|
|
||||||
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
|
||||||
|
|
||||||
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
[Fact]
|
||||||
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
|
||||||
|
|
||||||
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);
|
var cache = CreateCache();
|
||||||
await gate.Task;
|
var calls = 0;
|
||||||
return 7;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
[Fact]
|
||||||
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
|
||||||
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++;
|
var cache = CreateCache();
|
||||||
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom"));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
[Fact]
|
||||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.Equal(2, calls);
|
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
}
|
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
|
||||||
[Fact]
|
Assert.Equal(2, calls);
|
||||||
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);
|
[Fact]
|
||||||
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
|
||||||
|
{
|
||||||
|
var cache = CreateCache();
|
||||||
|
var calls = 0;
|
||||||
|
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||||
|
|
||||||
Assert.Equal(2, calls);
|
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||||
|
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,144 +8,145 @@ using Musify.Infrastructure.Services;
|
|||||||
using Musify.Infrastructure.Tests.TestSupport;
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
[Collection(InfrastructureCollection.Name)]
|
|
||||||
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
|
|
||||||
{
|
{
|
||||||
private Database database = null!;
|
[Collection(InfrastructureCollection.Name)]
|
||||||
private StorageService service = null!;
|
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
|
||||||
|
|
||||||
public ValueTask InitializeAsync()
|
|
||||||
{
|
{
|
||||||
database = fixture.CreateDatabase();
|
private Database database = null!;
|
||||||
var storageConfiguration = new InfrastructureStorageConfiguration
|
private StorageService service = null!;
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
Address = fixture.S3ServiceUrl,
|
database = fixture.CreateDatabase();
|
||||||
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
var storageConfiguration = new InfrastructureStorageConfiguration
|
||||||
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
{
|
||||||
ForcePathStyle = true,
|
Address = fixture.S3ServiceUrl,
|
||||||
UseHttp = true,
|
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||||
};
|
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||||
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
ForcePathStyle = true,
|
||||||
return ValueTask.CompletedTask;
|
UseHttp = true,
|
||||||
}
|
};
|
||||||
|
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => database.DisposeAsync();
|
public ValueTask DisposeAsync() => database.DisposeAsync();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
||||||
{
|
|
||||||
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
|
|
||||||
var content = "hello seaweedfs"u8.ToArray();
|
|
||||||
|
|
||||||
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
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 HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
|
||||||
{
|
|
||||||
var key = $"tests/{Guid.NewGuid():N}/head.txt";
|
|
||||||
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.NotNull(metadata);
|
|
||||||
Assert.Equal(1, metadata.ContentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
|
|
||||||
{
|
|
||||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Null(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
|
|
||||||
{
|
|
||||||
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
|
|
||||||
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CopyFileAsync_CopiesToTheDestinationKey()
|
|
||||||
{
|
|
||||||
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
|
|
||||||
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
|
|
||||||
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
|
|
||||||
{
|
|
||||||
var prefix = $"tests/{Guid.NewGuid():N}";
|
|
||||||
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
|
|
||||||
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
var keys = new List<string>();
|
|
||||||
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
|
|
||||||
keys.Add(item.Key);
|
|
||||||
|
|
||||||
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task 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);
|
|
||||||
|
|
||||||
using var httpClient = new HttpClient();
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
|
|
||||||
{
|
{
|
||||||
// Not StringContent: it appends "; charset=utf-8" to Content-Type, which no longer
|
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
|
||||||
// matches the "text/plain" the presigned URL's signature was computed over (SigV4
|
var content = "hello seaweedfs"u8.ToArray();
|
||||||
// signs Content-Type byte-for-byte) and the server answers 403 SignatureDoesNotMatch.
|
|
||||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
|
|
||||||
};
|
|
||||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
|
||||||
// GetUploadUrlAsync defaults preventOverwrite to true, which signs the URL together with
|
|
||||||
// this header (see StorageService.GetUploadUrlAsync) — the actual PUT has to send it too,
|
|
||||||
// or the signature no longer matches and the server answers 403.
|
|
||||||
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
|
|
||||||
var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(metadata);
|
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
Assert.Equal("presigned content".Length, metadata.ContentLength);
|
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 HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
||||||
|
{
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/head.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal(1, metadata.ContentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
|
||||||
|
{
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Null(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
|
||||||
|
{
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CopyFileAsync_CopiesToTheDestinationKey()
|
||||||
|
{
|
||||||
|
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
|
||||||
|
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
|
||||||
|
{
|
||||||
|
var prefix = $"tests/{Guid.NewGuid():N}";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
|
||||||
|
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var keys = new List<string>();
|
||||||
|
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
|
||||||
|
keys.Add(item.Key);
|
||||||
|
|
||||||
|
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task 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);
|
||||||
|
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
|
||||||
|
{
|
||||||
|
// Not StringContent: it appends "; charset=utf-8" to Content-Type, which no longer
|
||||||
|
// matches the "text/plain" the presigned URL's signature was computed over (SigV4
|
||||||
|
// signs Content-Type byte-for-byte) and the server answers 403 SignatureDoesNotMatch.
|
||||||
|
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
|
||||||
|
};
|
||||||
|
request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||||
|
// GetUploadUrlAsync defaults preventOverwrite to true, which signs the URL together with
|
||||||
|
// this header (see StorageService.GetUploadUrlAsync) — the actual PUT has to send it too,
|
||||||
|
// or the signature no longer matches and the server answers 403.
|
||||||
|
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
|
||||||
|
var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal("presigned content".Length, metadata.ContentLength);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,67 +5,68 @@ using Musify.Infrastructure.Configuration;
|
|||||||
using Musify.Infrastructure.Services;
|
using Musify.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services;
|
namespace Musify.Infrastructure.Tests.Services
|
||||||
|
|
||||||
public sealed class StreamTicketServiceTests : IDisposable
|
|
||||||
{
|
{
|
||||||
private readonly string privateKeyPath = Path.GetTempFileName();
|
public sealed class StreamTicketServiceTests : IDisposable
|
||||||
private readonly RSA rsa = RSA.Create(2048);
|
|
||||||
|
|
||||||
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
{
|
||||||
rsa.Dispose();
|
private readonly string privateKeyPath = Path.GetTempFileName();
|
||||||
File.Delete(privateKeyPath);
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
}
|
|
||||||
|
|
||||||
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
|
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
|
||||||
{
|
|
||||||
PrivateKeyPath = privateKeyPath,
|
|
||||||
Audience = "media-gateway",
|
|
||||||
Issuer = "musify-webapi",
|
|
||||||
TicketTtlSeconds = ticketTtlSeconds
|
|
||||||
});
|
|
||||||
|
|
||||||
[Fact]
|
public void Dispose()
|
||||||
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
|
|
||||||
{
|
|
||||||
using var service = CreateService(ticketTtlSeconds: 120);
|
|
||||||
|
|
||||||
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
|
||||||
|
|
||||||
Assert.Equal(120, ticket.ExpiresInSeconds);
|
|
||||||
|
|
||||||
var handler = new JsonWebTokenHandler();
|
|
||||||
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
|
||||||
Assert.Equal("42", jwt.GetClaim("sub").Value);
|
|
||||||
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
|
|
||||||
Assert.Equal("musify-webapi", jwt.Issuer);
|
|
||||||
Assert.Contains("media-gateway", jwt.Audiences);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
|
||||||
{
|
|
||||||
using var service = CreateService();
|
|
||||||
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
|
|
||||||
|
|
||||||
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
|
||||||
using var verifyingRsa = RSA.Create();
|
|
||||||
verifyingRsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
var handler = new JsonWebTokenHandler();
|
|
||||||
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
|
|
||||||
{
|
{
|
||||||
ValidIssuer = "musify-webapi",
|
rsa.Dispose();
|
||||||
ValidAudience = "media-gateway",
|
File.Delete(privateKeyPath);
|
||||||
ValidateIssuer = true,
|
}
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
|
||||||
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
|
{
|
||||||
|
PrivateKeyPath = privateKeyPath,
|
||||||
|
Audience = "media-gateway",
|
||||||
|
Issuer = "musify-webapi",
|
||||||
|
TicketTtlSeconds = ticketTtlSeconds
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(result.IsValid);
|
[Fact]
|
||||||
|
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
|
||||||
|
{
|
||||||
|
using var service = CreateService(ticketTtlSeconds: 120);
|
||||||
|
|
||||||
|
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
||||||
|
|
||||||
|
Assert.Equal(120, ticket.ExpiresInSeconds);
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||||
|
Assert.Equal("42", jwt.GetClaim("sub").Value);
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
|
||||||
|
Assert.Equal("musify-webapi", jwt.Issuer);
|
||||||
|
Assert.Contains("media-gateway", jwt.Audiences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
||||||
|
{
|
||||||
|
using var service = CreateService();
|
||||||
|
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
|
||||||
|
|
||||||
|
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
||||||
|
using var verifyingRsa = RSA.Create();
|
||||||
|
verifyingRsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidIssuer = "musify-webapi",
|
||||||
|
ValidAudience = "media-gateway",
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-93
@@ -10,108 +10,109 @@ using DotNet.Testcontainers.Builders;
|
|||||||
using DotNet.Testcontainers.Containers;
|
using DotNet.Testcontainers.Containers;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.TestSupport;
|
namespace Musify.Infrastructure.Tests.TestSupport
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One Postgres container and one SeaweedFS container (same image and startup
|
|
||||||
/// command as <c>deploy/compose.yml</c>), started once for every test in the
|
|
||||||
/// <see cref="InfrastructureCollection"/> and torn down when the run finishes.
|
|
||||||
/// EF Core migrations run once here too, against the real <c>Database</c>
|
|
||||||
/// DbContext, so a broken migration fails the whole run immediately.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class InfrastructureTestFixture : IAsyncLifetime
|
|
||||||
{
|
{
|
||||||
public const string S3AccessKey = "musify-test";
|
/// <summary>
|
||||||
public const string S3SecretKey = "musify-test-secret";
|
/// One Postgres container and one SeaweedFS container (same image and startup
|
||||||
public const string S3Bucket = "musify-test-bucket";
|
/// command as <c>deploy/compose.yml</c>), started once for every test in the
|
||||||
|
/// <see cref="InfrastructureCollection"/> and torn down when the run finishes.
|
||||||
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
/// EF Core migrations run once here too, against the real <c>Database</c>
|
||||||
.WithDatabase("musify_test")
|
/// DbContext, so a broken migration fails the whole run immediately.
|
||||||
.WithUsername("postgres")
|
/// </summary>
|
||||||
.WithPassword("postgres")
|
public sealed class InfrastructureTestFixture : IAsyncLifetime
|
||||||
.Build();
|
|
||||||
|
|
||||||
private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
|
|
||||||
.WithEntrypoint("/bin/sh", "-c")
|
|
||||||
.WithCommand(BuildSeaweedFsStartupScript())
|
|
||||||
.WithPortBinding(9333, assignRandomHostPort: true)
|
|
||||||
.WithPortBinding(8333, assignRandomHostPort: true)
|
|
||||||
.WithWaitStrategy(Wait.ForUnixContainer()
|
|
||||||
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
public string ConnectionString => postgres.GetConnectionString();
|
|
||||||
|
|
||||||
public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
|
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
{
|
||||||
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync());
|
public const string S3AccessKey = "musify-test";
|
||||||
|
public const string S3SecretKey = "musify-test-secret";
|
||||||
|
public const string S3Bucket = "musify-test-bucket";
|
||||||
|
|
||||||
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString });
|
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
||||||
await database.Database.MigrateAsync();
|
.WithDatabase("musify_test")
|
||||||
|
.WithUsername("postgres")
|
||||||
// SeaweedFS does not auto-create buckets (see deploy/compose.yml's seaweedfs-init).
|
.WithPassword("postgres")
|
||||||
using var s3 = CreateS3Client();
|
|
||||||
await s3.PutBucketAsync(S3Bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await postgres.DisposeAsync();
|
|
||||||
await seaweedFs.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A fresh, DI-wired <see cref="Database"/>, so <c>AuditableEntityInterceptor</c>
|
|
||||||
/// (attached in <c>AddDatabase</c>'s DI registration, not in the class itself) is active —
|
|
||||||
/// same wiring the real app uses, not a bare <c>new Database(...)</c>.</summary>
|
|
||||||
public Database CreateDatabase()
|
|
||||||
{
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)])
|
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
|
||||||
services.AddDatabase(configuration);
|
.WithEntrypoint("/bin/sh", "-c")
|
||||||
return services.BuildServiceProvider().GetRequiredService<Database>();
|
.WithCommand(BuildSeaweedFsStartupScript())
|
||||||
}
|
.WithPortBinding(9333, assignRandomHostPort: true)
|
||||||
|
.WithPortBinding(8333, assignRandomHostPort: true)
|
||||||
|
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||||
|
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
|
||||||
|
.Build();
|
||||||
|
|
||||||
/// <summary>An <see cref="IAmazonS3"/> client configured exactly like
|
public string ConnectionString => postgres.GetConnectionString();
|
||||||
/// <c>ServicesDependencyInjection.AddStorageService</c>, pointed at the SeaweedFS container.</summary>
|
|
||||||
public IAmazonS3 CreateS3Client() =>
|
|
||||||
new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config
|
|
||||||
{
|
|
||||||
ServiceURL = S3ServiceUrl,
|
|
||||||
ForcePathStyle = true,
|
|
||||||
UseHttp = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
private static string BuildSeaweedFsStartupScript()
|
public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
|
||||||
{
|
|
||||||
var identity = JsonSerializer.Serialize(new
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
identities = new[]
|
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync());
|
||||||
|
|
||||||
|
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString });
|
||||||
|
await database.Database.MigrateAsync();
|
||||||
|
|
||||||
|
// SeaweedFS does not auto-create buckets (see deploy/compose.yml's seaweedfs-init).
|
||||||
|
using var s3 = CreateS3Client();
|
||||||
|
await s3.PutBucketAsync(S3Bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await postgres.DisposeAsync();
|
||||||
|
await seaweedFs.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fresh, DI-wired <see cref="Database"/>, so <c>AuditableEntityInterceptor</c>
|
||||||
|
/// (attached in <c>AddDatabase</c>'s DI registration, not in the class itself) is active —
|
||||||
|
/// same wiring the real app uses, not a bare <c>new Database(...)</c>.</summary>
|
||||||
|
public Database CreateDatabase()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)])
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDatabase(configuration);
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<Database>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An <see cref="IAmazonS3"/> client configured exactly like
|
||||||
|
/// <c>ServicesDependencyInjection.AddStorageService</c>, pointed at the SeaweedFS container.</summary>
|
||||||
|
public IAmazonS3 CreateS3Client() =>
|
||||||
|
new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config
|
||||||
{
|
{
|
||||||
new
|
ServiceURL = S3ServiceUrl,
|
||||||
{
|
ForcePathStyle = true,
|
||||||
name = "musify-test",
|
UseHttp = true,
|
||||||
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
|
});
|
||||||
actions = new[] { "Admin", "Read", "Write" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $"""
|
private static string BuildSeaweedFsStartupScript()
|
||||||
cat > /tmp/s3.conf <<'JSON'
|
{
|
||||||
{identity}
|
var identity = JsonSerializer.Serialize(new
|
||||||
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
|
identities = new[]
|
||||||
""";
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = "musify-test",
|
||||||
|
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
|
||||||
|
actions = new[] { "Admin", "Read", "Write" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"""
|
||||||
|
cat > /tmp/s3.conf <<'JSON'
|
||||||
|
{identity}
|
||||||
|
JSON
|
||||||
|
exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "Infrastructure";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CollectionDefinition(Name)]
|
|
||||||
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
|
|
||||||
{
|
|
||||||
public const string Name = "Infrastructure";
|
|
||||||
}
|
|
||||||
|
|||||||
+125
-124
@@ -6,141 +6,142 @@ using Musify.StreamingGateway.Authentication;
|
|||||||
using Musify.StreamingGateway.Configuration;
|
using Musify.StreamingGateway.Configuration;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.StreamingGateway.Tests.Authentication;
|
namespace Musify.StreamingGateway.Tests.Authentication
|
||||||
|
|
||||||
public sealed class TicketValidatorTests : IDisposable
|
|
||||||
{
|
{
|
||||||
private const string Issuer = "musify-webapi";
|
public sealed class TicketValidatorTests : IDisposable
|
||||||
private const string Audience = "media-gateway";
|
|
||||||
|
|
||||||
private readonly string publicKeyPath = Path.GetTempFileName();
|
|
||||||
private readonly RSA rsa = RSA.Create(2048);
|
|
||||||
private readonly JsonWebTokenHandler tokenHandler = new();
|
|
||||||
|
|
||||||
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
{
|
||||||
rsa.Dispose();
|
private const string Issuer = "musify-webapi";
|
||||||
File.Delete(publicKeyPath);
|
private const string Audience = "media-gateway";
|
||||||
}
|
|
||||||
|
|
||||||
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
|
private readonly string publicKeyPath = Path.GetTempFileName();
|
||||||
{
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
PublicKeyPath = publicKeyPath,
|
private readonly JsonWebTokenHandler tokenHandler = new();
|
||||||
Audience = Audience,
|
|
||||||
Issuer = Issuer
|
|
||||||
}));
|
|
||||||
|
|
||||||
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
||||||
DateTime? expires = null, DateTime? notBefore = null)
|
|
||||||
{
|
public void Dispose()
|
||||||
var descriptor = new SecurityTokenDescriptor
|
|
||||||
{
|
{
|
||||||
Issuer = issuer,
|
rsa.Dispose();
|
||||||
Audience = audience,
|
File.Delete(publicKeyPath);
|
||||||
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
}
|
||||||
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
|
||||||
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
|
||||||
};
|
|
||||||
return tokenHandler.CreateToken(descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
|
||||||
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
|
|
||||||
{
|
|
||||||
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
||||||
|
|
||||||
Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(null)]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData(" ")]
|
|
||||||
public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token)
|
|
||||||
{
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
var token = IssueToken(issuer: "someone-else");
|
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
var token = IssueToken(audience: "someone-else");
|
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
using var otherKey = RSA.Create(2048);
|
|
||||||
var descriptor = new SecurityTokenDescriptor
|
|
||||||
{
|
{
|
||||||
Issuer = Issuer,
|
PublicKeyPath = publicKeyPath,
|
||||||
Audience = Audience,
|
Audience = Audience,
|
||||||
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
Issuer = Issuer
|
||||||
Expires = DateTime.UtcNow.AddMinutes(5),
|
}));
|
||||||
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
|
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
|
|
||||||
};
|
|
||||||
var token = tokenHandler.CreateToken(descriptor);
|
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
||||||
|
DateTime? expires = null, DateTime? notBefore = null)
|
||||||
Assert.Equal(string.Empty, prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
|
|
||||||
{
|
|
||||||
var descriptor = new SecurityTokenDescriptor
|
|
||||||
{
|
{
|
||||||
Issuer = Issuer,
|
var descriptor = new SecurityTokenDescriptor
|
||||||
Audience = Audience,
|
{
|
||||||
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
Issuer = issuer,
|
||||||
Expires = DateTime.UtcNow.AddMinutes(5),
|
Audience = audience,
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
||||||
};
|
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
||||||
var token = tokenHandler.CreateToken(descriptor);
|
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
return tokenHandler.CreateToken(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
|
||||||
|
{
|
||||||
|
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
||||||
|
|
||||||
Assert.Equal(string.Empty, prefix);
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token)
|
||||||
|
{
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(issuer: "someone-else");
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(audience: "someone-else");
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
using var otherKey = RSA.Create(2048);
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-113
@@ -9,145 +9,146 @@ using Musify.StreamingGateway.Configuration;
|
|||||||
using Musify.StreamingGateway.Middleware;
|
using Musify.StreamingGateway.Middleware;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.StreamingGateway.Tests.Middleware;
|
namespace Musify.StreamingGateway.Tests.Middleware
|
||||||
|
|
||||||
public sealed class TicketValidationMiddlewareTests : IDisposable
|
|
||||||
{
|
{
|
||||||
private const string Prefix = "Tracks/ProcessedAudios/abc";
|
public sealed class TicketValidationMiddlewareTests : IDisposable
|
||||||
|
|
||||||
private readonly string publicKeyPath = Path.GetTempFileName();
|
|
||||||
private readonly RSA rsa = RSA.Create(2048);
|
|
||||||
private readonly StreamTicketValidationOptions options;
|
|
||||||
|
|
||||||
public TicketValidationMiddlewareTests()
|
|
||||||
{
|
{
|
||||||
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
private const string Prefix = "Tracks/ProcessedAudios/abc";
|
||||||
options = new StreamTicketValidationOptions
|
|
||||||
|
private readonly string publicKeyPath = Path.GetTempFileName();
|
||||||
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
|
private readonly StreamTicketValidationOptions options;
|
||||||
|
|
||||||
|
public TicketValidationMiddlewareTests()
|
||||||
{
|
{
|
||||||
PublicKeyPath = publicKeyPath,
|
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
||||||
Audience = "media-gateway",
|
options = new StreamTicketValidationOptions
|
||||||
Issuer = "musify-webapi",
|
{
|
||||||
};
|
PublicKeyPath = publicKeyPath,
|
||||||
}
|
Audience = "media-gateway",
|
||||||
|
Issuer = "musify-webapi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
rsa.Dispose();
|
rsa.Dispose();
|
||||||
File.Delete(publicKeyPath);
|
File.Delete(publicKeyPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Issuer = options.Issuer,
|
Issuer = options.Issuer,
|
||||||
Audience = options.Audience,
|
Audience = options.Audience,
|
||||||
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
Expires = DateTime.UtcNow.AddMinutes(5),
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
||||||
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
});
|
});
|
||||||
|
|
||||||
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
|
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
|
||||||
{
|
{
|
||||||
var nextCalled = false;
|
var nextCalled = false;
|
||||||
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
|
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
|
||||||
var validator = new TicketValidator(Options.Create(options));
|
var validator = new TicketValidator(Options.Create(options));
|
||||||
var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance);
|
var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance);
|
||||||
return (middleware, () => nextCalled);
|
return (middleware, () => nextCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
|
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = "/health";
|
context.Request.Path = "/health";
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
|
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.False(wasNextCalled());
|
Assert.False(wasNextCalled());
|
||||||
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
|
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
|
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
|
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
|
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
|
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
|
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
|
||||||
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.False(wasNextCalled());
|
Assert.False(wasNextCalled());
|
||||||
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData($"/media/{Prefix}/../../etc/passwd")]
|
[InlineData($"/media/{Prefix}/../../etc/passwd")]
|
||||||
[InlineData($"/media/{Prefix}/./audio.m4a")]
|
[InlineData($"/media/{Prefix}/./audio.m4a")]
|
||||||
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
|
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = path;
|
context.Request.Path = path;
|
||||||
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.False(wasNextCalled());
|
Assert.False(wasNextCalled());
|
||||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
|
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
|
||||||
{
|
{
|
||||||
var (middleware, wasNextCalled) = CreateMiddleware();
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
var context = new DefaultHttpContext();
|
var context = new DefaultHttpContext();
|
||||||
context.Request.Path = $"/media/{Prefix}";
|
context.Request.Path = $"/media/{Prefix}";
|
||||||
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
await middleware.InvokeAsync(context);
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
Assert.True(wasNextCalled());
|
Assert.True(wasNextCalled());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user