6.8 KiB
6.8 KiB
QA procedure: media_purge_scheduler
Package: test (black-box). New file test/media_purge_scheduler_test.go. Go only: no SPA. No env var. Fake type names must be unique in the package. Reuse part 1's mockPurgeSettingsRepo, mockImageRepo, mockArticleRepo, seedGroup and articleUsing from test/media_purge_test.go and test/media_purge_http_test.go. No real sleeping, no time.Ticker, no time.Sleep, no live server, no real SQLite file.
Setup shared by every case
validator.InitValidator()once (only needed by the HTTP cases).- Fakes:
fakeClock(mutable,Now()andSet(t)), starting at 2026-10-05T09:00:00Z. Passclock.Nowas thenow func() time.Timeof RunPurge and UpdatePurgeSettings so last_purge_at and enabled_at come from it.mockPurgeSettingsRepowithGet,SaveandSaveLastRun(writes only the last_purge_* fields), failure switches for each, a write counter shared by Save and SaveLastRun, and a hook called before SaveLastRun writes.- A
fakePurgeRunPortwrapping the realRunPurgeUseCase. It counts calls, records the fingerprint argument (must be nil), and can be armed to return an error. A second variant uses the real RunPurge directly for the integration cases. - Hand-fed
tickChandstartChchannels passed to the runner ininternal/infrastructure/schedulerin place of time.NewTicker and the startup delay. Use abytes.Bufferlogger.
- Wire the real
PurgeSchedulerUseCaseover the settings fake, the run port and the clock.
Tick decisions (table-driven)
- Off never runs: off with ancient dates; the defaults; assert ran false, err nil, zero RunPurge calls, zero Save calls.
- Not due, due, boundary: enabled_at + interval one second after now gives no run; exactly now gives a run; assert the fingerprint argument is nil.
- Anchor table: the six rows of the outline. Cross-check each row against
Settings.NextPurgeAt()to prove the scheduler reuses it. - Enabled with unset enabled_at never runs and the scheduler never stamps it.
- Repeat the same Tick twice with the real RunPurge: one run (anchor moved), and
last_purge_atequals the clock. Advance the clock past the interval: a third Tick runs. - Empty library: due Tick records
last_purge_at= clock, merged 0, articles_updated 0, and leaves auto_purge_enabled, interval and enabled_at untouched. - Settings changes: shorten the interval (via
UpdatePurgeSettingson the same fake) and a run becomes due; lengthen it and a due run is postponed; toggle off and no run; toggle off then on gives enabled_at = clock, no run now, a run once the clock passes +30 days. AssertGetis called once per Tick.
Failures
failGet: Tick returns the error, RunPurge count 0, the next Tick with the fake healed runs.- RunPurge error (
ListDuplicatesfailure): error returned,last_purge_atunchanged, one RunPurge call per Tick, recovery on the next Tick. SaveLastRunfailure after merges: error returned, merged images still gone, next Tick converges.- Partial failure (UpdateBody failing for group B): ran true, err nil,
last_purge_atset, counters count group A only. - Busy: block a manual
RunPurge.Run(nil)inside the image fake (started channel). Call Tick with a due schedule and assert it returns promptly (use a timeout) witherrors.Is(err, purge.ErrPurgeBusy), with noSavefrom the scheduler. Release; the next Tick is not due because the manual run recordedlast_purge_at. Run with-race.
Runner
- Feed a startup signal: exactly one Tick, with the clock's time; no Tick before the signal.
- Feed three ticks: three Ticks. Feed none: no Tick. Assert no Tick between fed ticks.
- Error then success from Tick: the error text is in the logger buffer, the loop continues, the second Tick executes, and
Runhas not returned. - Busy from Tick: no error line in the logger; the loop continues.
19b. Completed run: Tick returns ran true with a known
purge.RunResult; the logger buffer holds exactly one line with groups_purged, merged, articles_updated and failed_groups. An empty result logs zeros. 19c. Tick result: a due Tick returns the run result; a Tick that is not due returns the empty result. 19d. Lost update: block the settings fake just beforeSaveLastRunwrites, PATCH auto_purge_enabled off and the interval to 5 throughUpdatePurgeSettings, release; the stored auto_purge_* survive and last_purge_* are the run's. The persistence repository test checks thatSaveLastRunleaves the other columns untouched and creates the default row when none exists. - Cancel the context while idle:
Runreturns within a timeout; then feed a tick and assert no Tick happens. Cancel before the startup signal: no Tick at all. - Shutdown waits without a bound: block RunPurge in the image fake, cancel the context, assert
Runhas not returned (select with a short timeout), release, assertRunreturns andlast_purge_atwas recorded.-race. - The runner and the use case spawn no goroutine that outlives
Run(verify with a done channel closed on return).
HTTP-observable check (existing settings endpoint)
- Mount the part 1 media router (same setup as media_purge.qa.md) over the same fakes. Seed on, interval 7, enabled_at 2026-09-20T09:00:00Z, one planned group. Tick, then GET
/v1/media/purge-settingswith the owner cookie. Assertlast_purge_at2026-10-05T09:00:00Z, counts equal the run,next_purge_at2026-10-12T09:00:00Z, and the other fields unchanged. Repeat with an empty library: zeros and a non-nulllast_purge_at.
Layering guards
- The scheduler use case imports no
net/http, chi, gorm,persistence,mailerorapi/.... Same grep-style test as media_purge.qa.md.internal/infrastructure/schedulerimports nopersistence,mailer,modelsorgorm, and noapi/.... - Grep the new and edited files (Go, docs) for comments: none; only tool directives.
Docs
docs/architecture.mdhas a scheduler paragraph (hourly tick, startup tick, due rule fromNextPurgeAt, overlap guard from RunPurge, unbounded shutdown wait, failures retried at the next tick).docs/configuration.mdstates that there is no env var and that the schedule lives in the database. Read both files and confirm.
Manual (not automated)
- Real boot with
task dev:apior the built binary: turn automatic purge on, set interval 1 and backdateenabled_atin the SQLite file (or wait); about a minute after start, a purge runs,GET /v1/media/purge-settingsshows the newlast_purge_at, and Settings "Last purge" is updated on the next visit. - Send SIGINT or SIGTERM during a long run: the process exits only after the run ended, with no database error on close.
- A scheduled run and a manual "Purge now" at the same moment: one wins, the other answers 409 busy (HTTP) or is skipped (scheduler).
- Docker: the same behaviour in the container, with state on /data.