diff options
Diffstat (limited to 'tests/Jellyfin.Server.Integration.Tests')
5 files changed, 325 insertions, 1 deletions
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs index 79b9d1e2c5..3d0840b776 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -1,12 +1,19 @@ using System; +using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -218,4 +225,122 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_UserHasLibraryPreferences_RemapsLibraryId() + { + const string Name = "rename-prefs-old"; + const string NewName = "rename-prefs-new"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&collectionType=movies&refreshLibrary=true", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, NewName), typeof(CollectionFolder)); + Assert.NotEqual(oldId, newId); + + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + + var otherId = Guid.NewGuid(); + var user = await userManager.CreateUserAsync("rename-prefs-user"); + user.SetPermission(PermissionKind.EnableAllFolders, false); + user.SetPreference(PreferenceKind.EnabledFolders, [otherId, oldId, newId]); + user.SetPreference(PreferenceKind.BlockedMediaFolders, [oldId]); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, [oldId, otherId]); + user.SetPreference(PreferenceKind.LatestItemExcludes, [oldId]); + + // The views built from the library take its id and name as input, so they move with it. + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var oldUserViewId = libraryManager.GetNamedView(user, oldLibrary.Name, oldId, oldLibrary.CollectionType, string.Empty).Id; + user.SetPreference(PreferenceKind.MyMediaExcludes, [otherId.ToString(), oldId.ToString(), oldShadowId.ToString("N", CultureInfo.InvariantCulture)]); + user.SetPreference(PreferenceKind.GroupedFolders, [oldId]); + user.SetPreference(PreferenceKind.OrderedViews, [otherId.ToString(), oldUserViewId.ToString(), oldShadowId.ToString(), oldId.ToString()]); + user.SetPreference(PreferenceKind.BlockedTags, ["keep"]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={NewName}&refreshLibrary=true", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + var reloaded = userManager.GetUserById(user.Id)!; + Assert.Equal([otherId, newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders)); + Assert.Equal([newId, otherId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.EnableContentDeletionFromFolders)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes)); + // The endpoint refreshes in the background; make sure the renamed library has been picked up. + await libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true); + var newLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(newId)); + var newShadowId = libraryManager.GetShadowView(newLibrary, newLibrary.CollectionType, string.Empty).Id; + var newUserViewId = libraryManager.GetNamedView(reloaded, newLibrary.Name, newId, newLibrary.CollectionType, string.Empty).Id; + Assert.NotEqual(oldShadowId, newShadowId); + Assert.NotEqual(oldUserViewId, newUserViewId); + Assert.Equal( + [otherId.ToString(), newId.ToString(), newShadowId.ToString("N", CultureInfo.InvariantCulture)], + reloaded.GetPreference(PreferenceKind.MyMediaExcludes)); + Assert.Equal([newId], reloaded.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders)); + Assert.Equal( + [otherId.ToString(), newUserViewId.ToString(), newShadowId.ToString(), newId.ToString()], + reloaded.GetPreference(PreferenceKind.OrderedViews)); + Assert.Equal(["keep"], reloaded.GetPreference(PreferenceKind.BlockedTags)); + + // With the block list cleared, the enabled list alone must let the user see the renamed library. + reloaded.SetPreference(PreferenceKind.BlockedMediaFolders, Array.Empty<Guid>()); + await userManager.UpdateUserAsync(reloaded); + Assert.True(newLibrary.IsVisible(userManager.GetUserById(user.Id)!)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={NewName}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task RenameLibrary_CapitalizationOnly_LeavesPreferencesAlone() + { + const string Name = "rename-case-test"; + + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var libraryManager = _factory.Services.GetRequiredService<ILibraryManager>(); + var userManager = _factory.Services.GetRequiredService<IUserManager>(); + var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath; + + var body = new AddVirtualFolderDto() { LibraryOptions = new LibraryOptions() }; + using var createResponse = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode); + + var oldId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name), typeof(CollectionFolder)); + var newId = libraryManager.GetNewItemId(Path.Combine(rootFolderPath, Name.ToUpperInvariant()), typeof(CollectionFolder)); + var oldLibrary = libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().Single(f => f.Id.Equals(oldId)); + var oldShadowId = libraryManager.GetShadowView(oldLibrary, oldLibrary.CollectionType, string.Empty).Id; + var newShadowId = libraryManager.GetShadowViewId(Name.ToUpperInvariant(), newId, oldLibrary.CollectionType); + + var user = await userManager.CreateUserAsync("rename-case-user"); + user.SetPreference(PreferenceKind.EnabledFolders, [oldId]); + user.SetPreference(PreferenceKind.MyMediaExcludes, [oldShadowId]); + await userManager.UpdateUserAsync(user); + + using var renameResponse = await client.PostAsync( + $"Library/VirtualFolders/Name?name={Name}&newName={Name.ToUpperInvariant()}&refreshLibrary=false", + null, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, renameResponse.StatusCode); + + Assert.Equal([newId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders)); + Assert.Equal([newShadowId], userManager.GetUserById(user.Id)!.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes)); + + using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name.ToUpperInvariant()}&refreshLibrary=false", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode); + } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs new file mode 100644 index 0000000000..f84e28de76 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public SyncPlayControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetGroups_Unauthorized_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetGroups_InvalidToken_ReturnsUnauthorized() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader("invalid-token"); + + var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs new file mode 100644 index 0000000000..81cff49945 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs @@ -0,0 +1,26 @@ +using Jellyfin.Server.Implementations.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + + public HostedServiceRegistrationTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void DeviceAccessHost_IsRegisteredAsHostedService() + { + _ = _factory.CreateClient(); + + var hostedServices = _factory.Services.GetServices<IHostedService>(); + + Assert.Contains(hostedServices, service => service is DeviceAccessHost); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 54f443de2d..bcd6cd9bf4 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -29,6 +29,7 @@ namespace Jellyfin.Server.Integration.Tests { private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data"); private readonly ConcurrentBag<IDisposable> _disposableComponents = new ConcurrentBag<IDisposable>(); + private readonly string _webHostPathRoot; /// <summary> /// Initializes static members of the <see cref="JellyfinApplicationFactory"/> class. @@ -42,6 +43,23 @@ namespace Jellyfin.Server.Integration.Tests StartupHelpers.PerformStaticInitialization(); } + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class using a new temporary directory. + /// </summary> + public JellyfinApplicationFactory() + : this(Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName()))) + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="JellyfinApplicationFactory"/> class. + /// </summary> + /// <param name="webHostPathRoot">The directory the application paths are created in. Reusing it starts the same server again.</param> + protected JellyfinApplicationFactory(string webHostPathRoot) + { + _webHostPathRoot = webHostPathRoot; + } + /// <inheritdoc/> protected override IHostBuilder CreateHostBuilder() { @@ -57,7 +75,7 @@ namespace Jellyfin.Server.Integration.Tests var commandLineOpts = new StartupOptions(); // Use a temporary directory for the application paths - var webHostPathRoot = Path.Combine(_testPathRoot, "test-host-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + var webHostPathRoot = _webHostPathRoot; Directory.CreateDirectory(Path.Combine(webHostPathRoot, "logs")); Directory.CreateDirectory(Path.Combine(webHostPathRoot, "config")); Directory.CreateDirectory(Path.Combine(webHostPathRoot, "cache")); diff --git a/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs new file mode 100644 index 0000000000..b8c149891f --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Emby.Server.Implementations.Serialization; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests; + +/// <summary> +/// Covers starting a server that has been set up before after its database has been deleted. +/// </summary> +public sealed class StartOverTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "jellyfin-test-data", "start-over-" + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + [Fact] + public async Task DeletedDatabase_WizardReset_StartsAsNewServer() + { + var paths = await SetUpServerAndDeleteDatabaseAsync(); + SetWizardCompleted(paths, false); + + using var factory = new SameRootApplicationFactory(_root); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/Startup/Configuration", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var dbContextFactory = factory.Services.GetRequiredService<IDbContextFactory<JellyfinDbContext>>(); + await using var context = await dbContextFactory.CreateDbContextAsync(TestContext.Current.CancellationToken); + var applied = (await context.Database.GetAppliedMigrationsAsync(TestContext.Current.CancellationToken)).ToHashSet(StringComparer.Ordinal); + Assert.All(context.GetService<IMigrationsAssembly>().Migrations.Keys, id => Assert.Contains(id, applied)); + } + + [Fact] + public async Task DeletedDatabase_WizardStillCompleted_RefusesToStart() + { + var paths = await SetUpServerAndDeleteDatabaseAsync(); + var systemConfiguration = await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken); + + using var factory = new SameRootApplicationFactory(_root); + var exception = Assert.ThrowsAny<Exception>(() => factory.CreateClient()); + + var guard = exception as InvalidOperationException ?? exception.InnerException as InvalidOperationException; + Assert.NotNull(guard); + Assert.Contains("the database does not exist", guard.Message, StringComparison.Ordinal); + Assert.Empty(Directory.GetFiles(paths.DataPath, "jellyfin.db*")); + Assert.Equal(systemConfiguration, await File.ReadAllBytesAsync(paths.SystemConfigurationFilePath, TestContext.Current.CancellationToken)); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + // Best effort, a locked file must not fail the test. + } + } + + private static void SetWizardCompleted(IApplicationPaths paths, bool completed) + { + var serializer = new MyXmlSerializer(); + var configuration = (ServerConfiguration)serializer.DeserializeFromFile(typeof(ServerConfiguration), paths.SystemConfigurationFilePath)!; + configuration.IsStartupWizardCompleted = completed; + serializer.SerializeToFile(configuration, paths.SystemConfigurationFilePath); + } + + private async Task<IApplicationPaths> SetUpServerAndDeleteDatabaseAsync() + { + IApplicationPaths paths; + using (var factory = new SameRootApplicationFactory(_root)) + { + using var client = factory.CreateClient(); + paths = factory.Services.GetRequiredService<IApplicationPaths>(); + + using (var response = await client.GetAsync("/Startup/User", TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + var user = new StartupUserDto { Name = "StartOver", Password = "StartOver" }; + using (var response = await client.PostAsJsonAsync("/Startup/User", user, _jsonOptions, TestContext.Current.CancellationToken)) + { + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + } + + // Completing the wizard through the API would queue a library scan that can still hold the database file when + // it is deleted below, so the flag is set on the stopped server instead. + SetWizardCompleted(paths, true); + SqliteConnection.ClearAllPools(); + foreach (var file in Directory.GetFiles(paths.DataPath, "jellyfin.db*")) + { + File.Delete(file); + } + + return paths; + } + + private sealed class SameRootApplicationFactory(string webHostPathRoot) : JellyfinApplicationFactory(webHostPathRoot); +} |
