aboutsummaryrefslogtreecommitdiff
path: root/tests/Jellyfin.Server.Integration.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/Jellyfin.Server.Integration.Tests')
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs125
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs36
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs26
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs20
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/StartOverTests.cs119
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);
+}