Files
RenewCMS/features/media_purge_scheduler.qa.md
T

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

  1. validator.InitValidator() once (only needed by the HTTP cases).
  2. Fakes:
    • fakeClock (mutable, Now() and Set(t)), starting at 2026-10-05T09:00:00Z. Pass clock.Now as the now func() time.Time of RunPurge and UpdatePurgeSettings so last_purge_at and enabled_at come from it.
    • mockPurgeSettingsRepo with Get, Save and SaveLastRun (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 fakePurgeRunPort wrapping the real RunPurgeUseCase. 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 tickCh and startCh channels passed to the runner in internal/infrastructure/scheduler in place of time.NewTicker and the startup delay. Use a bytes.Buffer logger.
  3. Wire the real PurgeSchedulerUseCase over the settings fake, the run port and the clock.

Tick decisions (table-driven)

  1. Off never runs: off with ancient dates; the defaults; assert ran false, err nil, zero RunPurge calls, zero Save calls.
  2. 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.
  3. Anchor table: the six rows of the outline. Cross-check each row against Settings.NextPurgeAt() to prove the scheduler reuses it.
  4. Enabled with unset enabled_at never runs and the scheduler never stamps it.
  5. Repeat the same Tick twice with the real RunPurge: one run (anchor moved), and last_purge_at equals the clock. Advance the clock past the interval: a third Tick runs.
  6. Empty library: due Tick records last_purge_at = clock, merged 0, articles_updated 0, and leaves auto_purge_enabled, interval and enabled_at untouched.
  7. Settings changes: shorten the interval (via UpdatePurgeSettings on 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. Assert Get is called once per Tick.

Failures

  1. failGet: Tick returns the error, RunPurge count 0, the next Tick with the fake healed runs.
  2. RunPurge error (ListDuplicates failure): error returned, last_purge_at unchanged, one RunPurge call per Tick, recovery on the next Tick.
  3. SaveLastRun failure after merges: error returned, merged images still gone, next Tick converges.
  4. Partial failure (UpdateBody failing for group B): ran true, err nil, last_purge_at set, counters count group A only.
  5. 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) with errors.Is(err, purge.ErrPurgeBusy), with no Save from the scheduler. Release; the next Tick is not due because the manual run recorded last_purge_at. Run with -race.

Runner

  1. Feed a startup signal: exactly one Tick, with the clock's time; no Tick before the signal.
  2. Feed three ticks: three Ticks. Feed none: no Tick. Assert no Tick between fed ticks.
  3. Error then success from Tick: the error text is in the logger buffer, the loop continues, the second Tick executes, and Run has not returned.
  4. 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 before SaveLastRun writes, PATCH auto_purge_enabled off and the interval to 5 through UpdatePurgeSettings, release; the stored auto_purge_* survive and last_purge_* are the run's. The persistence repository test checks that SaveLastRun leaves the other columns untouched and creates the default row when none exists.
  5. Cancel the context while idle: Run returns within a timeout; then feed a tick and assert no Tick happens. Cancel before the startup signal: no Tick at all.
  6. Shutdown waits without a bound: block RunPurge in the image fake, cancel the context, assert Run has not returned (select with a short timeout), release, assert Run returns and last_purge_at was recorded. -race.
  7. 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)

  1. 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-settings with the owner cookie. Assert last_purge_at 2026-10-05T09:00:00Z, counts equal the run, next_purge_at 2026-10-12T09:00:00Z, and the other fields unchanged. Repeat with an empty library: zeros and a non-null last_purge_at.

Layering guards

  1. The scheduler use case imports no net/http, chi, gorm, persistence, mailer or api/.... Same grep-style test as media_purge.qa.md. internal/infrastructure/scheduler imports no persistence, mailer, models or gorm, and no api/....
  2. Grep the new and edited files (Go, docs) for comments: none; only tool directives.

Docs

  1. docs/architecture.md has a scheduler paragraph (hourly tick, startup tick, due rule from NextPurgeAt, overlap guard from RunPurge, unbounded shutdown wait, failures retried at the next tick). docs/configuration.md states that there is no env var and that the schedule lives in the database. Read both files and confirm.

Manual (not automated)

  1. Real boot with task dev:api or the built binary: turn automatic purge on, set interval 1 and backdate enabled_at in the SQLite file (or wait); about a minute after start, a purge runs, GET /v1/media/purge-settings shows the new last_purge_at, and Settings "Last purge" is updated on the next visit.
  2. Send SIGINT or SIGTERM during a long run: the process exits only after the run ended, with no database error on close.
  3. A scheduled run and a manual "Purge now" at the same moment: one wins, the other answers 409 busy (HTTP) or is skipped (scheduler).
  4. Docker: the same behaviour in the container, with state on /data.