Feature: Duplicate images are purged automatically on the configured schedule As a RenewCMS owner or admin I want the duplicate purge to run by itself every few days once I turn it on So that I do not have to remember to purge, and the Settings "Last purge" line stays current Background: Given part 1 provides purge.Settings, purge.Repository (Get, Save and SaveLastRun) and RunPurgeUseCase.Run(fingerprint *string) And a new use case PurgeSchedulerUseCase in internal/infrastructure/useCases depends only on purge.Repository and a RunPurge port, takes no HTTP types and imports no persistence, mailer, models, gorm, net/http, chi or api And PurgeSchedulerUseCase.Tick(now time.Time) (result purge.RunResult, ran bool, err error) reads the settings on every call, where result carries groups purged, copies merged and articles updated and is empty when ran is false And a run is due when auto_purge_enabled is true, enabled_at is set and now is not before Settings.NextPurgeAt(), that is max(last_purge_at, enabled_at) + auto_purge_interval_days days And a scheduled run is RunPurge.Run(nil): no fingerprint, no HTTP, no user-visible message And a run that finds nothing to purge still records last_purge_at with merged 0 and articles_updated 0, so the anchor moves forward (this is RunPurge's existing behaviour) And the scheduler never writes settings itself: only RunPurge records last_purge_*, through Repository.SaveLastRun, which writes only the last_purge_* fields and never the whole settings row And there is no env var: the schedule (auto_purge_enabled, auto_purge_interval_days, enabled_at, last_purge_at) lives in the purge settings row, and is read again on every tick And a runner in the new package internal/infrastructure/scheduler calls Tick on a ticker every 1 hour, runs one Tick after a startup delay of 1 minute, and stops when its context is cancelled And the runner takes its clock, its tick source and its startup delay by injection, so tests use a fake clock and hand-fed channels, with no real sleeping and no real time.Ticker And api/dependecyInjection.go builds the runner and cmd/server/main.go only starts it And the runner uses the *log.Logger already used by RunPurge (log.Default() in the container) Rule: When a run is due Background: Given the fake clock reads 2026-10-05T09:00:00Z Scenario: Automatic purge off never runs, whatever the dates are Given auto_purge_enabled is false, interval 1, enabled_at 2020-01-01T00:00:00Z and last_purge_at 2020-01-02T00:00:00Z When Tick is called with the fake clock's time Then ran is false and err is nil And RunPurge was not called and Save was not called Scenario: The defaults never run Given nothing was ever saved, so the settings are the defaults When Tick is called Then ran is false and RunPurge was not called Scenario: On, never ran, not due yet Given auto_purge_enabled is true, interval 30, enabled_at 2026-09-20T09:00:00Z and no last_purge_at When Tick is called Then ran is false and RunPurge was not called Scenario: On, never ran, due since enabled_at plus the interval Given auto_purge_enabled is true, interval 10, enabled_at 2026-09-25T09:00:00Z and no last_purge_at When Tick is called Then RunPurge is called once with no fingerprint and ran is true Scenario Outline: The anchor is the later of last_purge_at and enabled_at Given auto_purge_enabled is true and interval 10 And enabled_at is and last_purge_at is When Tick is called at 2026-10-05T09:00:00Z Then RunPurge is called time(s) Examples: | enabled_at | last_purge_at | calls | why | | 2026-09-01T00:00:00Z | 2026-09-20T00:00:00Z | 1 | last purge + 10 days = 2026-09-30, due | | 2026-09-01T00:00:00Z | 2026-09-28T00:00:00Z | 0 | last purge + 10 days = 2026-10-08, not due | | 2026-09-28T00:00:00Z | 2026-09-01T00:00:00Z | 0 | enabled_at is later, 2026-10-08, not due | | 2026-09-20T00:00:00Z | 2026-09-01T00:00:00Z | 1 | enabled_at is later, 2026-09-30, due | | 2026-09-25T09:00:00Z | none | 1 | exactly now is due (2026-10-05T09:00:00Z) | | 2026-09-25T09:00:01Z | none | 0 | one second before due | Scenario: Due exactly at the boundary, not before Given the next purge is 2026-10-05T09:00:00Z When Tick is called at 2026-10-05T08:59:59Z Then RunPurge is not called When Tick is called at 2026-10-05T09:00:00Z Then RunPurge is called once Scenario: A run advances the anchor, so the next tick is not due Given a due run with RunPurge backed by the fake settings repository When Tick is called twice with the same time Then RunPurge is called once And last_purge_at equals the fake clock's time Scenario: A due run that finds nothing to purge still moves the clock forward Given a due schedule and a library with no planned group When Tick is called Then the stored last_purge_at is the clock's time, last_purge_merged is 0 and last_purge_articles_updated is 0 And a second Tick at the same time does not run And a Tick after the interval has elapsed runs again Scenario: A run keeps auto_purge_enabled, the interval and enabled_at Given a due schedule When Tick runs Then auto_purge_enabled, auto_purge_interval_days and enabled_at are unchanged in the stored settings Scenario: Enabled with no enabled_at is not due Given auto_purge_enabled is true and enabled_at is unset When Tick is called Then RunPurge is not called and the scheduler does not stamp enabled_at itself Rule: Settings changes take effect on the next tick, with no restart Background: Given the fake clock reads 2026-10-05T09:00:00Z Scenario: Shortening the interval makes a run due on the next tick Given auto_purge_enabled is true since 2026-09-25T09:00:00Z with interval 30 and no purge yet And Tick did not run When UpdatePurgeSettings sets auto_purge_interval_days to 7 And Tick is called again Then RunPurge is called once Scenario: Lengthening the interval postpones the run Given auto_purge_enabled is true since 2026-09-20T09:00:00Z with interval 10 and no purge yet, so a run is due When UpdatePurgeSettings sets auto_purge_interval_days to 30 And Tick is called Then RunPurge is not called Scenario: Turning it off stops runs Given a due schedule When UpdatePurgeSettings sets auto_purge_enabled to false And Tick is called Then RunPurge is not called Scenario: Turning it on resets the anchor to the moment of the switch Given auto_purge_enabled was turned off, last_purge_at is 2026-09-01T00:00:00Z, the interval is 30 and the clock reads 2026-10-05T09:00:00Z When UpdatePurgeSettings sets auto_purge_enabled to true And Tick is called at once Then RunPurge is not called, because enabled_at is now When Tick is called at 2026-11-04T09:00:00Z Then RunPurge is called once Scenario: The scheduler reads the settings at every tick When Tick is called three times with different stored settings Then Repository.Get is called three times and no value is cached between ticks Rule: Failures never crash the loop and are retried at the next tick Background: Given a due schedule and the fake clock reads 2026-10-05T09:00:00Z Scenario: Reading the settings fails Given the purge settings repository's next Get fails When Tick is called Then ran is false and err is the repository error And RunPurge was not called And the next Tick, with Get working again, runs the purge Scenario: RunPurge returns an error Given RunPurge fails, for example ListDuplicates fails When Tick is called Then err is that error and last_purge_at is unchanged And RunPurge was called once, not retried inside the same Tick And the next Tick, with the failure cleared, runs the purge Scenario: Saving the run result fails after the merges succeeded Given the purge settings repository's SaveLastRun fails When Tick is called Then err is returned and the merges stay done And the next Tick plans again from current data and converges Scenario: A run that partially fails still records what it merged Given group A merges and group B's UpdateBody fails When Tick is called Then ran is true and err is nil And last_purge_at is set and last_purge_merged and last_purge_articles_updated count only group A And group B is planned again by the next run Scenario: A settings change made during a run survives Given a run is in progress and about to record its result When UpdatePurgeSettings turns auto_purge_enabled off and sets the interval to 5 meanwhile And the run completes Then the stored auto_purge_enabled is false and the interval is 5 And last_purge_at, last_purge_merged and last_purge_articles_updated hold the run's values Scenario: A manual run is in progress Given a manual POST /v1/media/purge holds the RunPurge guard When Tick is called with a due schedule Then the scheduler does not queue or wait: it returns at once with errors.Is(err, purge.ErrPurgeBusy) And nothing is saved by the scheduler And the runner treats it as a skip, not a failure, and the next tick re-evaluates When the manual run finishes and records last_purge_at Then the next Tick finds the schedule not due and does not run Scenario: A failing tick does not stop the runner Given the runner is running with a hand-fed tick channel And the first Tick returns an error and the second returns ran true When two ticks are fed Then the error is written to the injected logger And the runner is still running and the second Tick was executed And no Tick is called between the two fed ticks (no tight retry loop) Scenario: A completed run logs one line with the counts Given Tick returns ran true with groups purged 3, merged 5, articles updated 2 and failed groups 1 When the runner receives the tick Then exactly one line is logged: "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1" And a run that found nothing to purge logs the same line with every count at 0 Scenario: A due Tick returns what the run did Given a due schedule and one planned group When Tick is called Then ran is true and the result holds the run's groups purged, merged and articles updated And a Tick that is not due returns an empty result Scenario: A busy skip is not logged as an error Given Tick returns ErrPurgeBusy When the runner receives the tick Then it logs no error line and keeps waiting for the next tick Rule: The runner starts, ticks and stops with the server Scenario: One Tick shortly after startup Given a runner with a hand-fed startup signal standing in for the 1 minute delay When the startup signal fires Then Tick is called once with the fake clock's time And no Tick is called before the signal Scenario: A due run is caught up at startup after downtime Given the server was down for 40 days, the interval is 30 and last_purge_at is 40 days old When the runner's startup Tick fires Then RunPurge is called once, and only once however many ticks were missed Scenario: A Tick on each hourly tick Given the runner is running When three hourly ticks are fed Then Tick is called three times, each with the fake clock's time at that tick Scenario: Cancelling the context stops the runner Given the runner is idle When its context is cancelled Then Run returns promptly And no further Tick is called even if a tick is fed after cancel Scenario: Shutdown waits for a run in progress without a time limit Given Tick is blocked inside RunPurge (fake image repository blocked on a channel) When the context is cancelled Then Run does not return yet When the blocked run is released and completes Then Run returns after the run has recorded last_purge_at And the database is closed only after Run has returned Scenario: Cancelled before the startup signal Given the context is cancelled before the startup signal Then Tick is never called and Run returns Rule: The Settings page shows the result (HTTP observable, no SPA change) Scenario: A scheduled run updates the Last purge values served by the settings endpoint Given an owner, automatic purge on with interval 7, enabled_at 2026-09-20T09:00:00Z and a planned group And the fake clock reads 2026-10-05T09:00:00Z When Tick runs And the client sends GET /v1/media/purge-settings with a valid cookie of the owner Then "last_purge_at" is "2026-10-05T09:00:00Z" And "last_purge_merged" and "last_purge_articles_updated" hold the run's counts And "next_purge_at" is "2026-10-12T09:00:00Z" And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are unchanged Scenario: A scheduled run with nothing to purge shows zeros, not "Never run" Given automatic purge on and due and no planned group When Tick runs and the client sends GET /v1/media/purge-settings Then "last_purge_at" is "2026-10-05T09:00:00Z", "last_purge_merged" is 0 and "last_purge_articles_updated" is 0 Scenario: A scheduled run answers no HTTP request and returns no message When Tick runs Then no HTTP handler is involved and the only observable results are the stored settings, the images, the article bodies and a log line Rule: Layering and documentation Scenario: The scheduler stays inside the domain boundary Then PurgeSchedulerUseCase imports only the stdlib, internal/domain/* and package-local use case types And internal/infrastructure/scheduler imports no persistence, mailer, models or gorm package And only api/dependecyInjection.go constructs it and only cmd/server/main.go starts it Scenario: The docs describe the scheduler and state that there is no env var Then docs/architecture.md describes the scheduler, the hourly tick, the startup tick, the due rule, the overlap guard and the unbounded shutdown wait And docs/configuration.md states that automatic purge has no env var and that its schedule lives in the database And no new Go, TS, docs or config file contains a comment @manual Rule: Wiring verified by hand (see the qa.md) Scenario: The runner starts with the real server and stops on SIGINT or SIGTERM Given the server runs and automatic purge is on and due When one minute elapses after startup Then a purge ran and the Settings "Last purge" line shows the new date on the next visit When SIGTERM is sent during a run Then the process exits only after the run finished, and the SQLite file is not corrupted