diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index 47ddbf0..3b638ff 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -159,16 +159,17 @@ public class TrackRepository : Repository .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); // Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean. - // All counts go through Query / the release set's !IsDeleted filter so soft-deleted rows never count. - // Mix runtime sums DurationSeconds with a null-coalesce to 0 so not-yet-backfilled rows contribute - // zero rather than throwing or skewing the total. The cut release-type breakdown is grouped here so - // a zero-count type is simply absent from the result (no present-with-zero row). + // All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks + // under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a + // null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the + // total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from + // the result (no present-with-zero row). public async Task GetHomeStatsAsync(CancellationToken ct = default) { var releases = _context.Set().Where(r => !r.IsDeleted); var cutTrackCount = await Query - .CountAsync(t => t.Release != null && t.Release.Medium == ReleaseMedium.Cut, ct); + .CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct); var cutReleaseTypeCounts = await releases .Where(r => r.Medium == ReleaseMedium.Cut) @@ -180,7 +181,7 @@ public class TrackRepository : Repository .CountAsync(r => r.Medium == ReleaseMedium.Mix, ct); var mixRuntimeSeconds = await Query - .Where(t => t.Release != null && t.Release.Medium == ReleaseMedium.Mix) + .Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix) .SumAsync(t => t.DurationSeconds ?? 0d, ct); return new HomeStatsDto diff --git a/DeepDrftTests/HomeStatsQueryTests.cs b/DeepDrftTests/HomeStatsQueryTests.cs index 8fcbfb2..c081d15 100644 --- a/DeepDrftTests/HomeStatsQueryTests.cs +++ b/DeepDrftTests/HomeStatsQueryTests.cs @@ -149,4 +149,34 @@ public class HomeStatsQueryTests Assert.That(stats.CutReleaseTypeCounts.Single(c => c.ReleaseType == ReleaseType.Album).Count, Is.EqualTo(1)); Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), "the only mix track is soft-deleted"); } + + // A live track under a directly-deleted release must be excluded from the track-based figures. + // SoftDeleteReleaseAsync does not cascade to child tracks, so without the !t.Release.IsDeleted + // guard the track-count and runtime figures are internally inconsistent with the release-level ones. + [Test] + public async Task GetHomeStatsAsync_ExcludesLiveTracksUnderSoftDeletedRelease() + { + var liveCut = Release("Live Cut", ReleaseMedium.Cut, ReleaseType.Album); + var deletedCut = Release("Dead Cut", ReleaseMedium.Cut, ReleaseType.Album); + deletedCut.IsDeleted = true; + var deletedMix = Release("Dead Mix", ReleaseMedium.Mix); + deletedMix.IsDeleted = true; + + // Both tracks are themselves live — only their parent release is soft-deleted. + var liveTrackUnderDeletedCut = Track(deletedCut); + var liveTrackUnderDeletedMix = Track(deletedMix, duration: 900d); + + await SeedAsync( + new[] { liveCut, deletedCut, deletedMix }, + new[] { Track(liveCut), liveTrackUnderDeletedCut, liveTrackUnderDeletedMix }); + + var stats = await CreateRepository().GetHomeStatsAsync(); + + Assert.That(stats.CutTrackCount, Is.EqualTo(1), + "live track under deleted Cut release must not inflate CutTrackCount"); + Assert.That(stats.MixReleaseCount, Is.EqualTo(0), + "deleted Mix release must not count"); + Assert.That(stats.MixRuntimeSeconds, Is.EqualTo(0d), + "live track under deleted Mix release must not inflate MixRuntimeSeconds"); + } }