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);
}
}