287 lines
15 KiB
Gherkin
287 lines
15 KiB
Gherkin
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 <enabled_at> and last_purge_at is <last_purge_at>
|
|
When Tick is called at 2026-10-05T09:00:00Z
|
|
Then RunPurge is called <calls> 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
|