using ApplicationServices; using AssertionHelper; using DataLayer; using Dinah.Core.ErrorHandling; using FileLiberator; using LibationFileManager; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; namespace LibationCli.Tests; /// /// Drives the real liberate run loop against a real library database and a processable that records /// downloads the way does, which is as close to a limited run as is possible /// without an Audible account. /// [TestClass] [DoNotParallelize] public class RunLimitLoopTests { private string tempLibationFiles = string.Empty; private TextWriter originalOut = Console.Out; private StringWriter capturedOut = new(); [TestInitialize] public void Initialize() { tempLibationFiles = Path.Combine(Path.GetTempPath(), $"libation-run-limit-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempLibationFiles); // A fresh Configuration resolves LibationFiles from this variable, so the database lands in the temp dir. Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, tempLibationFiles); Configuration.CreateMockInstance(); originalOut = Console.Out; capturedOut = new StringWriter(); Console.SetOut(capturedOut); } [TestCleanup] public void Cleanup() { Console.SetOut(originalOut); Configuration.RestoreSingletonInstance(); Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, null); try { Directory.Delete(tempLibationFiles, recursive: true); } catch (IOException) { // A leftover temp directory is not worth failing a test over. } } [TestMethod] public async Task A_book_limit_leaves_the_rest_of_the_library_for_the_next_run() { SeedLibrary(5); var processable = NewProcessable(); await new LimitedRun(new(Configuration.DailyLimitUnit.Books, 2)).Go(processable); processable.Downloaded.Should().HaveCount(2); StringAssert.Contains( Output, "Reached this run's limit of 2 book(s). Downloaded 2 title(s); stopping. " + "Remaining titles are still un-liberated and will be tried on the next run."); StringAssert.Contains(Output, "Done. Stopped early: this run's download limit was reached."); } [TestMethod] public async Task A_size_limit_stops_once_another_book_would_not_fit() { SeedLibrary(10); var processable = NewProcessable(bytesPerBook: 300L * 1024 * 1024); await new LimitedRun(new(Configuration.DailyLimitUnit.GB, 1)).Go(processable); // A fourth 300 MB title would be assumed to need another 400 MB, past the 1 GB limit. processable.Downloaded.Should().HaveCount(3); StringAssert.Contains( Output, "Reached this run's limit of 1 GB. Downloaded about 900 MB across 3 title(s); stopping. " + "Remaining titles are still un-liberated and will be tried on the next run."); } [TestMethod] public async Task A_run_that_fits_inside_its_limit_says_nothing_about_it() { SeedLibrary(3); var processable = NewProcessable(); await new LimitedRun(new(Configuration.DailyLimitUnit.Books, 10)).Go(processable); processable.Downloaded.Should().HaveCount(3); Output.Contains("Reached this run's limit").Should().BeFalse(); StringAssert.Contains(Output, "Done. All books have been processed"); } [TestMethod] public async Task A_run_ending_exactly_at_its_limit_says_nothing_either() { // Nothing was cut short, so reporting a limit reached would be misleading. SeedLibrary(2); var processable = NewProcessable(); await new LimitedRun(new(Configuration.DailyLimitUnit.Books, 2)).Go(processable); processable.Downloaded.Should().HaveCount(2); Output.Contains("Reached this run's limit").Should().BeFalse(); StringAssert.Contains(Output, "Done. All books have been processed"); } [TestMethod] public async Task Without_a_limit_the_whole_library_is_processed() { SeedLibrary(6); var processable = NewProcessable(); await new LimitedRun(null).Go(processable); processable.Downloaded.Should().HaveCount(6); StringAssert.Contains(Output, "Done. All books have been processed"); } [TestMethod] public async Task Downloads_from_an_earlier_run_do_not_count_against_this_one() { SeedLibrary(4); var processable = NewProcessable(); // The same titles, downloaded moments ago by a different run: inside the daily window, outside this run. for (var i = 0; i < 4; i++) DownloadHistoryStore.Record(ProductId(i), isAudiblePlus: true, bytes: 1, completedAt: DateTimeOffset.Now.AddHours(-1)); await new LimitedRun(new(Configuration.DailyLimitUnit.Books, 3)).Go(processable); processable.Downloaded.Should().HaveCount(3); } private string Output => capturedOut.ToString(); private static string ProductId(int index) => $"B0RUNLIMIT{index:0000}"; private static RecordingProcessable NewProcessable(long bytesPerBook = 1024 * 1024) => new() { Configuration = Configuration.Instance, BytesPerBook = bytesPerBook }; private static void SeedLibrary(int count) { // Shared instances: a contributor is one row no matter how many books credit it. var author = new Contributor("Test Author"); var narrator = new Contributor("Test Narrator"); using var context = DbContexts.GetContext(); for (var i = 0; i < count; i++) { var book = new Book( new AudibleProductId(ProductId(i)), $"Test Title {i}", "", "", 600, ContentType.Product, [author], [narrator], "us"); context.LibraryBooks.Add(new LibraryBook(book, new DateTime(2026, 1, 1).AddMinutes(i), "test-account")); } context.SaveChanges(); } /// Stands in for : succeeds, and records what it "downloaded". private sealed class RecordingProcessable : Processable { public required long BytesPerBook { get; init; } public List Downloaded { get; } = []; public override string Name => nameof(RecordingProcessable); public override bool Validate(LibraryBook libraryBook) => true; public override Task ProcessAsync(LibraryBook libraryBook) { Downloaded.Add(libraryBook.Book.AudibleProductId); DownloadHistoryStore.Record(libraryBook.Book.AudibleProductId, libraryBook.IsAudiblePlus, BytesPerBook); return Task.FromResult(new StatusHandler()); } } /// Exposes the run loop, which is otherwise reachable only through a verb. private sealed class LimitedRun(RunDownloadLimit? limit) : ProcessableOptionsBase { protected override RunDownloadLimit? RunLimit => limit; protected override Task ProcessAsync() => Task.CompletedTask; public Task Go(Processable processable) => RunAsync(processable); } }