Files

11 KiB

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)

  1. 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).
  2. Owner and admin cookies: GET /v1/media/purge-settings and GET /v1/media/purge-preview return a status other than 401 and 403.
  3. 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

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. Interval-only PATCH keeps enabled_at. PATCH true while already on keeps enabled_at. PATCH false nulls next_purge_at and keeps last_purge_*.
  6. 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.
  7. {} PATCH returns 200 equal to the current settings.
  8. 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.
  9. Failure rows: failGet gives 500 "Internal server error\n"; failSave on PATCH gives 500.
  10. Route precedence: PATCH /v1/media/purge-settings reaches the settings handler and does not touch Rename.

Preview

  1. 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.
  2. Preview purity: call twice, assert equal bodies, assert zero Delete, UpdateBody and Save calls.
  3. 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

  1. 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.
  2. 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.
  3. Used-sibling run with zero article rewrites (articles_updated 0, UpdateBody never called).
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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).
  10. Malformed body gives 400 apperr.BodyErrorMessage. ListDuplicates failure gives 500 and no Save.
  11. 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).
  12. 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.
  13. 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

  1. 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).