Kavita/API.Tests/Services/OpdsServiceTests.cs
Joe Milazzo 5f744fa2fe
Polish Pass 1 (#4084)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-10-08 06:47:41 -07:00

1003 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.OPDS;
using API.DTOs.OPDS.Requests;
using API.DTOs.Progress;
using API.Constants;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Services;
public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTest(testOutputHelper)
{
private readonly string _testFilePath = Path.Join(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/OpdsService"), "test.zip");
#region Setup
private static Tuple<IOpdsService, IReaderService> SetupService(IUnitOfWork unitOfWork, IMapper mapper)
{
JobStorage.Current = new InMemoryStorage();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(), ds, Substitute.For<IScrobblingService>());
var localizationService =
new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For<IMemoryCache>(), unitOfWork);
var seriesService = new SeriesService(unitOfWork, Substitute.For<IEventHub>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SeriesService>>(), Substitute.For<IScrobblingService>(),
localizationService, Substitute.For<IReadingListService>());
var opdsService = new OpdsService(unitOfWork, localizationService,
seriesService, Substitute.For<DownloadService>(),
ds, readerService, mapper);
return new Tuple<IOpdsService, IReaderService>(opdsService, readerService);
}
private async Task<AppUser> SetupSeriesAndUser(DataContext context, IUnitOfWork unitOfWork, int numberOfSeries = 1)
{
var library = new LibraryBuilder("Test Lib").Build();
unitOfWork.LibraryRepository.Add(library);
await unitOfWork.CommitAsync();
context.AppUser.Add(new AppUserBuilder("majora2007", "majora2007")
.WithLibrary(library)
.WithLocale("en")
.WithRole(PolicyConstants.AdminRole)
.Build());
await context.SaveChangesAsync();
Assert.NotEmpty(await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1));
var counter = 0;
foreach (var i in Enumerable.Range(0, numberOfSeries))
{
var series = new SeriesBuilder("Test " + (i + 1))
.WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)
.WithChapter(new ChapterBuilder("1")
.WithSortOrder(counter)
.WithPages(10)
.WithFile(new MangaFileBuilder(_testFilePath, MangaFormat.Archive, 10).Build())
.Build())
.WithChapter(new ChapterBuilder("2")
.WithFile(new MangaFileBuilder(_testFilePath, MangaFormat.Archive, 10).Build())
.WithSortOrder(counter + 1)
.WithPages(10)
.Build())
.Build())
.Build();
series.Library = library;
context.Series.Add(series);
counter += 2;
}
await unitOfWork.CommitAsync();
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress | AppUserIncludes.WantToRead | AppUserIncludes.Collections);
Assert.NotNull(user);
// Setup SideNav streams for library
user.SideNavStreams = new List<AppUserSideNavStream>
{
new AppUserSideNavStream
{
Name = library.Name,
IsProvided = true,
Order = 0,
StreamType = SideNavStreamType.Library,
Visible = true,
LibraryId = library.Id,
AppUserId = user.Id
}
};
await unitOfWork.CommitAsync();
return user;
}
private static async Task<AppUserCollection> CreateCollection(IUnitOfWork unitOfWork, string title, int userId, params int[] seriesIds)
{
var collectionBuilder = new AppUserCollectionBuilder(title, promoted: true);
foreach (var seriesId in seriesIds)
{
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series != null)
{
collectionBuilder.WithItem(series);
}
}
var collection = collectionBuilder.Build();
// Get the user and add collection
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Collections);
if (user != null)
{
user.Collections.Add(collection);
await unitOfWork.CommitAsync();
}
return collection;
}
private static async Task<ReadingList> CreateReadingList(DataContext context, IUnitOfWork unitOfWork, string title, int userId, List<(int seriesId, int volumeId, int chapterId)> items)
{
var readingList = new ReadingListBuilder(title)
.WithAppUserId(userId)
.Build();
var order = 0;
foreach (var (seriesId, volumeId, chapterId) in items)
{
var readingListItem = new ReadingListItem
{
SeriesId = seriesId,
VolumeId = volumeId,
ChapterId = chapterId,
Order = order++
};
readingList.Items.Add(readingListItem);
}
context.ReadingList.Add(readingList);
await unitOfWork.CommitAsync();
return readingList;
}
private static async Task<AppUserSmartFilter> CreateSmartFilter(DataContext context, int userId, string name, string filter)
{
var smartFilter = new AppUserSmartFilter
{
Name = name,
Filter = filter,
AppUserId = userId
};
context.AppUserSmartFilter.Add(smartFilter);
await context.SaveChangesAsync();
return smartFilter;
}
private static void ValidatePaginationLinks(Feed feed, int pageNumber, bool expectNext, bool expectPrev)
{
var nextLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Next);
var prevLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Prev);
if (expectNext)
{
Assert.NotNull(nextLink);
}
else
{
Assert.Null(nextLink);
}
if (expectPrev)
{
Assert.NotNull(prevLink);
}
else
{
Assert.Null(prevLink);
}
}
#endregion
#region Continue Points
[Fact]
public async Task ContinuePoint_ShouldWorkWithProgress()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1);
await readerService.MarkChaptersAsRead(user, 1, [firstChapter]);
await unitOfWork.CommitAsync();
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = 0
});
Assert.Equal(3, feed.Entries.Count);
Assert.StartsWith("Continue Reading from", feed.Entries.First().Title);
}
[Fact]
public async Task ContinuePoint_DoesntExist_WhenNoProgress()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = 0
});
Assert.Equal(2, feed.Entries.Count);
}
#endregion
#region Reading Progress Icons
[Theory]
[InlineData(0, "NoReadingProgressIcon", 0)] // No progress
[InlineData(2, "QuarterReadingProgressIcon", 0)] // 2/10 pages = quarter
[InlineData(5, "HalfReadingProgressIcon", 0)] // 5/10 pages = half
[InlineData(7, "AboveHalfReadingProgressIcon", 0)] // 7/10 pages = above half
[InlineData(10, "FullReadingProgressIcon", 1)] // 10/10 pages = full (shows in continue reading)
public async Task ReadingProgressIconEncoding(int pageNum, string expectedIconField, int entryIndex)
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1);
Assert.NotNull(firstChapter);
if (pageNum > 0)
{
await readerService.SaveReadingProgress(new ProgressDto
{
VolumeId = firstChapter.VolumeId,
ChapterId = firstChapter.Id,
PageNum = pageNum,
SeriesId = 1,
LibraryId = 1,
BookScrollId = null,
LastModifiedUtc = default
}, user.Id);
}
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = 0
});
Assert.NotEmpty(feed.Entries);
var expectedIcon = typeof(OpdsService).GetField(expectedIconField)?.GetValue(null) as string;
Assert.NotNull(expectedIcon);
Assert.Contains(expectedIcon, feed.Entries[entryIndex].Title);
}
#endregion
#region Misc
[Fact]
public async Task Search_EmptyQuery_ThrowsException()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
await Assert.ThrowsAsync<Exceptions.OpdsException>(async () =>
{
await opdsService.Search(new OpdsSearchRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
Query = string.Empty
});
});
}
[Fact]
public async Task GetCatalogue_ContainsDashboardStreams()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
// Setup dashboard streams
user.DashboardStreams = new List<AppUserDashboardStream>
{
new AppUserDashboardStream
{
Name = "On Deck",
IsProvided = true,
Order = 0,
StreamType = DashboardStreamType.OnDeck,
Visible = true,
AppUserId = user.Id
},
new AppUserDashboardStream
{
Name = "Recently Added",
IsProvided = true,
Order = 1,
StreamType = DashboardStreamType.NewlyAdded,
Visible = true,
AppUserId = user.Id
}
};
await unitOfWork.CommitAsync();
var feed = await opdsService.GetCatalogue(new OpdsCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id
});
Assert.NotEmpty(feed.Entries);
Assert.Contains(feed.Entries, e => e.Id == "onDeck");
Assert.Contains(feed.Entries, e => e.Id == "recentlyAdded");
Assert.Contains(feed.Entries, e => e.Id == "readingList");
Assert.Contains(feed.Entries, e => e.Id == "wantToRead");
Assert.Contains(feed.Entries, e => e.Id == "allLibraries");
Assert.Contains(feed.Entries, e => e.Id == "allCollections");
}
#endregion
#region Paginated Catalogue Tests
[Fact]
public async Task GetSmartFilters_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create smart filters (more than page size)
for (var i = 0; i < OpdsService.PageSize + 5; i++)
{
await CreateSmartFilter(context, user.Id, $"Filter {i}", "combination=0");
}
// Test page 1
var feed = await opdsService.GetSmartFilters(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
Assert.Equal(OpdsService.PageSize + 5, feed.Total);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetLibraries_ReturnsAllLibraries()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetLibraries(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Single(feed.Entries);
Assert.Contains("Test Lib", feed.Entries.First().Title);
}
[Fact]
public async Task GetWantToRead_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Mark series as want to read
for (var i = 1; i <= OpdsService.PageSize + 5; i++)
{
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(i);
if (series != null)
{
user.WantToRead.Add(new AppUserWantToRead
{
SeriesId = series.Id,
AppUserId = user.Id
});
}
}
await unitOfWork.CommitAsync();
// Test page 1
var feed = await opdsService.GetWantToRead(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetCollections_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create collections (more than page size)
var firstSeries = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
user.Collections ??= new List<AppUserCollection>();
for (var i = 0; i < OpdsService.PageSize + 5; i++)
{
user.Collections.Add(new AppUserCollectionBuilder($"Collection {i}").WithItem(firstSeries).Build());
}
await unitOfWork.CommitAsync();
// Test page 1
var feed = await opdsService.GetCollections(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
// Collections should exist
Assert.NotEmpty(feed.Entries);
// If we have more than page size, verify pagination
if (feed.Total > OpdsService.PageSize)
{
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
}
[Fact]
public async Task GetReadingLists_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create reading lists (more than page size)
for (var i = 0; i < OpdsService.PageSize + 5; i++)
{
await CreateReadingList(context, unitOfWork, $"Reading List {i}", user.Id,
[(1, 1, 1)]);
}
// Test page 1
var feed = await opdsService.GetReadingLists(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetRecentlyAdded_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Test page 1
var feed = await opdsService.GetRecentlyAdded(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetRecentlyUpdated_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Mark some chapters as read to create updated series
for (var i = 1; i <= OpdsService.PageSize + 5; i++)
{
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(i * 2 - 1);
if (chapter != null)
{
await readerService.MarkChaptersAsRead(user, i, [chapter]);
}
}
await unitOfWork.CommitAsync();
// Test page 1
var feed = await opdsService.GetRecentlyUpdated(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
}
[Fact]
public async Task GetOnDeck_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Mark first chapter as read for each series to create on-deck items
for (var i = 1; i <= OpdsService.PageSize + 5; i++)
{
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(i * 2 - 1);
if (chapter != null)
{
await readerService.MarkChaptersAsRead(user, i, [chapter]);
}
}
await unitOfWork.CommitAsync();
// Test page 1
var feed = await opdsService.GetOnDeck(new OpdsPaginatedCatalogueRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
}
#endregion
#region Entity Feeds
[Fact]
public async Task PaginationWorks()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize * 2);
var libs = (await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1)).ToList();
// Test page 1
var feed = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest()
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = libs.First().Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
Assert.Equal(OpdsService.PageSize * 2, feed.Total);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
// Test page 2
var feed2 = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest()
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = libs.First().Id,
PageNumber = OpdsService.FirstPageNumber + 1
});
Assert.Equal(OpdsService.PageSize, feed2.Entries.Count);
// Ensure there is no overlap between page 1 and page 2
var page1Ids = feed.Entries.Select(e => e.Id).ToList();
var page2Ids = feed2.Entries.Select(e => e.Id).ToList();
Assert.Empty(page1Ids.Intersect(page2Ids));
// Validate page 2 pagination - should have prev link but no next link (last page)
ValidatePaginationLinks(feed2, OpdsService.FirstPageNumber + 1, expectNext: false, expectPrev: true);
}
[Fact]
public async Task GetMoreInGenre_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Add genre to all series
var genre = new GenreBuilder("Action").Build();
context.Genre.Add(genre);
await context.SaveChangesAsync();
for (var i = 1; i <= OpdsService.PageSize + 5; i++)
{
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(i);
if (series?.Metadata != null)
{
series.Metadata.Genres.Add(genre);
}
}
await unitOfWork.CommitAsync();
// Test page 1
var feed = await opdsService.GetMoreInGenre(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = genre.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
#endregion
#region Detail Feeds
[Fact]
public async Task GetSeriesFromSmartFilter_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create a smart filter that matches series containing "Test" (all test series)
var smartFilter = await CreateSmartFilter(context, user.Id, "Test Filter", "combination=0");
// Test page 1
var feed = await opdsService.GetSeriesFromSmartFilter(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = smartFilter.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetSeriesFromCollection_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create a collection with PageSize + 1 series
var seriesIds = Enumerable.Range(1, OpdsService.PageSize + 1).ToArray();
var collection = await CreateCollection(unitOfWork, "Test Collection", user.Id, seriesIds);
// Test page 1
var feed = await opdsService.GetSeriesFromCollection(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = collection.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
// If we have pagination, verify it
if (feed.Total > OpdsService.PageSize)
{
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
// Validate all entries are from the collection
foreach (var entry in feed.Entries)
{
var seriesId = int.Parse(entry.Id);
Assert.True(seriesId <= OpdsService.PageSize + 5);
}
}
[Fact]
public async Task GetSeriesFromLibrary_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
var libraries = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
var library = libraries.First();
// Test page 1
var feed = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = library.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.Equal(OpdsService.PageSize, feed.Entries.Count);
ValidatePaginationLinks(feed, OpdsService.FirstPageNumber, expectNext: true, expectPrev: false);
}
[Fact]
public async Task GetReadingListItems_WithPagination()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork, OpdsService.PageSize + 5);
// Create reading list with items from all series (chapter 1 only)
var items = new List<(int, int, int)>();
for (var i = 1; i <= OpdsService.PageSize + 5; i++)
{
items.Add((i, 1, i * 2 - 1)); // seriesId, volumeId, chapterId
}
var readingList = await CreateReadingList(context, unitOfWork, "Test Reading List", user.Id, items);
// Test page 1
var feed = await opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = readingList.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.True(feed.Entries.Count == UserParams.Default.PageSize);
Assert.True(feed.Total >= OpdsService.PageSize);
// Validate pagination - reading lists have complex pagination due to continue reading items
// First page should never have prev link
var prevLink = feed.Links.FirstOrDefault(l => l.Rel == FeedLinkRelation.Prev);
Assert.Null(prevLink);
}
/// <summary>
/// Reading lists have unique pagination implementation thus need explicit testing
/// </summary>
[Fact]
public async Task GetReadingListItems_ContinueFromItem_WhenFirst2PagesFullyRead()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
// Create enough series for 2+ pages (UserParams.Default.PageSize * 2 + 5)
var totalItems = UserParams.Default.PageSize * 2 + 5;
var user = await SetupSeriesAndUser(context, unitOfWork, totalItems);
// Create reading list with items from all series (chapter 1 only)
var items = new List<(int, int, int)>();
for (var i = 1; i <= totalItems; i++)
{
items.Add((i, 1, i * 2 - 1)); // seriesId, volumeId, chapterId
}
var readingList = await CreateReadingList(context, unitOfWork, "Test Reading List", user.Id, items);
// Mark all chapters in first 2 pages as fully read
var itemsInFirst2Pages = UserParams.Default.PageSize * 2;
for (var i = 1; i <= itemsInFirst2Pages; i++)
{
var chapterId = i * 2 - 1;
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter != null)
{
await readerService.MarkChaptersAsRead(user, i, [chapter]);
}
}
await unitOfWork.CommitAsync();
// Test page 1 - should include continue reading item at the top
var feed = await opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = readingList.Id,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
// Verify continue reading item is inserted at the top
var firstEntry = feed.Entries.First();
Assert.Contains("Continue Reading from", firstEntry.Title);
// The continue reading should point to the first unread item (page 3, first item)
var expectedNextUnreadItemIndex = itemsInFirst2Pages + 1; // First item of page 3
Assert.Contains($"Test {expectedNextUnreadItemIndex}", firstEntry.Title);
}
[Fact]
public async Task GetSeriesDetail_ReturnsChapters()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
Assert.Equal(2, feed.Entries.Count); // 2 chapters
}
[Fact]
public async Task GetItemsFromVolume_ReturnsChapters()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetItemsFromVolume(new OpdsItemsFromCompoundEntityIdsRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
SeriesId = 1,
VolumeId = 1,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
// May include continue reading item if there's progress
Assert.True(feed.Entries.Count >= 2);
}
[Fact]
public async Task GetItemsFromChapter_ReturnsFiles()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetItemsFromChapter(new OpdsItemsFromCompoundEntityIdsRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
SeriesId = 1,
VolumeId = 1,
ChapterId = 1,
PageNumber = OpdsService.FirstPageNumber
});
Assert.NotEmpty(feed.Entries);
Assert.Single(feed.Entries); // Each chapter has 1 file
}
#endregion
#region XML Serialization
[Fact]
public async Task SerializeXml_ProducesValidXml()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = OpdsService.FirstPageNumber
});
var xml = opdsService.SerializeXml(feed);
Assert.NotEmpty(xml);
Assert.Contains("<?xml version=\"1.0\" encoding=\"utf-8\"?>", xml);
Assert.Contains("utf-8", xml);
Assert.DoesNotContain("utf-16", xml);
}
[Fact]
public async Task SerializeXml_HandlesNullFeed()
{
var (unitOfWork, _, mapper) = await CreateDatabase();
var (opdsService, _) = SetupService(unitOfWork, mapper);
var xml = opdsService.SerializeXml(null);
Assert.Empty(xml);
}
#endregion
}