Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private async Task<string> SeedTrackAsync(Genre genre)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
|
||||
var title = $"Genre track {Guid.NewGuid():N}";
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
|
||||
};
|
||||
foreach (var tag in track.Tags)
|
||||
tag.TrackId = track.Id;
|
||||
database.Users.Add(owner);
|
||||
database.Tracks.Add(track);
|
||||
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return title;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGenres_Anonymous_ListsGenresInUse()
|
||||
{
|
||||
await SeedTrackAsync(Genre.Funk);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
|
||||
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Nope")]
|
||||
[InlineData("999")]
|
||||
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Blues);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_FiltersByTag()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Ambient);
|
||||
await SeedTrackAsync(Genre.Techno);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private async Task<long> CreateUserAsync()
|
||||
{
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
|
||||
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return userId;
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
|
||||
var track = new Track
|
||||
{
|
||||
Title = "Progress track",
|
||||
NormalizedTitle = "PROGRESS TRACK",
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
DurationSeconds = durationSeconds
|
||||
};
|
||||
var listen = new ListeningHistory
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = track.Id,
|
||||
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
|
||||
};
|
||||
database.Tracks.Add(track);
|
||||
database.ListeningHistories.Add(listen);
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return listen.Id;
|
||||
}
|
||||
|
||||
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
return await database.ListeningHistories.AsNoTracking()
|
||||
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
|
||||
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
var stored = await LoadListenAsync(listenId);
|
||||
Assert.Equal(45, stored.PlayedSeconds);
|
||||
Assert.True(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
|
||||
{
|
||||
var ownerId = await CreateUserAsync();
|
||||
var otherId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
|
||||
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
|
||||
var countedTrack = TestEntities.Track(owner, "In counted");
|
||||
var skippedTrack = TestEntities.Track(owner, "In skipped");
|
||||
await SeedAsync(
|
||||
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
|
||||
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
|
||||
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
|
||||
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetAvailableGenresQueryHandlerTests
|
||||
{
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
|
||||
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryGenre()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListsIncompatibleGenresSymmetrically()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
var metal = result.Single(r => r.Genre == Genre.Metal);
|
||||
var lofi = result.Single(r => r.Genre == Genre.Lofi);
|
||||
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
|
||||
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
|
||||
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetGenresQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTracks_ReturnsEmpty()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
|
||||
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rockA, rockB, jazz,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
|
||||
result.Select(r => (r.Genre, r.TrackCount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var other = TestEntities.User(id: 2, name: "other-user");
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
|
||||
await SeedAsync(
|
||||
owner, other, track,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, Assert.Single(result).TrackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var skipped = TestEntities.Track(user, "Only skipped");
|
||||
await SeedAsync(
|
||||
user, skipped,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
|
||||
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,12 +179,16 @@ namespace Musify.Application.Tests.TestSupport
|
||||
public static ListeningHistory ListeningHistory(
|
||||
long userId,
|
||||
Guid trackId,
|
||||
DateTime? listenedAt = null) =>
|
||||
DateTime? listenedAt = null,
|
||||
double? playedSeconds = null,
|
||||
bool isCounted = true) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = trackId,
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow,
|
||||
PlayedSeconds = playedSeconds,
|
||||
IsCounted = isCounted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
|
||||
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DeletesOnlyOldUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
|
||||
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
|
||||
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
|
||||
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
|
||||
|
||||
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(oldUncounted.Id, remaining);
|
||||
Assert.Contains(oldCounted.Id, remaining);
|
||||
Assert.Contains(recentUncounted.Id, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var counted = TestEntities.Track(owner, "Counted");
|
||||
var skipped = TestEntities.Track(owner, "Skipped");
|
||||
await SeedAsync(
|
||||
owner, counted, skipped,
|
||||
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
|
||||
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted"], result.Select(track => track.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListensCount_OnlyCountsCountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(
|
||||
owner, track,
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Value.ListensCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(owner.Id, history.UserId);
|
||||
Assert.Equal(track.Id, history.TrackId);
|
||||
Assert.Equal(history.Id, result.Value.ListenId);
|
||||
Assert.False(history.IsCounted);
|
||||
Assert.Null(history.PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rock, jazz,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Loud", item.Track.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
|
||||
|
||||
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
|
||||
await Database.ListeningHistories.AsNoTracking()
|
||||
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
|
||||
|
||||
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
|
||||
double durationSeconds, DateTime listenedAt)
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
|
||||
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
|
||||
await SeedAsync(user, track, listen);
|
||||
return (user, listen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.Equal(10, stored.PlayedSeconds);
|
||||
Assert.False(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ShortTrack_CountsAtHalfDuration()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_LowerValueThanStored_KeepsHighest()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
var handler = CreateHandler();
|
||||
|
||||
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
|
||||
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.Equal(40, stored.PlayedSeconds);
|
||||
Assert.True(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OtherUsersListen_ReturnsNotFound()
|
||||
{
|
||||
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds)
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users
|
||||
Assert.Equal(500, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user, durationSeconds: 200);
|
||||
await SeedAsync(
|
||||
user, track,
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(212, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user);
|
||||
await SeedAsync(
|
||||
user, track,
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(0, result.TracksThisWeek);
|
||||
Assert.Equal(0, result.StreakDays);
|
||||
Assert.Equal(3, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user