using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using SeerrAutoApprove.Options; using SeerrAutoApprove.Processing; using SeerrAutoApprove.Quota; using SeerrAutoApprove.Storage; namespace SeerrAutoApprove.Tests; public sealed class RequestProcessorTests : IDisposable { private readonly TempDatabase _db = new(); private readonly SqliteApprovalStore _store; private readonly FakeSeerrClient _seerr = new(); private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 10, 15, 12, 0, 0, TimeSpan.Zero)); public RequestProcessorTests() { _store = _db.CreateStore(); } public void Dispose() => _db.Dispose(); private RequestProcessor CreateProcessor(AutoApproveOptions options) => new(_seerr, _store, new StaticOptionsMonitor(options), _time, NullLogger.Instance); [Fact] public async Task Approves_movies_up_to_limit_and_leaves_rest_pending() { var requests = Enumerable.Range(0, 4).Select(i => Requests.Movie(userId: 1, createdAt: _time.GetUtcNow().AddMinutes(i))).ToList(); _seerr.Pending.AddRange(requests); var result = await CreateProcessor(new AutoApproveOptions { MovieLimit = 2 }).ProcessAsync(CancellationToken.None); Assert.Equal([requests[0].Id, requests[1].Id], _seerr.Approved); Assert.Equal(2, result.Approved); Assert.Equal(2, result.Skipped); } [Fact] public async Task Approves_oldest_requests_first() { var newer = Requests.Movie(1, createdAt: _time.GetUtcNow()); var older = Requests.Movie(1, createdAt: _time.GetUtcNow().AddHours(-1)); _seerr.Pending.AddRange([newer, older]); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); Assert.Equal([older.Id], _seerr.Approved); } [Fact] public async Task Quotas_are_tracked_per_user() { var a1 = Requests.Movie(1); var a2 = Requests.Movie(1); var b1 = Requests.Movie(2); _seerr.Pending.AddRange([a1, a2, b1]); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); Assert.Equal([a1.Id, b1.Id], _seerr.Approved); } [Fact] public async Task Movie_and_series_quotas_are_independent() { var movie = Requests.Movie(1); var series = Requests.Series(1, seasons: 1); _seerr.Pending.AddRange([movie, series]); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, SeriesLimit = 1 }).ProcessAsync(CancellationToken.None); Assert.Equal([movie.Id, series.Id], _seerr.Approved); } [Fact] public async Task Series_count_per_season_by_default() { var threeSeasons = Requests.Series(1, seasons: 3); var twoSeasons = Requests.Series(1, seasons: 2); _seerr.Pending.AddRange([threeSeasons, twoSeasons]); await CreateProcessor(new AutoApproveOptions { SeriesLimit = 2 }).ProcessAsync(CancellationToken.None); Assert.Equal([twoSeasons.Id], _seerr.Approved); } [Fact] public async Task Series_count_per_request_when_configured() { var threeSeasons = Requests.Series(1, seasons: 3); var twoSeasons = Requests.Series(1, seasons: 2); _seerr.Pending.AddRange([threeSeasons, twoSeasons]); await CreateProcessor(new AutoApproveOptions { SeriesLimit = 2, SeriesCountMode = SeriesCountMode.PerRequest }) .ProcessAsync(CancellationToken.None); Assert.Equal([threeSeasons.Id, twoSeasons.Id], _seerr.Approved); } [Fact] public async Task FourK_requests_use_separate_limits() { var movie = Requests.Movie(1); var movie4k = Requests.Movie(1, is4k: true); var series4k = Requests.Series(1, seasons: 1, is4k: true); _seerr.Pending.AddRange([movie, movie4k, series4k]); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, Movie4kLimit = 1, Series4kLimit = 0 }) .ProcessAsync(CancellationToken.None); Assert.Equal([movie.Id, movie4k.Id], _seerr.Approved); } [Fact] public async Task Limit_zero_never_approves() { _seerr.Pending.Add(Requests.Movie(1)); await CreateProcessor(new AutoApproveOptions { MovieLimit = 0 }).ProcessAsync(CancellationToken.None); Assert.Empty(_seerr.Approved); } [Fact] public async Task Limit_minus_one_is_unlimited() { _seerr.Pending.AddRange(Enumerable.Range(0, 25).Select(_ => Requests.Movie(1))); await CreateProcessor(new AutoApproveOptions { MovieLimit = -1 }).ProcessAsync(CancellationToken.None); Assert.Equal(25, _seerr.Approved.Count); } [Fact] public async Task Previous_approvals_in_period_count_towards_quota() { await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, _time.GetUtcNow().AddDays(-5)), CancellationToken.None); _seerr.Pending.AddRange([Requests.Movie(1), Requests.Movie(1)]); await CreateProcessor(new AutoApproveOptions { MovieLimit = 2, RollingDays = 30 }).ProcessAsync(CancellationToken.None); Assert.Single(_seerr.Approved); } [Fact] public async Task Approvals_outside_rolling_window_do_not_count() { await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, _time.GetUtcNow().AddDays(-31)), CancellationToken.None); _seerr.Pending.Add(Requests.Movie(1)); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 30 }).ProcessAsync(CancellationToken.None); Assert.Single(_seerr.Approved); } [Fact] public async Task Approvals_in_previous_calendar_period_do_not_count() { // now = 2026-10-15; previous approval in September await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, new DateTimeOffset(2026, 9, 30, 23, 0, 0, TimeSpan.Zero)), CancellationToken.None); _seerr.Pending.Add(Requests.Movie(1)); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, PeriodType = PeriodType.Calendar, CalendarPeriod = CalendarPeriod.Month }) .ProcessAsync(CancellationToken.None); Assert.Single(_seerr.Approved); } [Fact] public async Task Dry_run_does_not_approve_or_record() { _seerr.Pending.Add(Requests.Movie(1)); var result = await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, DryRun = true }).ProcessAsync(CancellationToken.None); Assert.Equal(1, result.Approved); Assert.Empty(_seerr.Approved); Assert.Equal(0, await _store.GetUsedUnitsAsync(1, QuotaCategory.Movie, DateTimeOffset.MinValue, CancellationToken.None)); } [Fact] public async Task Failed_approval_is_not_recorded() { var failing = Requests.Movie(1); var ok = Requests.Movie(1); _seerr.Pending.AddRange([failing, ok]); _seerr.FailingApprovals.Add(failing.Id); await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); Assert.Equal([ok.Id], _seerr.Approved); } [Fact] public async Task Skipped_requests_are_approved_later_when_reevaluation_enabled() { var first = Requests.Movie(1); var second = Requests.Movie(1); _seerr.Pending.AddRange([first, second]); var processor = CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 7, ReevaluateSkippedRequests = true }); await processor.ProcessAsync(CancellationToken.None); Assert.Equal([first.Id], _seerr.Approved); _time.Advance(TimeSpan.FromDays(8)); await processor.ProcessAsync(CancellationToken.None); Assert.Equal([first.Id, second.Id], _seerr.Approved); } [Fact] public async Task Skipped_requests_stay_pending_when_reevaluation_disabled() { var first = Requests.Movie(1); var second = Requests.Movie(1); _seerr.Pending.AddRange([first, second]); var processor = CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 7, ReevaluateSkippedRequests = false }); await processor.ProcessAsync(CancellationToken.None); _time.Advance(TimeSpan.FromDays(8)); var third = Requests.Movie(1); _seerr.Pending.Add(third); await processor.ProcessAsync(CancellationToken.None); Assert.Equal([first.Id, third.Id], _seerr.Approved); } }