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 SetupService(IUnitOfWork unitOfWork, IMapper mapper) { JobStorage.Current = new InMemoryStorage(); var ds = new DirectoryService(Substitute.For>(), new FileSystem()); var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), ds, Substitute.For()); var localizationService = new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For(), unitOfWork); var seriesService = new SeriesService(unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), Substitute.For(), localizationService, Substitute.For()); var opdsService = new OpdsService(unitOfWork, localizationService, seriesService, Substitute.For(), ds, readerService, mapper); return new Tuple(opdsService, readerService); } private async Task 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 { 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 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 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 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(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 { 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(); 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); } /// /// Reading lists have unique pagination implementation thus need explicit testing /// [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); 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 }