using System; using Emby.Server.Implementations.Library; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using AudioBook = MediaBrowser.Controller.Entities.AudioBook; namespace Jellyfin.Server.Implementations.Tests.Library; public sealed class UserDataManagerTests : IDisposable { private readonly SqliteConnection _connection; private readonly DbContextOptions _dbOptions; private readonly UserDataManager _userDataManager; private readonly User _user; public UserDataManagerTests() { _connection = new SqliteConnection("Data Source=:memory:"); _connection.Open(); _dbOptions = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; using (var ctx = CreateDbContext()) { ctx.Database.EnsureCreated(); } var factory = new Mock>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); var config = new Mock(); config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration()); _userDataManager = new UserDataManager(config.Object, factory.Object); _user = new User("user", "auth-provider", "reset-provider") { Id = Guid.NewGuid() }; using (var ctx = CreateDbContext()) { ctx.Users.Add(_user); ctx.SaveChanges(); } } public void Dispose() { _connection.Dispose(); } private JellyfinDbContext CreateDbContext() { return new JellyfinDbContext( _dbOptions, NullLogger.Instance, new SqliteDatabaseProvider(null!, NullLogger.Instance), new NoLockBehavior(NullLogger.Instance)); } private AudioBook CreateAudioBook() { // GetUserDataKeys(): ["Author-Series-0001Book Title", ""] return new AudioBook { Id = Guid.NewGuid(), Name = "Book Title", Album = "Series", AlbumArtists = new[] { "Author" }, IndexNumber = 1 }; } private void Seed(AudioBook item, params UserData[] rows) { using var ctx = CreateDbContext(); ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! }); ctx.UserData.AddRange(rows); ctx.SaveChanges(); } private User CreateOtherUser() { var user = new User("other", "auth-provider", "reset-provider") { Id = Guid.NewGuid() }; using var ctx = CreateDbContext(); ctx.Users.Add(user); ctx.SaveChanges(); return user; } private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks) { return new UserData { ItemId = item.Id, Item = null, UserId = _user.Id, User = null, CustomDataKey = key, PlaybackPositionTicks = positionTicks }; } [Fact] public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow() { var item = CreateAudioBook(); var currentKey = item.GetUserDataKeys()[0]; // the retired-key row comes first to ensure selection is by key, not row order Seed( item, CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), CreateUserDataRow(item, currentKey, 222)); var userData = _userDataManager.GetUserData(_user, item); Assert.NotNull(userData); Assert.Equal(currentKey, userData.Key); Assert.Equal(222, userData.PlaybackPositionTicks); } [Fact] public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow() { var item = CreateAudioBook(); var idKey = item.GetUserDataKeys()[1]; Seed( item, CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111), CreateUserDataRow(item, idKey, 333)); var userData = _userDataManager.GetUserData(_user, item); Assert.NotNull(userData); Assert.Equal(idKey, userData.Key); Assert.Equal(333, userData.PlaybackPositionTicks); } [Fact] public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow() { var item = CreateAudioBook(); Seed(item, CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)); var userData = _userDataManager.GetUserData(_user, item); Assert.NotNull(userData); Assert.Equal(111, userData.PlaybackPositionTicks); } [Fact] public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey() { var item = CreateAudioBook(); Seed(item); var userData = _userDataManager.GetUserData(_user, item); Assert.NotNull(userData); Assert.Equal(item.GetUserDataKeys()[0], userData.Key); Assert.Equal(0, userData.PlaybackPositionTicks); } [Fact] public void GetUserData_RowsForOtherUsers_AreIgnored() { var item = CreateAudioBook(); var currentKey = item.GetUserDataKeys()[0]; var otherUserRow = CreateUserDataRow(item, currentKey, 999); otherUserRow.UserId = CreateOtherUser().Id; Seed(item, otherUserRow, CreateUserDataRow(item, currentKey, 222)); var userData = _userDataManager.GetUserData(_user, item); Assert.NotNull(userData); Assert.Equal(222, userData.PlaybackPositionTicks); } [Fact] public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder() { var fossilItem = CreateAudioBook(); var retiredItem = CreateAudioBook(); // the stale id-key row is inserted first so selection by row order would return it Seed( fossilItem, CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111), CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222)); Seed(retiredItem, CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333)); var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user); Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks); Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks); } [Fact] public void GetUserData_NullUser_ThrowsArgumentNullException() { var item = CreateAudioBook(); Assert.Throws(() => _userDataManager.GetUserData(null!, item)); } }