using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities.Enums; using API.Entities.User; using API.Services.Reading; using API.Data; using API.Data.Repositories; using API.Services; using API.Services.Plus; using API.SignalR; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; using System.IO.Abstractions.TestingHelpers; namespace API.Tests.Services; public class ReaderServiceRereadTests { private readonly ISeriesRepository _seriesRepo; private readonly IVolumeRepository _volumeRepo; private readonly IChapterRepository _chapterRepo; private readonly IAppUserProgressRepository _progressRepo; private readonly IUserRepository _userRepo; private readonly ILibraryRepository _libraryRepo; private readonly ISeriesService _seriesService; private readonly ReaderService _readerService; public ReaderServiceRereadTests() { var unitOfWork = Substitute.For(); _seriesRepo = Substitute.For(); _volumeRepo = Substitute.For(); _chapterRepo = Substitute.For(); _progressRepo = Substitute.For(); _userRepo = Substitute.For(); _libraryRepo = Substitute.For(); _seriesService = Substitute.For(); unitOfWork.SeriesRepository.Returns(_seriesRepo); unitOfWork.VolumeRepository.Returns(_volumeRepo); unitOfWork.ChapterRepository.Returns(_chapterRepo); unitOfWork.AppUserProgressRepository.Returns(_progressRepo); unitOfWork.UserRepository.Returns(_userRepo); unitOfWork.LibraryRepository.Returns(_libraryRepo); _readerService = new ReaderService( unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For(), Substitute.For(), Substitute.For(), _seriesService, Substitute.For(), Substitute.For() ); } #region CheckSeriesForReRead Tests [Fact] public async Task CheckSeriesForReRead_NoProgress_ShouldNotPrompt() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 0, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 1, VolumeId = 1, PagesRead = 0, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _chapterRepo.GetFirstChapterForSeriesAsync(seriesId, userId).Returns(continuePoint); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(false); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns((DateTime?)null); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); Assert.NotNull(result.ChapterOnContinue); Assert.Equal(continuePoint.Id, result.ChapterOnContinue.ChapterId); } [Fact] public async Task CheckSeriesForReRead_FullyRead_ShouldPromptFullReread() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 100, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 10, VolumeId = 3, PagesRead = 10, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; var firstChapter = new ChapterDto { Id = 1, VolumeId = 1, PagesRead = 10, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _chapterRepo.GetFirstChapterForSeriesAsync(seriesId, userId).Returns(firstChapter); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns(DateTime.UtcNow.AddDays(-1)); // Mock GetContinuePoint internals _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _chapterRepo.GetCurrentlyReadingChapterAsync(seriesId, userId).Returns(continuePoint); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.True(result.FullReread); Assert.False(result.TimePrompt); Assert.NotNull(result.ChapterOnReread); Assert.Equal(firstChapter.Id, result.ChapterOnReread.ChapterId); Assert.Equal(seriesDto.Name, result.ChapterOnReread.Label); } [Fact] public async Task CheckSeriesForReRead_ContinuePointHasProgress_NotFullyRead_ShouldNotPrompt() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 50, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 5, VolumeId = 2, PagesRead = 5, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns(DateTime.UtcNow.AddDays(-1)); _chapterRepo.GetCurrentlyReadingChapterAsync(seriesId, userId).Returns(continuePoint); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); Assert.Equal(continuePoint.Id, result.ChapterOnContinue.ChapterId); Assert.Equal(continuePoint.Id, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckSeriesForReRead_ContinuePointHasProgress_FullyRead_ShouldPrompt() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 50, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 5, VolumeId = 2, PagesRead = 10, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns(DateTime.UtcNow.AddDays(-1)); _chapterRepo.GetCurrentlyReadingChapterAsync(seriesId, userId).Returns(continuePoint); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.False(result.TimePrompt); Assert.Equal(continuePoint.Id, result.ChapterOnContinue.ChapterId); Assert.Equal(continuePoint.Id, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckSeriesForReRead_LongTimeSinceLastProgress_ShouldPromptTimeBasedReread() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; const int daysSinceRead = 50; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 50, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 5, VolumeId = 2, PagesRead = 5, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns(DateTime.UtcNow.AddDays(-daysSinceRead)); _chapterRepo.GetCurrentlyReadingChapterAsync(seriesId, userId).Returns(continuePoint); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.True(result.TimePrompt); Assert.Equal(daysSinceRead, result.DaysSinceLastRead); Assert.Equal(continuePoint.Id, result.ChapterOnContinue.ChapterId); Assert.Equal(continuePoint.Id, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckSeriesForReRead_ContinuePointNoProgress_WithPreviousChapter_ShouldOfferPrevChapter() { // Arrange const int userId = 1; const int seriesId = 1; const int libraryId = 1; const int daysSinceRead = 50; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var seriesDto = new SeriesDto { Id = seriesId, Name = "Test Series", PagesRead = 50, Pages = 100, LibraryId = libraryId }; var continuePoint = new ChapterDto { Id = 5, SortOrder = 2, VolumeId = 2, PagesRead = 0, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; var prevChapter = new ChapterDto { Id = 4, SortOrder = 1, VolumeId = 2, PagesRead = 10, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns(seriesDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _progressRepo.AnyUserProgressForSeriesAsync(seriesId, userId).Returns(true); _progressRepo.GetLatestProgressForSeries(seriesId, userId).Returns(DateTime.UtcNow.AddDays(-daysSinceRead)); _chapterRepo.GetCurrentlyReadingChapterAsync(seriesId, userId).Returns(continuePoint); _chapterRepo.GetChapterDtoAsync(4, userId).Returns(prevChapter); // Mock GetPrevChapterIdAsync to return chapter 4 var volumes = new List { new VolumeDto { Id = 2, MinNumber = 2, Chapters = new List { prevChapter, continuePoint } } }; _volumeRepo.GetVolumesDtoAsync(seriesId, userId).Returns(volumes); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.True(result.TimePrompt); Assert.Equal(daysSinceRead, result.DaysSinceLastRead); Assert.Equal(continuePoint.Id, result.ChapterOnContinue.ChapterId); Assert.Equal(prevChapter.Id, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckSeriesForReRead_SeriesNotFound_ShouldReturnDont() { // Arrange const int userId = 1; const int seriesId = 999; const int libraryId = 1; _seriesRepo.GetSeriesDtoByIdAsync(seriesId, userId).Returns((SeriesDto?)null); // Act var result = await _readerService.CheckSeriesForReRead(userId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); } #endregion #region CheckVolumeForReRead Tests [Fact] public async Task CheckVolumeForReRead_NoProgress_ShouldNotPrompt() { // Arrange const int userId = 1; const int volumeId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var volumeDto = new VolumeDto { Id = volumeId, PagesRead = 0, Pages = 100, Chapters = new List { new ChapterDto { Id = 1, VolumeId = volumeId, PagesRead = 0, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], } } }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _volumeRepo.GetVolumeDtoAsync(volumeId, userId).Returns(volumeDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForVolume(volumeId, userId).Returns((DateTime?)null); // Act var result = await _readerService.CheckVolumeForReRead(userId, volumeId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); Assert.Equal(volumeDto.Chapters.First().Id, result.ChapterOnContinue.ChapterId); } [Fact] public async Task CheckVolumeForReRead_FullyRead_ShouldPromptFullReread() { // Arrange const int userId = 1; const int volumeId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var volumeDto = new VolumeDto { Id = volumeId, Name = "Volume 1", PagesRead = 100, Pages = 100, Chapters = new List { new ChapterDto { Id = 1, VolumeId = volumeId, PagesRead = 50, Pages = 50, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }, new ChapterDto { Id = 2, VolumeId = volumeId, PagesRead = 50, Pages = 50, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], } } }; var firstChapter = volumeDto.Chapters.First(); _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _volumeRepo.GetVolumeDtoAsync(volumeId, userId).Returns(volumeDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForVolume(volumeId, userId).Returns(DateTime.UtcNow.AddDays(-1)); _chapterRepo.GetFirstChapterForVolumeAsync(volumeId, userId).Returns(firstChapter); // Act var result = await _readerService.CheckVolumeForReRead(userId, volumeId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.True(result.FullReread); Assert.False(result.TimePrompt); Assert.NotNull(result.ChapterOnReread); Assert.Equal(firstChapter.Id, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckVolumeForReRead_PartialProgressInVolume_ShouldCheckTime() { // Arrange const int userId = 1; const int volumeId = 1; const int seriesId = 1; const int libraryId = 1; const int daysSinceRead = 10; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var volumeDto = new VolumeDto { Id = volumeId, PagesRead = 50, Pages = 100, Chapters = new List { new ChapterDto { Id = 1, VolumeId = volumeId, PagesRead = 50, Pages = 50, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], SortOrder = 1 }, new ChapterDto { Id = 2, VolumeId = volumeId, PagesRead = 0, Pages = 50, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], SortOrder = 2 } } }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _volumeRepo.GetVolumeDtoAsync(volumeId, userId).Returns(volumeDto); _volumeRepo.GetVolumesDtoAsync(seriesId, userId).Returns([volumeDto]); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForVolume(volumeId, userId).Returns(DateTime.UtcNow.AddDays(-daysSinceRead)); // Act var result = await _readerService.CheckVolumeForReRead(userId, volumeId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); // Should not prompt because days < PromptForRereadsAfter Assert.Equal(volumeDto.Chapters.Skip(1).First().Id, result.ChapterOnContinue.ChapterId); } [Fact] public async Task CheckVolumeForReRead_VolumeNotFound_ShouldReturnDont() { // Arrange const int userId = 1; const int volumeId = 999; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _volumeRepo.GetVolumeDtoAsync(volumeId, userId).Returns((VolumeDto?)null); // Act var result = await _readerService.CheckVolumeForReRead(userId, volumeId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); } #endregion #region CheckChapterForReRead Tests [Fact] public async Task CheckChapterForReRead_NoProgress_ShouldNotPrompt() { // Arrange const int userId = 1; const int chapterId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var chapterDto = new ChapterDto { Id = chapterId, VolumeId = 1, PagesRead = 0, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _chapterRepo.GetChapterDtoAsync(chapterId, userId).Returns(chapterDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForChapter(chapterId, userId).Returns((DateTime?)null); // Act var result = await _readerService.CheckChapterForReRead(userId, chapterId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); Assert.Equal(chapterId, result.ChapterOnContinue.ChapterId); } [Fact] public async Task CheckChapterForReRead_FullyRead_ShouldPrompt() { // Arrange const int userId = 1; const int chapterId = 1; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var chapterDto = new ChapterDto { Id = chapterId, VolumeId = 1, PagesRead = 10, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _chapterRepo.GetChapterDtoAsync(chapterId, userId).Returns(chapterDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForChapter(chapterId, userId).Returns(DateTime.UtcNow.AddDays(-1)); // Act var result = await _readerService.CheckChapterForReRead(userId, chapterId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.False(result.TimePrompt); Assert.Equal(chapterId, result.ChapterOnContinue.ChapterId); Assert.Equal(chapterId, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckChapterForReRead_PartiallyRead_WithinTimeThreshold_ShouldNotPrompt() { // Arrange const int userId = 1; const int chapterId = 1; const int seriesId = 1; const int libraryId = 1; const int daysSinceRead = 10; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var chapterDto = new ChapterDto { Id = chapterId, VolumeId = 1, PagesRead = 5, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _chapterRepo.GetChapterDtoAsync(chapterId, userId).Returns(chapterDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForChapter(chapterId, userId).Returns(DateTime.UtcNow.AddDays(-daysSinceRead)); // Act var result = await _readerService.CheckChapterForReRead(userId, chapterId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); Assert.Equal(chapterId, result.ChapterOnContinue.ChapterId); } [Fact] public async Task CheckChapterForReRead_PartiallyRead_BeyondTimeThreshold_ShouldPromptTimeBasedReread() { // Arrange const int userId = 1; const int chapterId = 1; const int seriesId = 1; const int libraryId = 1; const int daysSinceRead = 50; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; var chapterDto = new ChapterDto { Id = chapterId, VolumeId = 1, PagesRead = 5, Pages = 10, Files = [new MangaFileDto(){Format = MangaFormat.Archive}], }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _chapterRepo.GetChapterDtoAsync(chapterId, userId).Returns(chapterDto); _libraryRepo.GetLibraryTypeAsync(libraryId).Returns(LibraryType.Manga); _progressRepo.GetLatestProgressForChapter(chapterId, userId).Returns(DateTime.UtcNow.AddDays(-daysSinceRead)); // Act var result = await _readerService.CheckChapterForReRead(userId, chapterId, seriesId, libraryId); // Assert Assert.True(result.ShouldPrompt); Assert.True(result.TimePrompt); Assert.Equal(daysSinceRead, result.DaysSinceLastRead); Assert.Equal(chapterId, result.ChapterOnContinue.ChapterId); Assert.Equal(chapterId, result.ChapterOnReread.ChapterId); } [Fact] public async Task CheckChapterForReRead_ChapterNotFound_ShouldReturnDont() { // Arrange const int userId = 1; const int chapterId = 999; const int seriesId = 1; const int libraryId = 1; var userPrefs = new AppUserPreferences { PromptForRereadsAfter = 30, Theme = null!, }; _userRepo.GetPreferencesForUser(userId).Returns(userPrefs); _chapterRepo.GetChapterDtoAsync(chapterId, userId).Returns((ChapterDto?)null); // Act var result = await _readerService.CheckChapterForReRead(userId, chapterId, seriesId, libraryId); // Assert Assert.False(result.ShouldPrompt); } #endregion }