using ErsatzTV.Application.Channels; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Tests.Support; using LanguageExt; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Infrastructure; // #72: the channels API reports a per-channel playout count so the SPA can flag a channel that will // never play. GetChannel omitted the Playouts include, and the read is AsNoTracking with no lazy-loading // proxies, so the navigation came back empty and every caller's count collapsed to 0 — GET // /api/v1/channels/{id} reported playoutCount: 0 for every channel on the system. That silently disabled // the channel editor's playout-source guard (ChannelEditScreen "Cannot be changed once a generated // channel has a playout"), which is gated on playoutCount > 0. // // These run the REAL repository against a real context on purpose: a handler test with a substituted // IChannelRepository hands the mapper a channel whose Playouts the test itself populated, so it passes // whether or not the query includes them. Only a repository-level read can catch a missing include. [TestFixture] public class ChannelRepositoryPlayoutIncludeTests { [SetUp] public async Task SetUp() { _db = await InMemoryTvContext.CreateAsync(); _repository = new ChannelRepository(_db.Factory); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private InMemoryTvContext _db = null!; private ChannelRepository _repository = null!; // FFmpegProfileId is non-nullable, so GetChannel's Include(c => c.FFmpegProfile) is an INNER JOIN — // a channel with no profile row is filtered out of the result entirely. Every fixture needs a real one. private async Task SeedFFmpegProfile() { var profile = new FFmpegProfile { Name = "Test Profile" }; await using TvContext context = _db.CreateContext(); context.FFmpegProfiles.Add(profile); await context.SaveChangesAsync(); return profile.Id; } private async Task SeedChannel(Channel channel) { await using TvContext context = _db.CreateContext(); context.Channels.Add(channel); await context.SaveChangesAsync(); return channel.Id; } private async Task GetDetail(int channelId) { var handler = new GetChannelByIdForApiHandler(_repository); Option result = await handler.Handle(new GetChannelByIdForApi(channelId), CancellationToken.None); return result.IfNone(() => throw new InvalidOperationException($"channel {channelId} not found")); } [Test] public async Task GetChannel_Should_Include_Playouts_So_The_Detail_Api_Reports_A_Real_Count() { int profileId = await SeedFFmpegProfile(); int channelId = await SeedChannel( new Channel(Guid.NewGuid()) { Number = "1", Name = "Generated", PlayoutSource = ChannelPlayoutSource.Generated, FFmpegProfileId = profileId, Artwork = [], Playouts = [new Playout(), new Playout()] }); ChannelDetailResponseModel detail = await GetDetail(channelId); detail.PlayoutCount.ShouldBe(2); } [Test] public async Task GetChannel_Should_Count_The_Mirror_Sources_Playouts_For_A_Mirror_Channel() { // A mirror channel owns no playouts; it relays its source's. Counting only its own navigation // would report 0 and wrongly flag a working mirror channel as "will never play". int profileId = await SeedFFmpegProfile(); int sourceId = await SeedChannel( new Channel(Guid.NewGuid()) { Number = "1", Name = "Source", PlayoutSource = ChannelPlayoutSource.Generated, FFmpegProfileId = profileId, Artwork = [], Playouts = [new Playout()] }); int mirrorId = await SeedChannel( new Channel(Guid.NewGuid()) { Number = "2", Name = "Mirror", PlayoutSource = ChannelPlayoutSource.Mirror, MirrorSourceChannelId = sourceId, FFmpegProfileId = profileId, Artwork = [], Playouts = [] }); ChannelDetailResponseModel detail = await GetDetail(mirrorId); detail.PlayoutCount.ShouldBe(1); } [Test] public async Task GetChannel_Should_Report_Zero_For_A_Channel_With_No_Playouts() { // Boundary check on the mapper only — this one passes with or WITHOUT the Playouts include // (0 == 0 either way), so unlike its two siblings it is NOT include coverage. It earns its // place by pinning that the "will never play" signal stays 0 and never drifts vacuously // non-zero, which would silently retire the badge. int profileId = await SeedFFmpegProfile(); int channelId = await SeedChannel( new Channel(Guid.NewGuid()) { Number = "3", Name = "Empty", PlayoutSource = ChannelPlayoutSource.Generated, FFmpegProfileId = profileId, Artwork = [], Playouts = [] }); ChannelDetailResponseModel detail = await GetDetail(channelId); detail.PlayoutCount.ShouldBe(0); } }