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 Saves { get; } = new();
public WatchedStateSyncService Service { get; private set; } = null!;
public static Harness Create(
SharedGroup? group,
IReadOnlyList members,
bool memberAlreadyPlayed = false,
int memberPlayCount = 0,
DateTime? memberLastPlayedDate = null,
long memberPositionTicks = 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,
LastPlayedDate = memberLastPlayedDate,
PlaybackPositionTicks = memberPositionTicks
});
h.UserData.Setup(m => m.SaveUserData(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Callback(
(u, _, d, _, _) => h.Saves.Add(
new Save(u, d.Played, d.PlayCount, d.LastPlayedDate, d.PlaybackPositionTicks)));
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, DateTime? lastPlayedDate = null)
{
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, LastPlayedDate = lastPlayedDate },
SaveReason = reason
});
Service.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
}
}
///
/// A snapshot of one member row as it was handed to SaveUserData. The service reuses the
/// object it got from GetUserData, so the fields are copied rather than the reference kept.
///
private sealed record Save(User Member, bool Played, int PlayCount, DateTime? LastPlayedDate, long PositionTicks);
private static readonly DateTime SharedWatchedAt = new(2026, 9, 18, 21, 30, 0, DateTimeKind.Utc);
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.UpdateUserRating)]
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
{
// Neither of these ever carries a watched-state change, whatever the flag says.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, reason);
Assert.Empty(h.Saves);
}
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserData)]
public void PlaybackDerivedReasons_SyncWatched(UserDataSaveReason reason)
{
// Progress crosses the completion threshold before the stop arrives, and some clients
// never send a stop at all, so the tick has to land on the progress tick too.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, reason);
Assert.Single(h.Saves);
Assert.True(h.Saves[0].Played);
}
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
[InlineData(UserDataSaveReason.UpdateUserData)]
public void PlaybackDerivedReasons_NeverSyncUnwatched(UserDataSaveReason reason)
{
// PlaybackFinished fires on every stop, not just on completion, and on 10.11 PlaybackStart
// resets Played to false first. Stopping halfway through on the shared account must not
// clear what a member watched on their own - even with Sync unwatched on.
var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true);
h.Raise(SharedId, false, reason);
Assert.Empty(h.Saves);
}
[Fact]
public void Played_WritesWhatMarkPlayedWrites()
{
// Next Up is driven by LastPlayedDate on the member's own row, and a stale resume position
// would keep the item in Continue Watching, so the tick alone is not enough.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberPositionTicks: 12_345);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
var save = Assert.Single(h.Saves);
Assert.True(save.Played);
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
Assert.Equal(0, save.PositionTicks);
}
[Fact]
public void Played_FallsBackToNowWhenSharedAccountHasNoDate()
{
var before = DateTime.UtcNow;
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
h.Raise(SharedId, true, UserDataSaveReason.TogglePlayed);
var save = Assert.Single(h.Saves);
Assert.NotNull(save.LastPlayedDate);
Assert.InRange(save.LastPlayedDate!.Value, before, DateTime.UtcNow);
}
[Fact]
public void Played_RepairsAnAlreadyTickedRowThatHasNoDate()
{
// Rows synced by earlier versions have the tick but no date. They are not "already in
// sync": without a date the member's Next Up never moves.
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true, memberLastPlayedDate: null);
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
var save = Assert.Single(h.Saves);
Assert.True(save.Played);
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
}
[Fact]
public void Unwatched_WritesWhatMarkUnplayedWrites()
{
var h = Harness.Create(
MakeGroup(syncUnwatched: true),
[MakeUser("alice")],
memberAlreadyPlayed: true,
memberPlayCount: 3,
memberLastPlayedDate: SharedWatchedAt,
memberPositionTicks: 999);
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
var save = Assert.Single(h.Saves);
Assert.False(save.Played);
Assert.Null(save.LastPlayedDate);
Assert.Equal(0, save.PositionTicks);
Assert.Equal(3, save.PlayCount);
}
[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);
}
[Theory]
[InlineData(UserDataSaveReason.PlaybackFinished)]
[InlineData(UserDataSaveReason.PlaybackProgress)]
public void RedundantWrites_AreSuppressed(UserDataSaveReason reason)
{
// The member already matches the shared account, so there is nothing to write. This is
// what keeps the stream of progress ticks after the completion threshold write-free.
var h = Harness.Create(
MakeGroup(),
[MakeUser("alice")],
memberAlreadyPlayed: true,
memberLastPlayedDate: SharedWatchedAt);
h.Raise(SharedId, true, reason);
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);
}
}