79 lines
11 KiB
Markdown
79 lines
11 KiB
Markdown
# QA procedure: media_purge
|
|
|
|
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
|
|
|
|
## Setup shared by every case
|
|
1. Call `validator.InitValidator()` once.
|
|
2. Fakes:
|
|
- `mockPurgeSettingsRepo` (domain `purge.Repository`): holds one `Settings`, `Get` returns the defaults when nothing was saved, `Save` records the call count, with armed failure switches `failGet` and `failSave`.
|
|
- Reuse or extend `mockImageRepo` (`test/image_test.go`): `ListDuplicates()` (images with a non-empty sha256 shared by at least two rows, ordered `sha256, id`), `Delete` with `failDeleteFor`, `Get`, `FindByHash`, `Counts`. Add a hook that lets a test block inside `ListDuplicates` or `Delete` on a channel, to hold a run in progress.
|
|
- Reuse or extend `mockArticleRepo` (`test/article_test.go`): `FindByFilters`, `FindByBodyContaining`, `UpdateBody` with a per-id failure switch.
|
|
- The user fake: a verified owner (id 1), a verified admin (id 2), a verified user with `Role("editor")` (id 3), a verified user with an empty role (id 5), and an unverified owner (id 4).
|
|
3. Wire the real use cases over the fakes: the four purge use cases (constructors chosen by the coder), `NewGetUserUseCase(userRepo)`, plus the existing media use cases. Inject a fixed clock (2026-10-05T09:00:00Z) and a `*log.Logger` writing to a `bytes.Buffer`. Add the four purge handler methods to the media handler, built with `media.NewHandler(...)`.
|
|
4. Build `jwtauth.New("HS256", []byte("test-secret"), nil)` and mount on a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`:
|
|
- `jwtauth.Verifier` at the top, then `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`.
|
|
- The purge routes inside `r.Group` with `middleware.RequireOwnerOrAdmin(getUser)`, registered BEFORE `Patch("/{id}")` and `Delete("/{id}")`.
|
|
- Also register `Get("/")`, `Post("/merge")`, `Patch("/{id}")` and `Delete("/{id}")` so route precedence is exercised.
|
|
- Mount under `/v1/media`.
|
|
5. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), "token_version": ...})` as in other HTTP tests. Build a stale-token cookie with a lower `token_version`.
|
|
6. Seeding helper `seedGroup(images ...)` assigning ids in order, strictly increasing `CreatedAt` unless the case says otherwise, and `articleUsing(id, images ...)` building bodies that contain each image's stored name (`image.StoredName(path)`).
|
|
7. Compare JSON bodies by decoding, except where the case says "raw body" (null versus `[]`, null versus absent). Error bodies are plain text: compare to `message + "\n"`.
|
|
|
|
## Auth and role checks (table-driven)
|
|
8. For each row of the "refuse callers" outline (4 routes x no cookie, stale, unverified, role "editor", empty role), assert the status and body. Assert the settings, image and article fakes saw no call (only the user lookup of the middleware).
|
|
9. Owner and admin cookies: GET `/v1/media/purge-settings` and GET `/v1/media/purge-preview` return a status other than 401 and 403.
|
|
10. Allowlist guard: add a case proving a role other than `owner`/`admin` (for example "OWNER" in upper case, or "superuser") is refused with 403, so the middleware is not a blocklist.
|
|
|
|
## Settings
|
|
11. Defaults: empty repo, GET returns the exact default body. Assert the raw JSON has `"last_purge_at":null`, `"enabled_at":null` and `"next_purge_at":null` (not omitted).
|
|
12. PATCH on: assert `enabled_at` equals the fixed clock, `next_purge_at` = enabled_at + 30 days, the GET after returns the same body, and `Save` was called once.
|
|
13. next_purge_at basis (max rule): last_purge_at later than enabled_at counts from last_purge_at; enabled_at later than last_purge_at counts from enabled_at; no last_purge_at counts from enabled_at.
|
|
14. Re-enable step: seed on with `last_purge_at` 2026-09-01, PATCH `false`, then PATCH `true` at the fixed clock with interval 30. Assert `enabled_at` is the clock, `last_purge_at` is still 2026-09-01, and `next_purge_at` is 2026-11-04T09:00:00Z, strictly after the clock.
|
|
15. Interval-only PATCH keeps `enabled_at`. PATCH `true` while already on keeps `enabled_at`. PATCH `false` nulls `next_purge_at` and keeps last_purge_*.
|
|
16. Interval table (1, 30, 365 accepted). Invalid table (0, 366, -1 give `apperr.PurgeIntervalErrorMessage`; 1.5, "7", "yes", not json give `apperr.BodyErrorMessage`). Assert the repo state and the `Save` count are unchanged after each 400.
|
|
17. `{}` PATCH returns 200 equal to the current settings.
|
|
18. Use-case-level: `UpdatePurgeSettings` with interval 0 and 366 returns an error with `errors.Is(err, purge.ErrInvalidPurgeInterval)` and no `Save`. At the handler, a value of 0 must be rejected by the validate tag and never reach the use case.
|
|
19. Failure rows: `failGet` gives 500 "Internal server error\n"; `failSave` on PATCH gives 500.
|
|
20. Route precedence: PATCH `/v1/media/purge-settings` reaches the settings handler and does not touch `Rename`.
|
|
|
|
## Preview
|
|
21. Build each Background scenario with the seed helpers and assert the decoded `groups`, `copies`, `articles`, `list` and `more`:
|
|
- used group (keep 2 with used_in 2; copies 2; articles 2);
|
|
- tie-breaks: same CreatedAt gives the lowest id, different CreatedAt gives the oldest (assert both orders by swapping ids);
|
|
- a single used copy with unused siblings (articles 0);
|
|
- an all-unused group;
|
|
- no duplicates (raw `"list":[]`);
|
|
- empty-hash images ignored;
|
|
- an article touching two groups counts once;
|
|
- an article using kept plus a copy counts once;
|
|
- 7 groups give 5 list entries in ascending keep_id order and `more` 2. Each entry has `keep_id`, `filename`, `url`, `used_in`, `merged`.
|
|
22. Preview purity: call twice, assert equal bodies, assert zero `Delete`, `UpdateBody` and `Save` calls.
|
|
23. Fingerprint table: compute a baseline, apply each change from the outline, and assert equal or different. Assert it is a 64-char lowercase hex string and that two empty plans share it.
|
|
|
|
## Run
|
|
24. Full run scenario: assert the response body, the remaining images, the rewritten article bodies (check bytes), and `Save` called once with `last_purge_at` = clock, `last_purge_merged` = 3 and `last_purge_articles_updated` = 1, and that `auto_purge_*` and `enabled_at` are untouched. Article 12 uses only kept copies, so only article 10 is rewritten.
|
|
25. `articles_updated` source: it must come from the union of `MergeMediaResult.UpdatedArticleIDs` returned by the group merges, never from the plan. Prove it with the rolled-back case in step 29: the plan contains B's articles, the response does not.
|
|
26. Used-sibling run with zero article rewrites (`articles_updated` 0, `UpdateBody` never called).
|
|
27. Fingerprint: match gives 200. A stale fingerprint gives 409 with `apperr.PurgeConflictErrorMessage + "\n"`, no `Delete`, no `UpdateBody`, no `Save`. A garbage string gives 409 with the same body. No fingerprint, `{}` and an empty body all run.
|
|
28. Empty plan: 200 zeros, `Save` with `last_purge_at` = clock and zero counters, and a run with the empty plan's fingerprint is also 200.
|
|
29. Partial failure, rolled back: arm `UpdateBody` to fail for the second article of group B (plan order A, B, C). Assert the body counters, B's bodies restored byte for byte, all B images present, A and C merged, B's articles absent from `articles_updated`, and `Save` counters equal the response.
|
|
30. Partial failure, delete: arm `failDeleteFor` for one of group A's merged images and plan B too. Assert `failed_groups` 1, `groups_purged` 1, A contributes 0 to `merged` and none of its ids to `articles_updated` (only B counts), A's article bodies stay switched to the kept copy, and a following preview plans A again. Run the purge again with the failure disarmed and assert it converges (A's leftover copy is merged away). Then arm every `UpdateBody` and assert `groups_purged` 0, `failed_groups` = `groups_total` and `last_purge_at` still saved.
|
|
31. Concurrency: start run 1 in a goroutine, blocked in the image fake (wait on a "started" channel). Send run 2 (once with a fingerprint, once without). Assert both answer 409 with `apperr.PurgeBusyErrorMessage + "\n"` immediately (use a timeout) and no `Save`/`Delete` calls from them. Release run 1, assert its 200 body and that a later run is accepted. Run this test with `-race`.
|
|
32. Sentinels at the use-case level, asserted separately: a stale fingerprint gives `errors.Is(err, purge.ErrPurgeConflict)` true and `errors.Is(err, purge.ErrPurgeBusy)` false; a busy guard gives `ErrPurgeBusy` true and `ErrPurgeConflict` false. Call RunPurge without any HTTP package to prove it is reusable (that test file section imports no `net/http`).
|
|
33. Malformed body gives 400 `apperr.BodyErrorMessage`. `ListDuplicates` failure gives 500 and no `Save`.
|
|
34. Persist failure: arm `failSave` on the run-result save after merges succeed. Assert 500 "Internal server error\n", the merged images are still gone, the logger buffer contains the error text, then disarm and run again and assert 200 with no remaining groups (convergence from current data).
|
|
35. Preview versus run parity: the run's `groups_total`, `merged` and `articles_updated` equal the preview's `groups`, `copies` and `articles` on the same seed.
|
|
36. MergeMedia contract: extend the existing merge tests only by adding assertions. `Merged` and `ArticlesUpdated` are unchanged, `UpdatedArticleIDs` lists the rewritten ids in ascending order, and a rolled-back merge returns the error and an empty result. Existing callers and tests must compile and pass unchanged.
|
|
|
|
## Layering guards
|
|
37. `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package. The new use cases import no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...` package. `internal/domain/purge` imports only the stdlib and other `internal/domain/*` packages. Use a `go list -deps` or grep-style test as in media_library.qa.md.
|
|
|
|
## Manual verification (not exercised by `task test`)
|
|
- AutoMigrate: boot against a scratch SQLite file (`task dev`) and check the purge settings table exists, GET returns the defaults, repeated PATCH keeps one row, and values survive a restart. The mapper round-trips `last_purge_at`/`enabled_at` including null.
|
|
- Real purge against real uploads: upload duplicates through `POST /v1/media/`, reference them from articles, run the preview then the purge. The merged files disappear from `UPLOAD_DIR`, the kept file loads through `/static/uploadedImages/`, and article bodies are switched.
|
|
- Concurrent real requests with two shells (`curl` with the `jwt` cookie): the second gets 409 "A purge is already running." while the first runs.
|
|
- Role check with a real admin account (invite then accept). Use a hand-edited user row with an empty or unknown role to check the 403.
|
|
- The persist-failure log line appears in the server output (check by making the database read-only during a purge).
|
|
- Docs: `docs/architecture.md` lists the `purge` domain package, the four use cases, the settings repository, the new routes and `RequireOwnerOrAdmin`. Read it and compare. No SMTP and no env var are involved.
|
|
- Out of scope: the scheduler (part 2) and the SPA (part 3).
|