diff --git a/backend/Core/Musify.Application/Tracks/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs new file mode 100644 index 0000000..146ef1e --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -0,0 +1,14 @@ +namespace Musify.Application.Tracks +{ + public static class ListeningRules + { + public const double CountedThresholdSeconds = 30; + public const double CountedThresholdFraction = 0.5; + public const double DurationToleranceSeconds = 2; + public const double ClockToleranceSeconds = 5; + public const int UncountedRetentionDays = 8; + + public static double CountedThreshold(double durationSeconds) => + Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + } +} diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index c37fdbe..c23a410 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -11,18 +11,6 @@ namespace Musify.Application.Tracks double PlayedSeconds) : ICommand>; - public static class ListeningRules - { - public const double CountedThresholdSeconds = 30; - public const double CountedThresholdFraction = 0.5; - public const double DurationToleranceSeconds = 2; - public const double ClockToleranceSeconds = 5; - public const int UncountedRetentionDays = 8; - - public static double CountedThreshold(double durationSeconds) => - Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); - } - public class RecordListeningProgressCommandHandler(IDatabase database) : ICommandHandler> { diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 8898711..cff1661 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -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)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 7a3a8a4..70bd2d1 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -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"); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs new file mode 100644 index 0000000..184f3d5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -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()); + + [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); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index c52a703..1886f27 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -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)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 9805b3d..4a5798a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs deleted file mode 100644 index 9671d05..0000000 --- a/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Musify.Application.Albums; -using Musify.Application.Mixes; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Tracks; -using Musify.Domain.Entities; -using Xunit; - -namespace Musify.Application.Tests.Tracks -{ - public sealed class UncountedListensTests : HandlerTestBase - { - [Fact] - public async Task ListeningHistory_IgnoresUncountedListens() - { - 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 new GetListeningHistoryQueryHandler(Database) - .Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted"], result.Select(track => track.Title)); - } - - [Fact] - public async Task RecentlyListenedAlbums_IgnoresUncountedListens() - { - 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 new GetRecentlyListenedAlbumsQueryHandler(Database) - .Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); - } - - [Fact] - public async Task Mixes_TreatTracksWithOnlyUncountedListensAsUnheard() - { - 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)); - - var handler = new GenerateMixesForUserCommandHandler( - Database, TestConfigurations.Mix(), NoOpLogger()); - await handler.Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - } - - [Fact] - public async Task TrackListensCount_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 new GetTrackByIdQueryHandler(Database) - .Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - - Assert.Equal(2, result.Value.ListensCount); - } - - [Fact] - public async Task Cleanup_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 new DeleteStaleUncountedListensCommandHandler(Database, NoOpLogger()) - .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); - } - } -}