Skip to content

Commit 2e1ddd5

Browse files
test: Add unit tests, controller ctor; fix timestamp logic
Add numerous unit tests across Themerr.Tests to increase coverage and validate edge cases (controller ReplaceTheme, manager Sync and Replace behaviors, ContinueDownload exception handling, legacy path candidates, database path/migration, and path handling for files without extension). Introduce SetThemerrPluginInstance test helper. Add an explicit ThemerrController constructor overload. Update ThemerrManager to clear DownloadedTimestampUtc when the current theme provider is not Themerr to avoid preserving irrelevant timestamps. Also add doxygen to .readthedocs.yaml apt_packages so source API docs can be generated.
1 parent 476d5a2 commit 2e1ddd5

6 files changed

Lines changed: 376 additions & 3 deletions

File tree

.readthedocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ build:
1212
tools:
1313
python: "3.14"
1414
apt_packages:
15+
- doxygen # required to generate source API docs
1516
- graphviz # required to build diagrams
1617
jobs:
1718
post_build:

Jellyfin.Plugin.Themerr.Tests/TestThemerrController.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections;
33
using System.Collections.Generic;
44
using System.IO;
5+
using Jellyfin.Plugin.Themerr;
56
using Jellyfin.Plugin.Themerr.Api;
67
using Jellyfin.Plugin.Themerr.Storage;
78
using MediaBrowser.Common.Configuration;
@@ -147,4 +148,71 @@ public async Task TestReplaceThemeNotFound()
147148
var result = await _controller.ReplaceTheme(Guid.NewGuid());
148149
Assert.IsType<NotFoundResult>(result);
149150
}
151+
152+
/// <summary>
153+
/// Test ReplaceTheme returns 204 when the item theme is replaced.
154+
/// </summary>
155+
[Fact]
156+
[Trait("Category", "Unit")]
157+
public async Task TestReplaceThemeNoContent()
158+
{
159+
var tempPath = Path.Combine(Path.GetTempPath(), "ThemerrJellyfinTests", Guid.NewGuid().ToString("N"));
160+
Directory.CreateDirectory(tempPath);
161+
var movie = new Movie
162+
{
163+
Name = "Controller Replace",
164+
Path = Path.Combine(tempPath, "Controller Replace (1970).mp4"),
165+
ProductionYear = 1970,
166+
ProviderIds = new Dictionary<string, string> { { MetadataProvider.Tmdb.ToString(), "controller-replace" } },
167+
};
168+
var themePath = ThemerrManager.GetThemePath(movie);
169+
var audioStubPath = Path.Combine(Directory.GetCurrentDirectory(), "data", "audio_stub.mp3");
170+
var repository = new ThemerrRepository(
171+
Path.Combine(tempPath, "themerr.db"),
172+
new Mock<ILogger>().Object);
173+
repository.Save(movie, new ThemerrMediaItemSaveOptions
174+
{
175+
ThemePath = themePath,
176+
ThemeProvider = ThemerrThemeProvider.User,
177+
InThemerrDb = true,
178+
InThemerrDbCheckedUtc = DateTime.UtcNow,
179+
YoutubeThemeUrl = "https://www.youtube.com/watch?v=controller-replace",
180+
});
181+
182+
var mockApplicationPaths = TestHelper.GetMockApplicationPaths(tempPath);
183+
var mockLibraryManager = new Mock<ILibraryManager>();
184+
mockLibraryManager
185+
.Setup(x => x.GetItemById(movie.Id))
186+
.Returns(movie);
187+
var mockYoutubeClient = new Mock<IYoutubeClientWrapper>();
188+
mockYoutubeClient
189+
.Setup(x => x.DownloadAudioAsync(It.IsAny<string>(), It.IsAny<string>()))
190+
.Returns<string, string>((_, destination) =>
191+
{
192+
File.Copy(audioStubPath, destination, true);
193+
return Task.CompletedTask;
194+
});
195+
var manager = new ThemerrManager(
196+
mockApplicationPaths.Object,
197+
mockLibraryManager.Object,
198+
new Mock<ILogger<ThemerrManager>>().Object,
199+
mockYoutubeClient.Object,
200+
repository);
201+
var controller = new ThemerrController(manager, new Mock<ILogger<ThemerrController>>().Object);
202+
203+
try
204+
{
205+
var result = await controller.ReplaceTheme(movie.Id);
206+
207+
Assert.IsType<NoContentResult>(result);
208+
Assert.True(File.Exists(themePath));
209+
}
210+
finally
211+
{
212+
if (File.Exists(themePath))
213+
{
214+
File.Delete(themePath);
215+
}
216+
}
217+
}
150218
}

Jellyfin.Plugin.Themerr.Tests/TestThemerrManager.cs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,15 @@ private static void CreateThemerrPluginInstance(bool backupUserSuppliedTheme = t
433433
plugin.Configuration.BackupUserSuppliedTheme = backupUserSuppliedTheme;
434434
}
435435

436+
private static void SetThemerrPluginInstance(ThemerrPlugin? plugin)
437+
{
438+
var instanceField = typeof(ThemerrPlugin).GetField(
439+
"<Instance>k__BackingField",
440+
BindingFlags.NonPublic | BindingFlags.Static);
441+
Assert.NotNull(instanceField);
442+
instanceField.SetValue(null, plugin);
443+
}
444+
436445
private static ThemerrManager CreateThemerrManagerWithFailingYoutubeAndItemById(
437446
BaseItem item,
438447
ThemerrRepository? themerrRepository = null,
@@ -969,6 +978,17 @@ private async Task TestGetThemeProvider(BaseItem item)
969978
Assert.Equal(ThemerrThemeProvider.Themerr, themeProvider);
970979
}
971980

981+
[Fact]
982+
[Trait("Category", "Unit")]
983+
private async Task TestGetThemeProviderUnsupportedItem()
984+
{
985+
var manager = CreateThemerrManager();
986+
987+
var themeProvider = await manager.GetThemeProvider(new Audio { Name = "Unsupported Theme Provider" });
988+
989+
Assert.Null(themeProvider);
990+
}
991+
972992
[Fact]
973993
[Trait("Category", "Unit")]
974994
private void TestContinueDownload()
@@ -1360,6 +1380,55 @@ private async Task TestSyncLibraryItemTracksUserThemeAndCachedThemerrDbUrl()
13601380
Assert.Null(syncedItem.ThemeHash);
13611381
}
13621382

1383+
[Fact]
1384+
[Trait("Category", "Unit")]
1385+
private async Task TestSyncLibraryItemPreservesThemerrDownloadedTimestamp()
1386+
{
1387+
var repository = CreateThemerrRepository();
1388+
var manager = CreateThemerrManager(repository);
1389+
var tempPath = CreateTempDirectory();
1390+
var item = CreateMovie("sync-themerr-timestamp");
1391+
item.Path = Path.Combine(tempPath, "Test Movie (1970).mp4");
1392+
var themePath = ThemerrManager.GetThemePath(item);
1393+
var downloadedTimestampUtc = DateTime.UtcNow.AddMinutes(-10);
1394+
1395+
File.Copy(
1396+
Path.Combine(Directory.GetCurrentDirectory(), "data", "audio_stub.mp3"),
1397+
themePath,
1398+
true);
1399+
var themeHash = ThemerrManager.GetThemeHash(themePath);
1400+
1401+
repository.Save(
1402+
item,
1403+
new ThemerrMediaItemSaveOptions
1404+
{
1405+
ThemePath = themePath,
1406+
ThemeHash = themeHash,
1407+
ThemeHashAlgorithm = ThemerrThemeHasher.CurrentAlgorithm,
1408+
ThemeProvider = ThemerrThemeProvider.Themerr,
1409+
DownloadedTimestampUtc = downloadedTimestampUtc,
1410+
InThemerrDb = true,
1411+
InThemerrDbCheckedUtc = DateTime.UtcNow,
1412+
YoutubeThemeUrl = "https://www.youtube.com/watch?v=sync-themerr-timestamp",
1413+
});
1414+
1415+
try
1416+
{
1417+
var syncedItem = await manager.SyncLibraryItem(item);
1418+
1419+
Assert.NotNull(syncedItem);
1420+
Assert.Equal(ThemerrThemeProvider.Themerr, syncedItem.ThemeProvider);
1421+
Assert.Equal(downloadedTimestampUtc, syncedItem.DownloadedTimestampUtc);
1422+
}
1423+
finally
1424+
{
1425+
if (File.Exists(themePath))
1426+
{
1427+
File.Delete(themePath);
1428+
}
1429+
}
1430+
}
1431+
13631432
[Theory]
13641433
[Trait("Category", "Unit")]
13651434
[MemberData(nameof(FixtureJellyfinServer.MockItemsData), MemberType = typeof(FixtureJellyfinServer))]
@@ -1903,6 +1972,109 @@ private async Task TestReplaceWithThemerThemeNoBackupWhenNoExistingTheme()
19031972
}
19041973
}
19051974

1975+
/// <summary>
1976+
/// Test that ReplaceWithThemerTheme skips backup when the plugin instance is unavailable.
1977+
/// </summary>
1978+
[Fact]
1979+
[Trait("Category", "Unit")]
1980+
private async Task TestReplaceWithThemerThemeNoBackupWhenPluginInstanceMissing()
1981+
{
1982+
var previousPlugin = ThemerrPlugin.Instance;
1983+
SetThemerrPluginInstance(null);
1984+
1985+
var movie = CreateMovie("backup-plugin-missing");
1986+
var tempPath = CreateTempDirectory();
1987+
movie.Path = Path.Combine(tempPath, "Test Movie (1970).mp4");
1988+
1989+
var repository = CreateThemerrRepository();
1990+
var manager = CreateThemerrManagerWithMockYoutubeAndItemById(movie, repository);
1991+
var themePath = ThemerrManager.GetThemePath(movie);
1992+
var backupPath = Path.Combine(Path.GetDirectoryName(themePath)!, "theme.backup.mp3");
1993+
1994+
File.Copy(Path.Combine(Directory.GetCurrentDirectory(), "data", "audio_stub.mp3"), themePath, true);
1995+
1996+
repository.Save(movie, new ThemerrMediaItemSaveOptions
1997+
{
1998+
ThemePath = themePath,
1999+
ThemeProvider = ThemerrThemeProvider.User,
2000+
InThemerrDb = true,
2001+
InThemerrDbCheckedUtc = DateTime.UtcNow,
2002+
YoutubeThemeUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
2003+
});
2004+
2005+
try
2006+
{
2007+
var result = await manager.ReplaceWithThemerTheme(movie.Id);
2008+
Assert.True(result);
2009+
Assert.True(File.Exists(themePath));
2010+
Assert.False(File.Exists(backupPath));
2011+
}
2012+
finally
2013+
{
2014+
SetThemerrPluginInstance(previousPlugin);
2015+
if (File.Exists(themePath))
2016+
{
2017+
File.Delete(themePath);
2018+
}
2019+
2020+
if (File.Exists(backupPath))
2021+
{
2022+
File.Delete(backupPath);
2023+
}
2024+
}
2025+
}
2026+
2027+
/// <summary>
2028+
/// Test that ReplaceWithThemerTheme returns false without restore work when no backup exists.
2029+
/// </summary>
2030+
[Fact]
2031+
[Trait("Category", "Unit")]
2032+
private async Task TestReplaceWithThemerThemeDownloadFailureWithoutBackup()
2033+
{
2034+
var previousPlugin = ThemerrPlugin.Instance;
2035+
CreateThemerrPluginInstance(backupUserSuppliedTheme: false);
2036+
2037+
var movie = CreateMovie("backup-failure-no-backup");
2038+
var tempPath = CreateTempDirectory();
2039+
movie.Path = Path.Combine(tempPath, "Test Movie (1970).mp4");
2040+
2041+
var repository = CreateThemerrRepository();
2042+
var manager = CreateThemerrManagerWithFailingYoutubeAndItemById(movie, repository);
2043+
var themePath = ThemerrManager.GetThemePath(movie);
2044+
var backupPath = Path.Combine(Path.GetDirectoryName(themePath)!, "theme.backup.mp3");
2045+
2046+
repository.Save(movie, new ThemerrMediaItemSaveOptions
2047+
{
2048+
ThemePath = themePath,
2049+
ThemeProvider = ThemerrThemeProvider.User,
2050+
InThemerrDb = true,
2051+
InThemerrDbCheckedUtc = DateTime.UtcNow,
2052+
YoutubeThemeUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
2053+
});
2054+
2055+
try
2056+
{
2057+
var result = await manager.ReplaceWithThemerTheme(movie.Id);
2058+
2059+
Assert.False(result);
2060+
Assert.False(File.Exists(themePath));
2061+
Assert.False(File.Exists(backupPath));
2062+
}
2063+
finally
2064+
{
2065+
SetThemerrPluginInstance(previousPlugin);
2066+
if (File.Exists(themePath))
2067+
{
2068+
File.Delete(themePath);
2069+
}
2070+
2071+
if (File.Exists(backupPath))
2072+
{
2073+
File.Delete(backupPath);
2074+
}
2075+
}
2076+
}
2077+
19062078
/// <summary>
19072079
/// Test that ReplaceWithThemerTheme restores the original theme from backup when the download fails.
19082080
/// </summary>
@@ -1980,6 +2152,52 @@ private void TestContinueDownloadUsesExistingThemeSongPath()
19802152
Assert.False(manager.ContinueDownload(item, missingThemePath));
19812153
}
19822154

2155+
[Fact]
2156+
[Trait("Category", "Unit")]
2157+
private void TestContinueDownloadIgnoresNullReferenceFromThemeSongs()
2158+
{
2159+
var repository = CreateThemerrRepository();
2160+
var item = CreateMovie("theme-song-null-reference");
2161+
var missingThemePath = Path.Combine(CreateTempDirectory(), "missing-theme.mp3");
2162+
var manager = CreateThemerrManager(
2163+
repository,
2164+
themeSongProvider: _ => throw new NullReferenceException("Simulated theme song failure"));
2165+
2166+
Assert.True(manager.ContinueDownload(item, missingThemePath));
2167+
}
2168+
2169+
[Fact]
2170+
[Trait("Category", "Unit")]
2171+
private void TestContinueDownloadIgnoresThemeSongExceptionWithoutLogger()
2172+
{
2173+
var repository = CreateThemerrRepository();
2174+
var item = CreateMovie("theme-song-null-logger");
2175+
var missingThemePath = Path.Combine(CreateTempDirectory(), "missing-theme.mp3");
2176+
var manager = new ThemerrManager(
2177+
TestHelper.GetMockApplicationPaths().Object,
2178+
new Mock<ILibraryManager>().Object,
2179+
null!,
2180+
themerrRepository: repository,
2181+
httpClient: CreateThemerrDbHttpClient(),
2182+
themeSongProvider: _ => throw new NullReferenceException("Simulated theme song failure"));
2183+
2184+
Assert.True(manager.ContinueDownload(item, missingThemePath));
2185+
}
2186+
2187+
[Fact]
2188+
[Trait("Category", "Unit")]
2189+
private void TestContinueDownloadPropagatesUnexpectedThemeSongException()
2190+
{
2191+
var repository = CreateThemerrRepository();
2192+
var item = CreateMovie("theme-song-unexpected-exception");
2193+
var missingThemePath = Path.Combine(CreateTempDirectory(), "missing-theme.mp3");
2194+
var manager = CreateThemerrManager(
2195+
repository,
2196+
themeSongProvider: _ => throw new ArgumentException("Simulated unexpected theme song failure"));
2197+
2198+
Assert.Throws<ArgumentException>(() => manager.ContinueDownload(item, missingThemePath));
2199+
}
2200+
19832201
[Fact]
19842202
[Trait("Category", "Unit")]
19852203
private void TestLegacyThemerrDataPathCandidatesIncludeRawDirectoryCandidate()
@@ -2011,6 +2229,8 @@ private void TestIsMatchingMediaDirectoryHandlesEmptyAndMismatchedNames()
20112229
var tempPath = CreateTempDirectory();
20122230
var matchingDirectory = Path.Combine(tempPath, "Empty Name (1970)");
20132231
var mismatchedDirectory = Path.Combine(tempPath, "Different Title (1970)");
2232+
var yearlessDirectory = Path.Combine(tempPath, "Yearless Title");
2233+
var missingYearDirectory = Path.Combine(tempPath, "Test Movie matching-media-directory (1980)");
20142234
Directory.CreateDirectory(matchingDirectory);
20152235
Directory.CreateDirectory(mismatchedDirectory);
20162236
var method = typeof(ThemerrManager).GetMethod(
@@ -2036,9 +2256,29 @@ private void TestIsMatchingMediaDirectoryHandlesEmptyAndMismatchedNames()
20362256
CreateMovie("matching-media-directory"),
20372257
mismatchedDirectory,
20382258
})!;
2259+
var yearlessResult = (bool)method.Invoke(
2260+
manager,
2261+
new object[]
2262+
{
2263+
new Movie
2264+
{
2265+
Name = "Yearless Title",
2266+
ProductionYear = null,
2267+
},
2268+
yearlessDirectory,
2269+
})!;
2270+
var missingYearResult = (bool)method.Invoke(
2271+
manager,
2272+
new object[]
2273+
{
2274+
CreateMovie("matching-media-directory"),
2275+
missingYearDirectory,
2276+
})!;
20392277

20402278
Assert.False(emptyNameResult);
20412279
Assert.False(mismatchedNameResult);
2280+
Assert.True(yearlessResult);
2281+
Assert.False(missingYearResult);
20422282
}
20432283

20442284
[Fact]

0 commit comments

Comments
 (0)