using System; using System.Collections.Generic; using System.Threading; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.WatchedTogether.Configuration; using Jellyfin.Plugin.WatchedTogether.Services; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Plugin.WatchedTogether.Tests; /// /// Covers propagation of played state from a shared account to its members. /// public class WatchedStateSyncTests { private static readonly Guid SharedId = Guid.NewGuid(); private sealed class Harness { public Mock UserData { get; } = new(); public Mock Groups { get; } = new(); public List<(User Member, bool Played, int PlayCount)> Saves { get; } = new(); public WatchedStateSyncService Service { get; private set; } = null!; public static Harness Create( SharedGroup? group, IReadOnlyList members, bool memberAlreadyPlayed = false, int memberPlayCount = 0) { var h = new Harness(); h.Groups.Setup(g => g.GetGroupForSharedUser(It.IsAny())).Returns(group); h.Groups.Setup(g => g.GetEligibleMembers(It.IsAny())).Returns(members); h.UserData.Setup(m => m.GetUserData(It.IsAny(), It.IsAny())) .Returns(() => new UserItemData { Key = "k", Played = memberAlreadyPlayed, PlayCount = memberPlayCount }); h.UserData.Setup(m => m.SaveUserData( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback( (u, _, d, _, _) => h.Saves.Add((u, d.Played, d.PlayCount))); h.Service = new WatchedStateSyncService( h.UserData.Object, new Mock().Object, h.Groups.Object, NullLogger.Instance); return h; } /// /// Raises UserDataSaved as the server would, by starting the service so it subscribes. /// public void Raise(Guid userId, bool played, UserDataSaveReason reason) { Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult(); UserData.Raise( m => m.UserDataSaved += null, new UserDataSaveEventArgs { UserId = userId, Item = new Folder { Name = "Some Item" }, UserData = new UserItemData { Key = "k", Played = played }, SaveReason = reason }); Service.StopAsync(CancellationToken.None).GetAwaiter().GetResult(); } } private static User MakeUser(string name) => new(name, "Prov", "ResetProv"); private static SharedGroup MakeGroup(bool syncUnwatched = true, bool syncPlayCount = false) => new() { SharedUserId = SharedId, MemberUserIds = [Guid.NewGuid(), Guid.NewGuid()], SyncUnwatched = syncUnwatched, SyncPlayCount = syncPlayCount }; [Fact] public void Played_PropagatesToEveryMember() { var alice = MakeUser("alice"); var bob = MakeUser("bob"); var h = Harness.Create(MakeGroup(), [alice, bob]); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Equal(2, h.Saves.Count); Assert.All(h.Saves, s => Assert.True(s.Played)); } [Fact] public void WritesFromAMember_AreIgnored() { // The loop guard: a member's own save must not be treated as a shared-account change. // GetGroupForSharedUser returns null for any id that is not a shared account. var h = Harness.Create(null, []); h.Raise(Guid.NewGuid(), true, UserDataSaveReason.PlaybackFinished); Assert.Empty(h.Saves); } [Theory] [InlineData(UserDataSaveReason.PlaybackStart)] [InlineData(UserDataSaveReason.PlaybackProgress)] [InlineData(UserDataSaveReason.UpdateUserRating)] public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason) { // UserDataSaved fires constantly during playback; only watched-state changes matter. var h = Harness.Create(MakeGroup(), [MakeUser("alice")]); h.Raise(SharedId, true, reason); Assert.Empty(h.Saves); } [Fact] public void Unwatched_PropagatesWhenSyncUnwatchedEnabled() { var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true); h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed); Assert.Single(h.Saves); Assert.False(h.Saves[0].Played); } [Fact] public void Unwatched_IsSuppressedWhenSyncUnwatchedDisabled() { var h = Harness.Create(MakeGroup(syncUnwatched: false), [MakeUser("alice")], memberAlreadyPlayed: true); h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed); Assert.Empty(h.Saves); } [Fact] public void RedundantWrites_AreSuppressed() { // The member already matches the shared account, so there is nothing to write. var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Empty(h.Saves); } [Fact] public void PlayCount_IsRaisedWhenEnabled() { var h = Harness.Create(MakeGroup(syncPlayCount: true), [MakeUser("alice")]); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Single(h.Saves); Assert.Equal(1, h.Saves[0].PlayCount); } [Fact] public void PlayCount_IsNotDecrementedForAlreadyWatchedItems() { // A member who has watched something five times keeps that count. var h = Harness.Create( MakeGroup(syncPlayCount: true), [MakeUser("alice")], memberAlreadyPlayed: false, memberPlayCount: 5); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Single(h.Saves); Assert.Equal(5, h.Saves[0].PlayCount); } [Fact] public void PlayCount_IsLeftAloneWhenDisabled() { var h = Harness.Create(MakeGroup(syncPlayCount: false), [MakeUser("alice")]); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Single(h.Saves); Assert.Equal(0, h.Saves[0].PlayCount); } [Fact] public void DisabledGroup_DoesNotSync() { // GetGroupForSharedUser returns null for suspended groups. var h = Harness.Create(null, [MakeUser("alice")]); h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished); Assert.Empty(h.Saves); } }