11 KiB
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
- Call
validator.InitValidator()once. - Fakes:
mockPurgeSettingsRepo(domainpurge.Repository): holds oneSettings,Getreturns the defaults when nothing was saved,Saverecords the call count, with armed failure switchesfailGetandfailSave.- Reuse or extend
mockImageRepo(test/image_test.go):ListDuplicates()(images with a non-empty sha256 shared by at least two rows, orderedsha256, id),DeletewithfailDeleteFor,Get,FindByHash,Counts. Add a hook that lets a test block insideListDuplicatesorDeleteon a channel, to hold a run in progress. - Reuse or extend
mockArticleRepo(test/article_test.go):FindByFilters,FindByBodyContaining,UpdateBodywith 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).
- 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.Loggerwriting to abytes.Buffer. Add the four purge handler methods to the media handler, built withmedia.NewHandler(...). - Build
jwtauth.New("HS256", []byte("test-secret"), nil)and mount on a freshchi.NewRouter()mirroringNewMediaRouterinapi/router.go:jwtauth.Verifierat the top, thenAuthenticator,RequireCurrentToken(getUser),RequireVerifiedUser(getUser).- The purge routes inside
r.Groupwithmiddleware.RequireOwnerOrAdmin(getUser), registered BEFOREPatch("/{id}")andDelete("/{id}"). - Also register
Get("/"),Post("/merge"),Patch("/{id}")andDelete("/{id}")so route precedence is exercised. - Mount under
/v1/media.
- 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 lowertoken_version. - Seeding helper
seedGroup(images ...)assigning ids in order, strictly increasingCreatedAtunless the case says otherwise, andarticleUsing(id, images ...)building bodies that contain each image's stored name (image.StoredName(path)). - Compare JSON bodies by decoding, except where the case says "raw body" (null versus
[], null versus absent). Error bodies are plain text: compare tomessage + "\n".
Auth and role checks (table-driven)
- 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).
- Owner and admin cookies: GET
/v1/media/purge-settingsand GET/v1/media/purge-previewreturn a status other than 401 and 403. - 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
- Defaults: empty repo, GET returns the exact default body. Assert the raw JSON has
"last_purge_at":null,"enabled_at":nulland"next_purge_at":null(not omitted). - PATCH on: assert
enabled_atequals the fixed clock,next_purge_at= enabled_at + 30 days, the GET after returns the same body, andSavewas called once. - 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.
- Re-enable step: seed on with
last_purge_at2026-09-01, PATCHfalse, then PATCHtrueat the fixed clock with interval 30. Assertenabled_atis the clock,last_purge_atis still 2026-09-01, andnext_purge_atis 2026-11-04T09:00:00Z, strictly after the clock. - Interval-only PATCH keeps
enabled_at. PATCHtruewhile already on keepsenabled_at. PATCHfalsenullsnext_purge_atand keeps last_purge_*. - Interval table (1, 30, 365 accepted). Invalid table (0, 366, -1 give
apperr.PurgeIntervalErrorMessage; 1.5, "7", "yes", not json giveapperr.BodyErrorMessage). Assert the repo state and theSavecount are unchanged after each 400. {}PATCH returns 200 equal to the current settings.- Use-case-level:
UpdatePurgeSettingswith interval 0 and 366 returns an error witherrors.Is(err, purge.ErrInvalidPurgeInterval)and noSave. At the handler, a value of 0 must be rejected by the validate tag and never reach the use case. - Failure rows:
failGetgives 500 "Internal server error\n";failSaveon PATCH gives 500. - Route precedence: PATCH
/v1/media/purge-settingsreaches the settings handler and does not touchRename.
Preview
- Build each Background scenario with the seed helpers and assert the decoded
groups,copies,articles,listandmore:- 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
more2. Each entry haskeep_id,filename,url,used_in,merged.
- Preview purity: call twice, assert equal bodies, assert zero
Delete,UpdateBodyandSavecalls. - 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
- Full run scenario: assert the response body, the remaining images, the rewritten article bodies (check bytes), and
Savecalled once withlast_purge_at= clock,last_purge_merged= 3 andlast_purge_articles_updated= 1, and thatauto_purge_*andenabled_atare untouched. Article 12 uses only kept copies, so only article 10 is rewritten. articles_updatedsource: it must come from the union ofMergeMediaResult.UpdatedArticleIDsreturned 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.- Used-sibling run with zero article rewrites (
articles_updated0,UpdateBodynever called). - Fingerprint: match gives 200. A stale fingerprint gives 409 with
apperr.PurgeConflictErrorMessage + "\n", noDelete, noUpdateBody, noSave. A garbage string gives 409 with the same body. No fingerprint,{}and an empty body all run. - Empty plan: 200 zeros,
Savewithlast_purge_at= clock and zero counters, and a run with the empty plan's fingerprint is also 200. - Partial failure, rolled back: arm
UpdateBodyto 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 fromarticles_updated, andSavecounters equal the response. - Partial failure, delete: arm
failDeleteForfor one of group A's merged images and plan B too. Assertfailed_groups1,groups_purged1, A contributes 0 tomergedand none of its ids toarticles_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 everyUpdateBodyand assertgroups_purged0,failed_groups=groups_totalandlast_purge_atstill saved. - 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 noSave/Deletecalls from them. Release run 1, assert its 200 body and that a later run is accepted. Run this test with-race. - Sentinels at the use-case level, asserted separately: a stale fingerprint gives
errors.Is(err, purge.ErrPurgeConflict)true anderrors.Is(err, purge.ErrPurgeBusy)false; a busy guard givesErrPurgeBusytrue andErrPurgeConflictfalse. Call RunPurge without any HTTP package to prove it is reusable (that test file section imports nonet/http). - Malformed body gives 400
apperr.BodyErrorMessage.ListDuplicatesfailure gives 500 and noSave. - Persist failure: arm
failSaveon 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). - Preview versus run parity: the run's
groups_total,mergedandarticles_updatedequal the preview'sgroups,copiesandarticleson the same seed. - MergeMedia contract: extend the existing merge tests only by adding assertions.
MergedandArticlesUpdatedare unchanged,UpdatedArticleIDslists 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
api/handlers/mediaimports nopersistence,mailer,modelsorgormpackage. The new use cases import nonet/http, chi, gorm,persistence,mailerorapi/...package.internal/domain/purgeimports only the stdlib and otherinternal/domain/*packages. Use ago list -depsor 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-tripslast_purge_at/enabled_atincluding 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 fromUPLOAD_DIR, the kept file loads through/static/uploadedImages/, and article bodies are switched. - Concurrent real requests with two shells (
curlwith thejwtcookie): 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.mdlists thepurgedomain package, the four use cases, the settings repository, the new routes andRequireOwnerOrAdmin. Read it and compare. No SMTP and no env var are involved. - Out of scope: the scheduler (part 2) and the SPA (part 3).