refac: atomic merge, lost-update race, RunPurge cost, duplicated walks
This commit is contained in:
1 parent
0fc3511e2b
commit
074944c99a
45 files changed
+2201
-264
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
domainTransaction "RenewCMS/internal/domain/transaction"
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/auditing"
|
||||
@@ -160,6 +161,7 @@ type Repositories struct {
|
||||
AuthorRepo domainAuthor.Repository
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
Transactor domainTransaction.Transactor
|
||||
VariantRepo domainImage.VariantRepository
|
||||
VariantFileRepo domainImage.VariantFileRepository
|
||||
VariantCacheRepo domainImage.VariantCacheRepository
|
||||
@@ -302,6 +304,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
Transactor: persistence.NewTransactor(db),
|
||||
VariantRepo: persistence.NewVariantRepository(db),
|
||||
VariantFileRepo: variantFiles,
|
||||
VariantCacheRepo: variantFiles,
|
||||
@@ -332,7 +335,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
|
||||
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
|
||||
|
||||
@@ -363,11 +363,11 @@ Feature: The media library holds every uploaded image as a library-level asset
|
||||
And article 1's body is back to its original text, article 2 is unchanged
|
||||
And images 2 and 3 still exist
|
||||
|
||||
Scenario: A failure deleting a copy after the rewrite is 500 and the bodies stay switched
|
||||
Scenario: A failure deleting a copy after the rewrite is 500 and the bodies are rolled back
|
||||
Given the image repository's Delete fails for image 3
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
|
||||
Then the response status is 500
|
||||
And article 1's and article 2's bodies use image 2's url and image 3 still exists
|
||||
And article 1's and article 2's bodies are back to their original text and image 3 still exists
|
||||
|
||||
@manual
|
||||
Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md)
|
||||
|
||||
@@ -26,7 +26,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
|
||||
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
|
||||
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
|
||||
15. Usage: assert the exact JSON arrays (raw body compared as JSON), the empty `[]`, the absolute-url article, 404 and 400.
|
||||
16. Merge: assert 200 `{articles_updated, merged}`, resulting bodies (both occurrences replaced), unchanged bodies for the other articles, and only image 2 left. Assert the 409 rows and 404 row, with no body change and no `Delete` calls (`deleteCalls` empty). Assert every 400 row with `apperr.MediaMergeRequestErrorMessage`. Assert the two failure rows: `UpdateBody` failing for article 2 leaves article 1 restored and no `Delete` call; `Delete` failing for image 3 leaves bodies switched and returns 500.
|
||||
16. Merge: assert 200 `{articles_updated, merged}`, resulting bodies (both occurrences replaced), unchanged bodies for the other articles, and only image 2 left. Assert the 409 rows and 404 row, with no body change and no `Delete` calls (`deleteCalls` empty). Assert every 400 row with `apperr.MediaMergeRequestErrorMessage`. Assert the two failure rows: `UpdateBody` failing for article 2 leaves article 1 restored and no `Delete` call; `Delete` failing for image 3 returns 500 and rolls the bodies back to their original text, with image 3 still present.
|
||||
17. Unit-level asserts (same file, no HTTP) with `errors.Is` on the sentinels `ErrImageUnreadable`, `ErrMergeCopyMissing`, `ErrMergeHashMismatch`, `ErrInvalidFilename`.
|
||||
18. Layering guard: `go list -deps` or a grep-style test that `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package.
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
Feature: Media merge hardening: atomic merge, no lost entry edits, one entry scan per purge
|
||||
|
||||
As a site owner or admin
|
||||
I want a duplicate merge to either fully happen or not happen at all
|
||||
So that a failure halfway never leaves articles, sites, authors, entries or the library half merged
|
||||
|
||||
Background:
|
||||
Given this slice is backend only: no route, request body, response body, status code, error text or audit payload changes
|
||||
And internal/domain gains a unit-of-work port (package transaction): Transactor with Run(ctx, func(Repositories) error) error, and Repositories holding the article, site, author, entry and image repositories bound to the open transaction
|
||||
And internal/domain/transaction imports only the stdlib and other internal/domain/* packages
|
||||
And Run commits when the function returns nil and rolls everything back when it returns an error or panics, and returns that error unchanged
|
||||
And the GORM adapter lives in internal/infrastructure/persistence, asserts var _ transaction.Transactor = &X{}, and is built only in api/dependecyInjection.go
|
||||
And SQL in the adapter stays portable across SQLite, MySQL/MariaDB and PostgreSQL
|
||||
And MergeMedia and RunPurge depend on transaction.Transactor and never on persistence, gorm or models
|
||||
And MergeMedia keeps its order: validate, load copies, plan which articles contain a copy name, then inside one transaction re-read each planned article body, apply the replacement of the copy names by the kept name to that re-read body and UpdateBody it, articles RepointImage, sites RepointImage, authors RepointAvatar, entry repoint and image row delete per copy
|
||||
And restoreBodies, the entry restore of entryMediaScanner and every logCompensation call of the merge are deleted; nothing compensates by hand any more
|
||||
And the stored file, the variant cache files and the variant cache rows of a merged copy are removed only after the transaction committed, through image.Storage and the variant file repository
|
||||
And a failure removing those files after the commit is written to the injected *log.Logger, never rolled back, never changes the response or the audit event
|
||||
And the audit event media.merge and the audit event media.purge are recorded outside the transaction, with the same params as today
|
||||
And entries are repointed from candidates found by one scan (contenttype.Repository.List then entry.Repository.ListPage with Unpaged, filtered in Go), and each candidate is re-read with entry.Repository.Get inside the transaction before it is written
|
||||
And an entry repoint only changes the media values that still point at a merged copy in the re-read data, keeps every other value of the re-read data, and leaves created_by, updated_by, created_at and updated_at untouched
|
||||
And a candidate that no longer exists at re-read time is skipped
|
||||
And RunPurge and PreviewPurge scan entries once per run and once per preview into an index of image id to entries, shared by the usage counts and by every duplicate group; the index is built in the use case and no repository method is added
|
||||
And RunPurge runs one transaction per duplicate group, so a failing group rolls back alone and the other groups still merge
|
||||
And the replacement of a copy name inside an article body matches whole stored file names only, so a name that is a suffix of another name is not rewritten inside it
|
||||
And the media-field walks (field-type loops, the required and type rule constants, the entry.InvalidField wrapper) are deduplicated into one place; this is a pure refactor and has no scenario of its own beyond "behaviour unchanged"
|
||||
And MergeMediaResult, purge.RunResult, the merge response {"articles_updated","merged"} (plus sites_updated and authors_updated where the sites and authors features already report them) and the 409, 404, 400 and 500 answers are unchanged
|
||||
And fixtures: images 7, 8 and 9 share sha256 A with stored names; image 2 and 3 share sha256 B; the type "event" has title (text, required) then thumbnail (media, optional); users: admin 2 (verified)
|
||||
|
||||
Rule: A merge is all-or-nothing
|
||||
|
||||
Scenario Outline: Any failing step leaves the library exactly as it was
|
||||
Given images 7 and 9 share sha256 A and 7 is the kept one
|
||||
And article 1 and article 2 contain the stored name of image 9 in their bodies, article 3 has image 9 as its cover
|
||||
And site 1 uses image 9 as its default social image and author 4 uses image 9 as its avatar
|
||||
And entries 41 and 42 of "event" hold thumbnail 9
|
||||
And <failing step> is armed to fail
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And the bodies of articles 1 and 2 are byte-identical to before
|
||||
And article 3 still has cover 9, site 1 still uses 9, author 4 still uses avatar 9
|
||||
And entries 41 and 42 still hold thumbnail 9 with updated_at and updated_by unchanged
|
||||
And images 7 and 9 both still exist
|
||||
And image.Storage.Delete and the variant file repository Delete are never called
|
||||
And one failed media.merge audit event is recorded
|
||||
|
||||
Examples:
|
||||
| failing step |
|
||||
| the UpdateBody of the first article |
|
||||
| the UpdateBody of the second article |
|
||||
| the article repository RepointImage |
|
||||
| the site repository RepointImage |
|
||||
| the author repository RepointAvatar |
|
||||
| the entry repository Get of the second entry |
|
||||
| the entry repository Update of the second entry |
|
||||
| the image repository Delete of image 9 |
|
||||
| the commit of the transaction |
|
||||
|
||||
Scenario: The transaction cannot start
|
||||
Given the transactor fails to begin
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 500
|
||||
And no repository other than the reads of the planning phase was called, and no file was touched
|
||||
|
||||
Scenario: Several copies roll back together
|
||||
Given images 7, 8 and 9 share sha256 A and the image repository Delete fails for image 9 after image 8 was deleted
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [8, 9]}
|
||||
Then the response status is 500
|
||||
And image 8 exists again, image 9 exists and no article, site, author or entry changed
|
||||
|
||||
Scenario: A successful merge still does everything
|
||||
Given the Background fixtures of the first scenario and no armed failure
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 200 and the body is {"articles_updated": 3, "merged": 1} plus the existing sites_updated and authors_updated keys
|
||||
And entries 41 and 42 hold thumbnail 7 with created_by, updated_by and updated_at unchanged
|
||||
And image 9 is gone from the repository and its stored file is removed
|
||||
|
||||
Scenario: A validation or lookup error never opens a transaction
|
||||
Given any of the existing 400, 404 or 409 merge requests
|
||||
Then the transactor Run is not called
|
||||
|
||||
Scenario: A rollback leaves updated_at and updated_by of entries untouched
|
||||
Given the entries 41 and 42 hold thumbnail 9 with known created_at, updated_at, created_by and updated_by
|
||||
And the repoint of entry 42 fails
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 500
|
||||
And entries 41 and 42 are byte-identical to before, including updated_at and updated_by
|
||||
|
||||
Rule: Stored files are removed only after the commit
|
||||
|
||||
Scenario: Files are touched after the commit and never before
|
||||
Given a merge of image 9 into image 7 that succeeds
|
||||
Then image.Storage.Delete("<stored name of 9>") is called after the transaction committed
|
||||
And the variant files of image 9 are removed after the commit
|
||||
And at no time before the commit is a file or a variant cache row of image 9 removed
|
||||
|
||||
Scenario Outline: A file failure after the commit is logged, not rolled back
|
||||
Given a merge of the copies 8 and 9 into image 7 where <file failure>
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [8, 9]}
|
||||
Then the response status is 200 and the body is the same as without the failure
|
||||
And images 8 and 9 are gone from the repository and the articles, sites, authors and entries stay repointed
|
||||
And the removal of the other copy is still attempted
|
||||
And the injected logger holds one line naming the stored name and the error
|
||||
And the media.merge audit event is a success with the usual params
|
||||
|
||||
Examples:
|
||||
| file failure |
|
||||
| image.Storage.Delete fails for image 9 |
|
||||
| the variant file purge of image 9 fails |
|
||||
|
||||
Scenario: A purge group with a file failure after the commit counts as purged
|
||||
Given groups A and B are planned and image.Storage.Delete fails for a merged copy of group A
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200 and the body has groups_purged 2 and failed_groups 0
|
||||
And the merged count includes the copies of group A
|
||||
And the injected logger holds the file failure
|
||||
|
||||
Rule: A concurrent entry edit survives a merge
|
||||
|
||||
Scenario: An edit made after the scan is kept
|
||||
Given entry 41 of "event" is {"title": "Workshop", "thumbnail": 9}
|
||||
And the entry repository changes entry 41 to {"title": "Workshop 2026", "thumbnail": 9}, with a new updated_at and updated_by, right after the scan returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 200
|
||||
And entry 41 is {"title": "Workshop 2026", "thumbnail": 7}
|
||||
And its updated_at and updated_by are the ones of the concurrent edit, not older ones
|
||||
|
||||
Scenario: A concurrent edit that moved the entry off the merged copy is not overwritten
|
||||
Given entry 41 holds thumbnail 9 when scanned
|
||||
And the entry repository changes entry 41 to thumbnail 8 right after the scan returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then entry 41 still holds thumbnail 8
|
||||
And the entry repository Update is not called for entry 41
|
||||
|
||||
Scenario: An entry deleted after the scan is skipped
|
||||
Given entry 41 holds thumbnail 9 when scanned
|
||||
And entry 41 is deleted right after the scan returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 200 and image 9 is gone
|
||||
|
||||
Scenario: Entries are written from the re-read data
|
||||
Given entry 41 holds {"title": "A", "thumbnail": 9, "poster": 9} when scanned
|
||||
When the merge runs
|
||||
Then entry 41 holds thumbnail 7 and poster 7 and the same title
|
||||
And the entry repository Update is called once for entry 41
|
||||
|
||||
Rule: A concurrent article edit survives a merge
|
||||
|
||||
Scenario: A body edited after planning is kept
|
||||
Given article 1 contains the stored name of image 9 and the planning has read its body
|
||||
And the article repository changes article 1 to the same text plus a new paragraph, right after the planning read returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 200
|
||||
And article 1's body holds the new paragraph and the stored name of image 7 instead of image 9
|
||||
|
||||
Scenario: A body edited so that it no longer holds the copy name is not rewritten
|
||||
Given article 1 contains the stored name of image 9 when planned
|
||||
And the article repository changes article 1 to a text without the stored name of image 9 right after the planning read returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then article 1's body is the edited text and the article repository UpdateBody is not called for article 1
|
||||
And article 1 is not counted in "articles_updated"
|
||||
|
||||
Scenario: An article deleted after planning is skipped
|
||||
Given article 1 contains the stored name of image 9 when planned
|
||||
And article 1 is deleted right after the planning read returns it
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then the response status is 200 and image 9 is gone
|
||||
|
||||
Scenario: Only whole stored names are replaced
|
||||
Given image 9 has the stored name "a.jpg" and article 1 contains "ba.jpg" and "a.jpg"
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 7, "merge_ids": [9]}
|
||||
Then article 1 still contains "ba.jpg" and no longer contains the standalone "a.jpg"
|
||||
|
||||
Rule: A cancelled purge stops cleanly
|
||||
|
||||
Scenario: A request cancelled between groups stops the run
|
||||
Given groups A, B and C are planned and the request context is cancelled after group A committed
|
||||
When the purge runs
|
||||
Then group A stays merged and groups B and C are untouched
|
||||
And the run returns the context error and records no last_purge_* values and no media.purge success event
|
||||
And failed_groups is not incremented for B and C
|
||||
|
||||
Scenario: The scheduler passes its context to the run
|
||||
Given the scheduler context is cancelled while a scheduled purge is between groups
|
||||
Then the run stops after the group in progress and records no last_purge_* values
|
||||
|
||||
Rule: A purge scans entries once
|
||||
|
||||
Scenario: A preview over many groups scans once
|
||||
Given 6 planned duplicate groups, 40 entries and 2 content types with a media field
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then contenttype.Repository.List is called once
|
||||
And entry.Repository.ListPage is called once per content type with a media field, 2 times in all
|
||||
And the response is the same as before this slice
|
||||
|
||||
Scenario: A run over many groups scans once
|
||||
Given the same 6 groups, 40 entries and 2 content types with a media field
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then contenttype.Repository.List is called once
|
||||
And entry.Repository.ListPage is called 2 times in all, not once per group
|
||||
And the response and the stored last_purge_* values are the same as before this slice
|
||||
|
||||
Scenario: The index is stale after a group but the result is right
|
||||
Given entry 41 holds thumbnail 2 and poster 5, where 2 is a copy of group A and 5 is a copy of group B
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then entry 41 holds the kept image of group A as thumbnail and the kept image of group B as poster
|
||||
And entry.Repository.Update is called twice for entry 41
|
||||
|
||||
Scenario: The usage counts and the candidates come from the same scan
|
||||
Given the duplicate group 7, 9 where only 9 is used, by 1 entry
|
||||
When the purge preview and the purge run
|
||||
Then 9 is the kept image and 7 is the merged copy
|
||||
And there is one scan per call
|
||||
|
||||
Rule: A purge run commits one group at a time
|
||||
|
||||
Scenario Outline: A failing group rolls back alone
|
||||
Given groups A, B and C are planned, in that order
|
||||
And <failure> is armed for group B
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 3, "groups_purged": 2, "merged": <copies of A and C>, "articles_updated": <distinct articles of A and C>, "failed_groups": 1}
|
||||
And all of group B's article bodies, entries, sites, authors and images are back to their original state
|
||||
And groups A and C are merged
|
||||
And the stored last_purge_merged and last_purge_articles_updated equal the response totals
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| the UpdateBody of the second article |
|
||||
| the article repository RepointImage |
|
||||
| the entry repository Update |
|
||||
| the image repository Delete of a merged copy |
|
||||
|
||||
Scenario: Every group failing is still a 200
|
||||
Given every group is armed to fail
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And groups_purged is 0 and failed_groups equals groups_total
|
||||
And no image, body, entry, site or author changed and the stored last_purge_at is set
|
||||
|
||||
Scenario: The run opens one transaction per planned group
|
||||
Given 3 planned groups
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the transactor Run is called 3 times
|
||||
|
||||
Rule: Behaviour unchanged (the media-field walk refactor)
|
||||
|
||||
Scenario: The shared walk changes nothing
|
||||
Given the media-field rule constants, the InvalidField wrapper and the walks over a content type's media fields live in one place
|
||||
Then every scenario of features/media_field_type.feature, media_library.feature and media_purge.feature passes unchanged, except the two amended below
|
||||
|
||||
Rule: Amendments to earlier features
|
||||
|
||||
Scenario: media_library.feature "A failure deleting a copy after the rewrite is 500 and the bodies are rolled back" is amended
|
||||
Given the image repository's Delete fails for image 3
|
||||
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
|
||||
Then the response status is 500
|
||||
And article 1's and article 2's bodies are back to their original text and image 3 still exists
|
||||
|
||||
Scenario: media_purge.feature "A group that fails after rewriting bodies" is amended
|
||||
Given the image repository's Delete fails for a merged image of group A, and group B is planned
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1
|
||||
And group A contributes 0 to "merged" and none of its article ids to "articles_updated"
|
||||
And group A's article bodies are back to their original text
|
||||
And a following GET /v1/media/purge-preview plans group A again, and the next run converges
|
||||
|
||||
@manual
|
||||
Rule: Persistence (verified by hand, see the qa.md)
|
||||
|
||||
Scenario: The GORM adapter rolls back on SQLite, MySQL/MariaDB and PostgreSQL
|
||||
Given duplicates with articles, a site, an author and entries pointing at a copy
|
||||
When a merge fails at the image delete on each driver
|
||||
Then no row changed on any of the three drivers
|
||||
@@ -0,0 +1,80 @@
|
||||
# QA procedure: media_merge_hardening
|
||||
|
||||
Package: `test` (black-box). New file `test/media_merge_hardening_test.go`. Go only: no SPA, no env var, no live server, no real SQLite file, no real SMTP. Fake type names must be unique in the package.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once per test.
|
||||
2. Reuse the fakes of the media tests and the purge tests: `mockImageRepo`, `mockArticleRepo`, the site, author and storage fakes, the variant file fake, the audit recorder fake, `mockContentTypeRepo`, `mockEntryRepo`, `mockPurgeSettingsRepo`, and the helpers `seedGroup` and `articleUsing`.
|
||||
3. New fake `mockMergeTransactor` (implements `transaction.Transactor`):
|
||||
- It holds the fakes of step 2 and hands them to the function as the `Repositories` struct. In-memory fakes need no real binding to the transaction.
|
||||
- Rollback emulation: before calling the function it snapshots the state of every fake it holds (a deep copy of their maps), and it restores the snapshots when the function returns an error. Counters (`updateCalls`, `getCalls`, `deleteCalls`) are NOT restored, so tests can still assert that calls happened.
|
||||
- Switches: `failBegin`, `failCommit`. `failCommit` restores the snapshots and returns the error after the function returned nil.
|
||||
- Fields: `runCalls`, and a `committed bool` that is true only between the commit and the next Run.
|
||||
- Each fake gains `snapshot()` and `restore()` helpers in the test package only; no production code change.
|
||||
4. Fake extensions (no new method on a domain interface):
|
||||
- `mockEntryRepo`: `listPageCalls`, `getCalls`, `failUpdateOn(n)` (already exists), `failGetOn(n)`, and `afterListPage func()` called right after `ListPage` builds its result, used to mutate or delete an entry after the scan.
|
||||
- `mockContentTypeRepo`: `listCalls`.
|
||||
- `mockArticleRepo`: failure switches for `UpdateBody` (per id and per call number) and `RepointImage`.
|
||||
- Site and author fakes: failure switches for `RepointImage` and `RepointAvatar`.
|
||||
- `mockImageRepo`: `Delete` with `failDeleteFor`.
|
||||
- Storage fake: records `Delete` calls, with a failure switch per stored name; each record notes `transactor.committed` at call time.
|
||||
- Variant file fake: the same recording and failure switch.
|
||||
5. Build `NewMergeMediaUseCase(...)` with the transactor, storage, variant files, recorder, the fakes, an injected `*log.Logger` over a `bytes.Buffer`, and `NewRunPurgeUseCase` / `NewPreviewPurgeUseCase` over the same fakes. The constructors are chosen by the coder. Mount the media handler on a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in api/router.go, with the media_purge.qa.md setup, `jwtauth.New("HS256", []byte("test-secret"), nil)` and an admin 2 cookie. Compare error bodies as `message + "\n"`.
|
||||
6. Seed: images 7, 8 and 9 (same sha256 A), 2 and 3 (sha256 B). The type "event" has title and thumbnail (media, optional), plus a second type with two media fields (thumbnail, poster) for the walk and index cases. Seed articles, a site and an author that point at the copies. Inject a fixed clock so entry `updated_at` is known.
|
||||
7. Snapshot helper `libraryState()` deep-copying every fake. Assert "unchanged" with `reflect.DeepEqual` of before and after.
|
||||
|
||||
## All-or-nothing
|
||||
8. Failing step table (the 9 rows of the first outline), one subtest per row. Arm the failure, POST `/v1/media/merge` with keep 7 and merge [9]. Assert 500 and "Internal server error\n". Assert `libraryState()` equals the state before, including bodies, cover ids, site and author references, entries with `updated_at` and `updated_by`, and both image rows. Assert the storage fake and the variant fake recorded no `Delete` call. Assert exactly one failed `media.merge` audit event. Use `failCommit` for the commit row.
|
||||
9. `failBegin`: 500, no write call on any fake, no file call.
|
||||
10. Two copies (8 and 9), `failDeleteFor(9)` after 8 was deleted: 500, image 8 exists again after rollback, all else unchanged.
|
||||
11. Success path: 200 and the unchanged body, entries 41 and 42 hold 7 with `created_by`, `updated_by`, `created_at` and `updated_at` unchanged, image 9 gone, the storage fake got `Delete`, and the audit event is a success with `merged` and `articles_updated`.
|
||||
12. Validation, lookup and 409 requests (all existing 400, 404, 409 cases): `runCalls == 0`.
|
||||
13. Rollback and `updated_at` / `updated_by`: arm `failUpdateOn(2)` on the entry fake, assert both entries are `DeepEqual` to the seed.
|
||||
|
||||
## Files after commit
|
||||
14. Success: the storage fake `Delete` record has `committed == true` for every call; the variant fake records likewise. Failure rows of step 8: no records at all.
|
||||
15. File failure table (storage `Delete` fails for 9; variant purge fails for 9) with copies 8 and 9: 200 and the same body as the no-failure run, images gone, repoints kept, the other copy's removal still attempted, the log buffer holds one line with the stored name and the error text, the audit event is a success with the usual params.
|
||||
16. Purge group with a storage failure after commit: 200, `groups_purged` 2, `failed_groups` 0, merged count includes the copies of group A, the log buffer holds the failure.
|
||||
|
||||
## Concurrent entry edit
|
||||
17. Edit after the scan: set `afterListPage` to rewrite entry 41 to a new title with new `updated_at` and `updated_by`. After the merge, entry 41 has the new title, thumbnail 7 and the edit's `updated_at` and `updated_by`.
|
||||
18. Edit that moves the entry to image 8 after the scan: entry 41 still holds 8 and the entry fake saw no `Update` for 41.
|
||||
19. Entry deleted after the scan: 200, image 9 gone, no error.
|
||||
20. Re-read data: an entry with thumbnail 9 and poster 9: both become 7, the title is kept, `Update` is called once for that entry.
|
||||
|
||||
## Concurrent article edit
|
||||
17a. `mockArticleRepo` gains `afterFindByBody func()` called right after `FindByBodyContaining` builds its result. Edit after planning: article 1 gets an extra paragraph; after the merge it holds the paragraph and the kept stored name. Edit removing the copy name: no `UpdateBody` for article 1, not in `articles_updated`. Article deleted after planning: 200, image 9 gone. Suffix collision (`a.jpg` inside `ba.jpg`): `ba.jpg` untouched. Add a property-style table for the replacement helper (identity without the copy name, idempotence, kept name never removed).
|
||||
|
||||
## Cancelled purge
|
||||
17b. Cancel the context from a fake hook after group A commits: A merged, B and C untouched, `Execute`/`Run` return the context error, `failed_groups` unchanged, no `last_purge_*` saved, no success `media.purge` audit event. Scheduler: `Execute` receives the scheduler context (no more `context.Background()`), cancelled between groups it stops the same way.
|
||||
|
||||
## One scan per run and per preview
|
||||
21. Preview over 6 groups, 40 entries, 2 media types: `contenttype listCalls == 1`, `entry listPageCalls == 2`. Body equals the body from before the slice (same seed in an existing test).
|
||||
22. Run over the same seed: `listCalls == 1`, `listPageCalls == 2`, and the response and stored `last_purge_*` match.
|
||||
23. Stale index: an entry with thumbnail in group A and poster in group B ends with both kept ids, `Update` called twice for it.
|
||||
24. Media-used-only-by-entry group (existing step 30 of media_field_type.qa.md): the used copy is kept, the preview and the run each scan once.
|
||||
|
||||
## Purge per group
|
||||
25. Failure table (UpdateBody of the second article, article RepointImage, entry Update, image Delete for a merged copy) armed for group B in plan order A, B, C: 200 and the counters of the feature, group B fully rolled back, A and C merged, `Save` counters equal the response.
|
||||
26. All groups failing: 200, `groups_purged` 0, `failed_groups == groups_total`, state unchanged, `last_purge_at` saved.
|
||||
27. Three groups: `transactor.runCalls == 3`.
|
||||
|
||||
## Amended existing tests (the coder owns them)
|
||||
28. media_library test "failure deleting a copy": bodies are now restored and image 3 still exists. media_purge step 30: group A's bodies are restored (no longer switched), failed_groups 1, groups_purged 1, A contributes 0, the next run converges.
|
||||
29. Every other existing test passes unchanged. Constructors of `MergeMedia`, `RunPurge` and `PreviewPurge` gain the transactor and logger; update the call sites in tests only.
|
||||
|
||||
## Behaviour unchanged (walk refactor)
|
||||
30. No new case. Run the full existing suites (`task test`) and require them to pass, as the pure-refactor proof.
|
||||
|
||||
## Layering guards
|
||||
31. `internal/domain/transaction` imports only the stdlib and `internal/domain/*`. The use cases import no `persistence`, `gorm`, `models` or `net/http`. The GORM adapter asserts `var _ transaction.Transactor = &X{}` and is constructed only in api/dependecyInjection.go. Use the grep-style test of media_purge.qa.md.
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the driver tasks: seed duplicates with articles, a site, an author and entries on a copy; make the image delete fail (for example a read-only DB or a trigger); confirm no row changed.
|
||||
- Real merge against real uploads: the copy's file and variant files are gone after a 200, and still present after a forced failure.
|
||||
- Concurrent merge and entry edit with two shells (curl with the jwt cookie): the edit survives.
|
||||
- Time `POST /v1/media/purge` with many groups and 1,000 entries (one scan, not one per group).
|
||||
- Check that a SQLite transaction does not deadlock with the connection settings in `persistence/database` (all reads inside the transaction go through the tx-bound repositories).
|
||||
|
||||
## Decisions accepted by the user
|
||||
Amend the two earlier scenarios; a file failure after commit is 200 plus a log line (injected `*log.Logger`); one transaction per purge group; entries scanned then re-read per candidate inside the transaction; article bodies are re-read and rewritten inside the transaction (the user chose to fix the body race: the plan only decides which articles change); a purge cancelled by its context stops between groups without recording a run; entries created after the scan that point at a copy are not repointed (accepted limitation, shown as missing); adapter verified by hand, Go tests use fakes with snapshot/restore rollback.
|
||||
@@ -342,11 +342,12 @@ Feature: Owners and admins can merge duplicate images in use, with persisted pur
|
||||
And group B's UpdatedArticleIDs is empty, so no article of group B is in "articles_updated"
|
||||
And the stored last_purge_merged and last_purge_articles_updated equal the response totals
|
||||
|
||||
Scenario: A group that fails after rewriting bodies is reported as failed and contributes nothing
|
||||
Scenario: A group that fails after rewriting bodies is rolled back, reported as failed and contributes nothing
|
||||
Given the image repository's Delete fails for a merged image of group A, and group B is planned
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1
|
||||
And group A contributes 0 to "merged" and its article ids are not added to "articles_updated", even though its article bodies stay switched to the kept copy
|
||||
And group A contributes 0 to "merged" and its article ids are not added to "articles_updated"
|
||||
And group A's article bodies are back to their original text
|
||||
And "articles_updated" and "merged" count only the fully merged group B
|
||||
And a following GET /v1/media/purge-preview plans group A again, and the next run converges because the plan is recomputed from current data
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test
|
||||
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.
|
||||
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 are back to their original text, 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`.
|
||||
|
||||
@@ -115,6 +115,21 @@ func (f Field) validDefault() bool {
|
||||
return !f.Unique && f.Violation(f.Default) == ""
|
||||
}
|
||||
|
||||
type MediaRef struct {
|
||||
Field Field
|
||||
ID uint32
|
||||
}
|
||||
|
||||
func MediaRefs(fields []Field, data map[string]any) []MediaRef {
|
||||
var refs []MediaRef
|
||||
for _, field := range MediaFields(fields) {
|
||||
if id, set := MediaID(data[field.Key]); set {
|
||||
refs = append(refs, MediaRef{Field: field, ID: id})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func MediaFields(fields []Field) []Field {
|
||||
var media []Field
|
||||
for _, field := range fields {
|
||||
|
||||
@@ -51,12 +51,12 @@ func validateField(field contenttype.Field, data map[string]any) error {
|
||||
value := data[field.Key]
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return invalidField(field.Key, RuleRequired)
|
||||
return InvalidField(field.Key, RuleRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rule := field.Violation(value); rule != "" {
|
||||
return invalidField(field.Key, rule)
|
||||
return InvalidField(field.Key, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return invalidField(unknown[0], RuleUnknownField)
|
||||
return InvalidField(unknown[0], RuleUnknownField)
|
||||
}
|
||||
|
||||
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
|
||||
@@ -91,10 +91,15 @@ func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue
|
||||
|
||||
func MediaIDs(fields []contenttype.Field, data map[string]any) map[string]uint32 {
|
||||
ids := map[string]uint32{}
|
||||
for _, field := range contenttype.MediaFields(fields) {
|
||||
if id, ok := contenttype.MediaID(data[field.Key]); ok {
|
||||
ids[field.Key] = id
|
||||
}
|
||||
for _, ref := range contenttype.MediaRefs(fields, data) {
|
||||
ids[ref.Field.Key] = ref.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func MissingMedia(field contenttype.Field, unchanged bool) error {
|
||||
if unchanged {
|
||||
return InvalidField(field.Key, RuleRequired)
|
||||
}
|
||||
return InvalidField(field.Key, contenttype.RuleType)
|
||||
}
|
||||
@@ -55,12 +55,8 @@ func (e FieldError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func invalidField(key string, rule string) error {
|
||||
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
|
||||
}
|
||||
|
||||
func InvalidField(key string, rule string) error {
|
||||
return invalidField(key, rule)
|
||||
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
|
||||
}
|
||||
|
||||
func UniqueViolation(key string) error {
|
||||
|
||||
@@ -246,3 +246,52 @@ type Image struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func isStoredNameByte(char byte) bool {
|
||||
return char == '-' || char == '_' || char == '.' || char >= '0' && char <= '9' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
|
||||
}
|
||||
|
||||
func continuesName(body string, end int) bool {
|
||||
if end >= len(body) || !isStoredNameByte(body[end]) {
|
||||
return false
|
||||
}
|
||||
return body[end] != '.' || end+1 < len(body) && isStoredNameByte(body[end+1])
|
||||
}
|
||||
|
||||
func storedNameMatches(body string, name string) []int {
|
||||
var starts []int
|
||||
if name == "" {
|
||||
return starts
|
||||
}
|
||||
for from := 0; from < len(body); {
|
||||
found := strings.Index(body[from:], name)
|
||||
if found < 0 {
|
||||
break
|
||||
}
|
||||
start := from + found
|
||||
end := start + len(name)
|
||||
from = start + 1
|
||||
if start > 0 && isStoredNameByte(body[start-1]) || continuesName(body, end) {
|
||||
continue
|
||||
}
|
||||
starts = append(starts, start)
|
||||
from = end
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
func ContainsStoredName(body string, name string) bool {
|
||||
return len(storedNameMatches(body, name)) > 0
|
||||
}
|
||||
|
||||
func ReplaceStoredName(body string, from string, to string) string {
|
||||
var rewritten strings.Builder
|
||||
last := 0
|
||||
for _, start := range storedNameMatches(body, from) {
|
||||
rewritten.WriteString(body[last:start])
|
||||
rewritten.WriteString(to)
|
||||
last = start + len(from)
|
||||
}
|
||||
rewritten.WriteString(body[last:])
|
||||
return rewritten.String()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Repositories struct {
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
Run(ctx context.Context, work func(Repositories) error) error
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ domain.Transactor = &Transactor{}
|
||||
|
||||
type Transactor struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTransactor(db *gorm.DB) *Transactor {
|
||||
return &Transactor{db}
|
||||
}
|
||||
|
||||
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
|
||||
return t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return work(domain.Repositories{
|
||||
Articles: NewArticleRepository(tx),
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Tick(now time.Time) (purge.RunResult, bool, error)
|
||||
Tick(ctx context.Context, now time.Time) (purge.RunResult, bool, error)
|
||||
}
|
||||
|
||||
type CleanupJob interface {
|
||||
@@ -24,15 +24,15 @@ type CleanupJob interface {
|
||||
}
|
||||
|
||||
type task interface {
|
||||
tick(now time.Time) (summary string, ran bool, err error)
|
||||
tick(ctx context.Context, now time.Time) (summary string, ran bool, err error)
|
||||
}
|
||||
|
||||
type purgeTask struct {
|
||||
job Job
|
||||
}
|
||||
|
||||
func (p purgeTask) tick(now time.Time) (string, bool, error) {
|
||||
result, ran, err := p.job.Tick(now)
|
||||
func (p purgeTask) tick(ctx context.Context, now time.Time) (string, bool, error) {
|
||||
result, ran, err := p.job.Tick(ctx, now)
|
||||
if errors.Is(err, purge.ErrPurgeBusy) {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type cleanupTask struct {
|
||||
job CleanupJob
|
||||
}
|
||||
|
||||
func (c cleanupTask) tick(now time.Time) (string, bool, error) {
|
||||
func (c cleanupTask) tick(_ context.Context, now time.Time) (string, bool, error) {
|
||||
result, ran, err := c.job.Tick(now)
|
||||
return fmt.Sprintf("removed=%d", result.Removed), ran, err
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (r *Runner) tick(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
summary, ran, err := r.task.tick(r.now())
|
||||
summary, ran, err := r.task.tick(ctx, r.now())
|
||||
if err != nil {
|
||||
r.logger.Printf("scheduler: %s failed: %v", r.label, err)
|
||||
return
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -111,7 +110,7 @@ func (u *GetMediaUsageUseCase) articleUsages(img image.Image) ([]MediaUsage, err
|
||||
}
|
||||
byID := map[uint32]*MediaUsage{}
|
||||
for _, found := range inBody {
|
||||
if !strings.Contains(found.Body, storedName) {
|
||||
if !image.ContainsStoredName(found.Body, storedName) {
|
||||
continue
|
||||
}
|
||||
entry := usageFor(byID, found)
|
||||
@@ -172,5 +171,5 @@ func (u *GetMediaUsageUseCase) siteUsages(img image.Image) ([]MediaUsage, error)
|
||||
}
|
||||
|
||||
func articleMentionsImage(a article.Article, img image.Image) bool {
|
||||
return strings.Contains(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
|
||||
return image.ContainsStoredName(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
|
||||
}
|
||||
@@ -3,17 +3,14 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"cmp"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MergeMediaResult struct {
|
||||
@@ -30,13 +27,13 @@ type MergeMediaUseCase struct {
|
||||
articleRepository article.Repository
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
entries *entryMediaScanner
|
||||
transactor transaction.Transactor
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, contentTypes contenttype.Repository, entries entry.Repository) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, contentTypes contenttype.Repository, entries entry.Repository, transactor transaction.Transactor, logger *log.Logger) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, entries: newEntryMediaScanner(contentTypes, entries), transactor: transactor, logger: logger}
|
||||
}
|
||||
|
||||
func copyIdsOf(copies []image.Image) []uint32 {
|
||||
@@ -47,22 +44,8 @@ func copyIdsOf(copies []image.Image) []uint32 {
|
||||
return copyIds
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
|
||||
return u.sites.RepointImage(copyIdsOf(copies), keptId)
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) {
|
||||
return u.authors.RepointAvatar(copyIdsOf(copies), keptId)
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
articleId uint32
|
||||
original string
|
||||
rewritten string
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) Merge(ctx context.Context, keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
|
||||
result, err := u.merge(keepId, mergeIds)
|
||||
result, err := u.merge(ctx, keepId, mergeIds, nil)
|
||||
event := audit.NewEvent(audit.ActionMediaMerge, audit.CategoryMedia)
|
||||
if kept, found := u.keptImage(keepId); found {
|
||||
event.Target = imageTarget(kept)
|
||||
@@ -84,7 +67,7 @@ func (u *MergeMediaUseCase) keptImage(keepId uint32) (image.Image, bool) {
|
||||
return kept, err == nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
|
||||
func (u *MergeMediaUseCase) merge(ctx context.Context, keepId uint32, mergeIds []uint32, index *entryMediaIndex) (MergeMediaResult, error) {
|
||||
if err := validateMergeRequest(keepId, mergeIds); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
@@ -98,51 +81,94 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
rewrites, err := u.planRewrites(kept, copies)
|
||||
planned, err := u.planRewrites(copies)
|
||||
if err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
if err := u.applyRewrites(rewrites); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
repointedArticles, err := u.articleRepository.RepointImage(copyIdsOf(copies), kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
sitesUpdated, err := u.repointSites(copies, kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
authorsUpdated, err := u.repointAuthors(copies, kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
repointedEntries, err := u.entries.repoint(copyIdsOf(copies), kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
for _, duplicate := range copies {
|
||||
if err := u.remover.remove(duplicate); err != nil {
|
||||
if index == nil {
|
||||
if index, err = u.entries.index(); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
updatedIds := updatedArticleIds(rewrites, repointedArticles)
|
||||
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, EntriesUpdated: len(repointedEntries), UpdatedArticleIDs: updatedIds}, nil
|
||||
result, err := u.applyMerge(ctx, kept, copies, planned, index.referencing(copyIdsOf(copies)))
|
||||
if err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
u.discardCopies(copies)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func updatedArticleIds(rewrites []bodyRewrite, repointed []uint32) []uint32 {
|
||||
ids := slices.Clone(repointed)
|
||||
for _, plan := range rewrites {
|
||||
ids = append(ids, plan.articleId)
|
||||
func (u *MergeMediaUseCase) applyMerge(ctx context.Context, kept image.Image, copies []image.Image, planned []uint32, candidates []mediaEntry) (MergeMediaResult, error) {
|
||||
var result MergeMediaResult
|
||||
copyIds := copyIdsOf(copies)
|
||||
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
rewritten, err := rewriteBodies(tx.Articles, planned, copies, kept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repointedArticles, err := tx.Articles.RepointImage(copyIds, kept.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.SitesUpdated, err = tx.Sites.RepointImage(copyIds, kept.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.AuthorsUpdated, err = tx.Authors.RepointAvatar(copyIds, kept.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.EntriesUpdated, err = repointEntries(tx.Entries, candidates, copyIds, kept.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, duplicate := range copies {
|
||||
if err := tx.Images.Delete(duplicate.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.UpdatedArticleIDs = updatedArticleIds(rewritten, repointedArticles)
|
||||
result.ArticlesUpdated = len(result.UpdatedArticleIDs)
|
||||
result.Merged = len(copies)
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) discardCopies(copies []image.Image) {
|
||||
for _, duplicate := range copies {
|
||||
if err := u.remover.discardFiles(duplicate); err != nil {
|
||||
u.logger.Printf("media merge: could not remove the files of %s: %v", image.StoredName(duplicate.Path), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteBodies(articles article.Repository, planned []uint32, copies []image.Image, kept image.Image) ([]uint32, error) {
|
||||
keptName := image.StoredName(kept.Path)
|
||||
var rewritten []uint32
|
||||
for _, id := range planned {
|
||||
current, err := articles.Get(id)
|
||||
if errors.Is(err, article.ErrArticleNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := current.Body
|
||||
for _, duplicate := range copies {
|
||||
body = image.ReplaceStoredName(body, image.StoredName(duplicate.Path), keptName)
|
||||
}
|
||||
if body == current.Body {
|
||||
continue
|
||||
}
|
||||
if _, err := articles.UpdateBody(id, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewritten = append(rewritten, id)
|
||||
}
|
||||
return rewritten, nil
|
||||
}
|
||||
|
||||
func updatedArticleIds(rewritten []uint32, repointed []uint32) []uint32 {
|
||||
ids := append(slices.Clone(repointed), rewritten...)
|
||||
slices.Sort(ids)
|
||||
return slices.Compact(ids)
|
||||
}
|
||||
@@ -179,10 +205,8 @@ func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]i
|
||||
return copies, nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image) ([]bodyRewrite, error) {
|
||||
keptName := image.StoredName(kept.Path)
|
||||
rewritten := map[uint32]*bodyRewrite{}
|
||||
|
||||
func (u *MergeMediaUseCase) planRewrites(copies []image.Image) ([]uint32, error) {
|
||||
seen := map[uint32]bool{}
|
||||
for _, duplicate := range copies {
|
||||
copyName := image.StoredName(duplicate.Path)
|
||||
articles, err := u.articleRepository.FindByBodyContaining(copyName)
|
||||
@@ -190,39 +214,15 @@ func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image)
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range articles {
|
||||
if !strings.Contains(a.Body, copyName) {
|
||||
continue
|
||||
}
|
||||
plan, ok := rewritten[a.ID]
|
||||
if !ok {
|
||||
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
|
||||
rewritten[a.ID] = plan
|
||||
}
|
||||
plan.rewritten = strings.ReplaceAll(plan.rewritten, copyName, keptName)
|
||||
if image.ContainsStoredName(a.Body, copyName) {
|
||||
seen[a.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
plans := make([]bodyRewrite, 0, len(rewritten))
|
||||
for _, plan := range rewritten {
|
||||
plans = append(plans, *plan)
|
||||
}
|
||||
slices.SortFunc(plans, func(a, b bodyRewrite) int { return cmp.Compare(a.articleId, b.articleId) })
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
|
||||
for applied, plan := range rewrites {
|
||||
if _, err := u.articleRepository.UpdateBody(plan.articleId, plan.rewritten); err != nil {
|
||||
u.restoreBodies(rewrites[:applied])
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
|
||||
for _, plan := range applied {
|
||||
_, err := u.articleRepository.UpdateBody(plan.articleId, plan.original)
|
||||
logCompensation("restore article body "+strconv.FormatUint(uint64(plan.articleId), 10), err)
|
||||
}
|
||||
planned := make([]uint32, 0, len(seen))
|
||||
for id := range seen {
|
||||
planned = append(planned, id)
|
||||
}
|
||||
slices.Sort(planned)
|
||||
return planned, nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type PurgeRunner interface {
|
||||
Execute(fingerprint *string) (purge.RunResult, error)
|
||||
Execute(ctx context.Context, fingerprint *string) (purge.RunResult, error)
|
||||
}
|
||||
|
||||
type PurgeSchedulerUseCase struct {
|
||||
@@ -22,7 +22,7 @@ func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner, r
|
||||
return &PurgeSchedulerUseCase{repository, runner, recorder}
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
func (u *PurgeSchedulerUseCase) Tick(ctx context.Context, now time.Time) (purge.RunResult, bool, error) {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
@@ -31,7 +31,7 @@ func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, erro
|
||||
if next == nil || now.Before(*next) {
|
||||
return purge.RunResult{}, false, nil
|
||||
}
|
||||
result, err := u.runner.Execute(nil)
|
||||
result, err := u.runner.Execute(ctx, nil)
|
||||
if err != nil {
|
||||
u.recordFailedRun(err)
|
||||
return purge.RunResult{}, false, err
|
||||
|
||||
@@ -36,7 +36,7 @@ func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository arti
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(ctx context.Context, fingerprint *string) (purge.RunResult, error) {
|
||||
result, err := u.Execute(fingerprint)
|
||||
result, err := u.Execute(ctx, fingerprint)
|
||||
event := audit.NewEvent(audit.ActionMediaPurge, audit.CategoryMedia)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
@@ -55,7 +55,7 @@ func purgeParams(result purge.RunResult) map[string]any {
|
||||
return params
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error) {
|
||||
func (u *RunPurgeUseCase) Execute(ctx context.Context, fingerprint *string) (purge.RunResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return purge.RunResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
@@ -69,7 +69,10 @@ func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error)
|
||||
return purge.RunResult{}, purge.ErrPurgeConflict
|
||||
}
|
||||
|
||||
result := u.mergeGroups(plan)
|
||||
result, err := u.mergeGroups(ctx, plan)
|
||||
if err != nil {
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
if err := u.recordRun(result); err != nil {
|
||||
u.logger.Printf("purge: could not record the run result: %v", err)
|
||||
return purge.RunResult{}, err
|
||||
@@ -77,11 +80,14 @@ func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
func (u *RunPurgeUseCase) mergeGroups(ctx context.Context, plan purgePlan) (purge.RunResult, error) {
|
||||
result := purge.RunResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
if err := ctx.Err(); err != nil {
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
merged, err := u.merge.merge(ctx, group.kept.ID, slices.Clone(group.mergedIds), plan.entries)
|
||||
if err != nil {
|
||||
result.FailedGroups++
|
||||
continue
|
||||
@@ -95,7 +101,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
}
|
||||
}
|
||||
result.ArticlesUpdated = len(updated)
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"cmp"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
@@ -30,16 +31,12 @@ func newMediaResolver(images image.Repository) *mediaResolver {
|
||||
|
||||
func (r *mediaResolver) resolve(owner contenttype.ContentType, record entry.Entry) (map[string]EntryMedia, error) {
|
||||
resolved := map[string]EntryMedia{}
|
||||
for _, field := range contenttype.MediaFields(owner.Fields) {
|
||||
id, set := contenttype.MediaID(record.Data[field.Key])
|
||||
if !set {
|
||||
continue
|
||||
}
|
||||
media, err := r.lookup(id)
|
||||
for _, ref := range contenttype.MediaRefs(owner.Fields, record.Data) {
|
||||
media, err := r.lookup(ref.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved[field.Key] = media
|
||||
resolved[ref.Field.Key] = media
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -73,6 +70,63 @@ type mediaEntry struct {
|
||||
record entry.Entry
|
||||
}
|
||||
|
||||
func (m mediaEntry) keysPointingAt(ids map[uint32]bool) []string {
|
||||
keys := []string{}
|
||||
for _, ref := range contenttype.MediaRefs(m.owner.Fields, m.record.Data) {
|
||||
if ids[ref.ID] {
|
||||
keys = append(keys, ref.Field.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func compareMediaEntries(a, b mediaEntry) int {
|
||||
return cmp.Or(cmp.Compare(a.owner.Name, b.owner.Name), cmp.Compare(a.owner.ID, b.owner.ID), cmp.Compare(a.record.ID, b.record.ID))
|
||||
}
|
||||
|
||||
type entryMediaIndex struct {
|
||||
byImage map[uint32][]mediaEntry
|
||||
}
|
||||
|
||||
func newEntryMediaIndex(found []mediaEntry) *entryMediaIndex {
|
||||
index := &entryMediaIndex{byImage: map[uint32][]mediaEntry{}}
|
||||
for _, candidate := range found {
|
||||
distinct := map[uint32]bool{}
|
||||
for _, ref := range contenttype.MediaRefs(candidate.owner.Fields, candidate.record.Data) {
|
||||
distinct[ref.ID] = true
|
||||
}
|
||||
for id := range distinct {
|
||||
index.byImage[id] = append(index.byImage[id], candidate)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (i *entryMediaIndex) usageCounts() map[uint32]int {
|
||||
counts := make(map[uint32]int, len(i.byImage))
|
||||
for id, found := range i.byImage {
|
||||
counts[id] = len(found)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func (i *entryMediaIndex) referencing(imageIDs []uint32) []mediaEntry {
|
||||
type entryKey struct{ typeID, entryID uint32 }
|
||||
seen := map[entryKey]bool{}
|
||||
var found []mediaEntry
|
||||
for _, id := range imageIDs {
|
||||
for _, candidate := range i.byImage[id] {
|
||||
key := entryKey{candidate.owner.ID, candidate.record.ID}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
found = append(found, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(found, compareMediaEntries)
|
||||
return found
|
||||
}
|
||||
|
||||
type entryMediaScanner struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
@@ -82,7 +136,7 @@ func newEntryMediaScanner(contentTypes contenttype.Repository, entries entry.Rep
|
||||
return &entryMediaScanner{contentTypes: contentTypes, entries: entries}
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) scan() ([]mediaEntry, error) {
|
||||
func (s *entryMediaScanner) index() (*entryMediaIndex, error) {
|
||||
types, err := s.contentTypes.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -100,91 +154,51 @@ func (s *entryMediaScanner) scan() ([]mediaEntry, error) {
|
||||
found = append(found, mediaEntry{owner: owner, record: record})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(found, func(a, b mediaEntry) int {
|
||||
return cmp.Or(cmp.Compare(a.owner.Name, b.owner.Name), cmp.Compare(a.owner.ID, b.owner.ID), cmp.Compare(a.record.ID, b.record.ID))
|
||||
})
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m mediaEntry) keysPointingAt(ids map[uint32]bool) []string {
|
||||
keys := []string{}
|
||||
for _, field := range contenttype.MediaFields(m.owner.Fields) {
|
||||
if id, set := contenttype.MediaID(m.record.Data[field.Key]); set && ids[id] {
|
||||
keys = append(keys, field.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
return newEntryMediaIndex(found), nil
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) usageCounts() (map[uint32]int, error) {
|
||||
found, err := s.scan()
|
||||
index, err := s.index()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := map[uint32]int{}
|
||||
for _, candidate := range found {
|
||||
distinct := map[uint32]bool{}
|
||||
for _, id := range entry.MediaIDs(candidate.owner.Fields, candidate.record.Data) {
|
||||
distinct[id] = true
|
||||
}
|
||||
for id := range distinct {
|
||||
counts[id]++
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
return index.usageCounts(), nil
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) referencing(imageID uint32) ([]mediaEntry, error) {
|
||||
found, err := s.scan()
|
||||
index, err := s.index()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets := map[uint32]bool{imageID: true}
|
||||
return slices.DeleteFunc(found, func(candidate mediaEntry) bool {
|
||||
return len(candidate.keysPointingAt(targets)) == 0
|
||||
}), nil
|
||||
return index.referencing([]uint32{imageID}), nil
|
||||
}
|
||||
|
||||
type entryRepoint struct {
|
||||
original entry.Entry
|
||||
updated entry.Entry
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) repoint(copyIDs []uint32, keptID uint32) ([]entryRepoint, error) {
|
||||
found, err := s.scan()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func repointEntries(entries entry.Repository, candidates []mediaEntry, copyIDs []uint32, keptID uint32) (int, error) {
|
||||
copies := map[uint32]bool{}
|
||||
for _, id := range copyIDs {
|
||||
copies[id] = true
|
||||
}
|
||||
applied := []entryRepoint{}
|
||||
for _, candidate := range found {
|
||||
keys := candidate.keysPointingAt(copies)
|
||||
updated := 0
|
||||
for _, candidate := range candidates {
|
||||
current, err := entries.Get(candidate.owner.ID, candidate.record.ID)
|
||||
if errors.Is(err, entry.ErrEntryNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return updated, err
|
||||
}
|
||||
keys := mediaEntry{owner: candidate.owner, record: current}.keysPointingAt(copies)
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
change := entryRepoint{original: candidate.record, updated: candidate.record}
|
||||
change.updated.Data = make(map[string]any, len(candidate.record.Data))
|
||||
for key, value := range candidate.record.Data {
|
||||
change.updated.Data[key] = value
|
||||
}
|
||||
current.Data = maps.Clone(current.Data)
|
||||
for _, key := range keys {
|
||||
change.updated.Data[key] = float64(keptID)
|
||||
current.Data[key] = float64(keptID)
|
||||
}
|
||||
if _, err := s.entries.Update(change.updated); err != nil {
|
||||
s.restore(applied)
|
||||
return nil, err
|
||||
if _, err := entries.Update(current); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
applied = append(applied, change)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) restore(applied []entryRepoint) {
|
||||
for _, change := range applied {
|
||||
_, err := s.entries.Update(change.original)
|
||||
logCompensation("restore entry media", err)
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -24,26 +24,18 @@ func typedEntry(owner contenttype.ContentType, record entry.Entry, resolver *med
|
||||
|
||||
func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
|
||||
storedIDs := entry.MediaIDs(owner.Fields, stored)
|
||||
for _, field := range contenttype.MediaFields(owner.Fields) {
|
||||
id, set := contenttype.MediaID(data[field.Key])
|
||||
if !set {
|
||||
for _, ref := range contenttype.MediaRefs(owner.Fields, data) {
|
||||
unchanged := stored != nil && storedIDs[ref.Field.Key] == ref.ID
|
||||
if unchanged && !ref.Field.Required {
|
||||
continue
|
||||
}
|
||||
unchanged := stored != nil && storedIDs[field.Key] == id
|
||||
if unchanged && !field.Required {
|
||||
continue
|
||||
}
|
||||
exists, err := imageExists(images, id)
|
||||
exists, err := imageExists(images, ref.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
if !exists {
|
||||
return entry.MissingMedia(ref.Field, unchanged)
|
||||
}
|
||||
if unchanged {
|
||||
return entry.InvalidField(field.Key, entry.RuleRequired)
|
||||
}
|
||||
return entry.InvalidField(field.Key, contenttype.RuleType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type mediaRemover struct {
|
||||
imageRepository image.Repository
|
||||
@@ -22,3 +25,8 @@ func (r mediaRemover) remove(img image.Image) error {
|
||||
}
|
||||
return r.imageRepository.Delete(img.ID)
|
||||
}
|
||||
|
||||
func (r mediaRemover) discardFiles(img image.Image) error {
|
||||
storedName := image.StoredName(img.Path)
|
||||
return errors.Join(r.generated.purgeSource(storedName), r.storage.Delete(storedName))
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type purgePlan struct {
|
||||
copies int
|
||||
articles int
|
||||
fingerprint string
|
||||
entries *entryMediaIndex
|
||||
}
|
||||
|
||||
type purgePlanner struct {
|
||||
@@ -43,7 +44,15 @@ func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
siteUsage, err := imageUsageCount(p.sites, p.authors, p.entries)
|
||||
var entryIndex *entryMediaIndex
|
||||
siteUsage, err := combinedImageUsage(p.sites, p.authors, func() (map[uint32]int, error) {
|
||||
built, err := p.entries.index()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entryIndex = built
|
||||
return built.usageCounts(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
@@ -64,7 +73,7 @@ func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
}
|
||||
slices.SortFunc(groups, func(a, b purgeGroup) int { return int(a.kept.ID) - int(b.kept.ID) })
|
||||
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups)}, nil
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups), entries: entryIndex}, nil
|
||||
}
|
||||
|
||||
func groupByHash(images []image.Image) [][]image.Image {
|
||||
|
||||
@@ -20,6 +20,10 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
|
||||
}
|
||||
|
||||
func imageUsageCount(sites site.Repository, authors author.Repository, entries *entryMediaScanner) (map[uint32]int, error) {
|
||||
return combinedImageUsage(sites, authors, entries.usageCounts)
|
||||
}
|
||||
|
||||
func combinedImageUsage(sites site.Repository, authors author.Repository, entryCounts func() (map[uint32]int, error)) (map[uint32]int, error) {
|
||||
usage, err := siteImageUsage(sites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -31,11 +35,11 @@ func imageUsageCount(sites site.Repository, authors author.Repository, entries *
|
||||
for imageID, total := range avatars {
|
||||
usage[imageID] += total
|
||||
}
|
||||
entryCounts, err := entries.usageCounts()
|
||||
counts, err := entryCounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for imageID, total := range entryCounts {
|
||||
for imageID, total := range counts {
|
||||
usage[imageID] += total
|
||||
}
|
||||
return usage, nil
|
||||
|
||||
@@ -58,7 +58,7 @@ type producerPurgeRunner struct {
|
||||
|
||||
var _ useCases.PurgeRunner = &producerPurgeRunner{}
|
||||
|
||||
func (r *producerPurgeRunner) Execute(*string) (purge.RunResult, error) {
|
||||
func (r *producerPurgeRunner) Execute(context.Context, *string) (purge.RunResult, error) {
|
||||
r.calls++
|
||||
return r.result, r.err
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestActivityLogProducersFailureReasons(t *testing.T) {
|
||||
t.Helper()
|
||||
h := newActivityHarness(t)
|
||||
scheduler := useCases.NewPurgeSchedulerUseCase(dueSettings(), &producerPurgeRunner{err: err}, h.recorder)
|
||||
if _, _, tickErr := scheduler.Tick(*purgeTime("2026-10-05T09:00:00Z")); !errors.Is(tickErr, err) {
|
||||
if _, _, tickErr := scheduler.Tick(testCtx, *purgeTime("2026-10-05T09:00:00Z")); !errors.Is(tickErr, err) {
|
||||
t.Fatalf("tick err = %v", tickErr)
|
||||
}
|
||||
return onlyEvent(t, h)
|
||||
@@ -322,7 +322,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 2, GroupsPurged: 2, Merged: 3, ArticlesUpdated: 5}}
|
||||
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
|
||||
|
||||
if _, ran, err := scheduler.Tick(now); err != nil || !ran {
|
||||
if _, ran, err := scheduler.Tick(testCtx, now); err != nil || !ran {
|
||||
t.Fatalf("ran = %v, err = %v", ran, err)
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 3, GroupsPurged: 2, FailedGroups: 1, Merged: 2, ArticlesUpdated: 2}}
|
||||
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
|
||||
|
||||
if _, _, err := scheduler.Tick(now); err != nil {
|
||||
if _, _, err := scheduler.Tick(testCtx, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -356,10 +356,10 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
notDue := newMockPurgeSettingsRepo()
|
||||
runner := &producerPurgeRunner{}
|
||||
if _, ran, err := useCases.NewPurgeSchedulerUseCase(notDue, runner, h.recorder).Tick(now); err != nil || ran {
|
||||
if _, ran, err := useCases.NewPurgeSchedulerUseCase(notDue, runner, h.recorder).Tick(testCtx, now); err != nil || ran {
|
||||
t.Fatalf("ran = %v, err = %v", ran, err)
|
||||
}
|
||||
if _, ran, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err != nil || !ran {
|
||||
if _, ran, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(testCtx, now); err != nil || !ran {
|
||||
t.Fatalf("ran = %v, err = %v", ran, err)
|
||||
}
|
||||
assertNoEvents(t, h)
|
||||
@@ -369,7 +369,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
runner := &producerPurgeRunner{err: errRepositoryFailure}
|
||||
|
||||
if _, _, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err == nil {
|
||||
if _, _, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(testCtx, now); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
|
||||
articles := newMockArticleRepo()
|
||||
storage := newMockImageStorage()
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
finished := make(chan struct{})
|
||||
|
||||
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
mail := newMockMailRepo()
|
||||
env.mail = mail
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
mergeMedia := newTestMergeMedia(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
purgeSettings := newMockPurgeSettingsRepo()
|
||||
restartJobs := &fakeJobStore{}
|
||||
env.container = &api.UseCases{
|
||||
|
||||
@@ -25,6 +25,8 @@ type mockArticleRepo struct {
|
||||
authors *mockAuthorRepo
|
||||
reportSlugRace bool
|
||||
createAlwaysRaces bool
|
||||
afterFindByBody func()
|
||||
updateBodyIDs []uint32
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
@@ -315,6 +317,7 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
|
||||
m.updateBodyIDs = append(m.updateBodyIDs, id)
|
||||
if err := m.fail.trigger("UpdateBody"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
@@ -427,6 +430,9 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
|
||||
result = append(result, a)
|
||||
}
|
||||
}
|
||||
if m.afterFindByBody != nil {
|
||||
defer m.afterFindByBody()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,9 @@ func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
if err := m.fail.trigger("RepointAvatar"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for id, a := range m.authors {
|
||||
if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) {
|
||||
@@ -355,7 +358,7 @@ func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
sites := plainSites()
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
|
||||
@@ -37,6 +37,7 @@ type mockContentTypeRepo struct {
|
||||
createCalls int
|
||||
updateCalls int
|
||||
deleteCalls int
|
||||
listCalls int
|
||||
fail failSwitch
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ func (m *mockContentTypeRepo) GetByKey(key string) (contenttype.ContentType, err
|
||||
}
|
||||
|
||||
func (m *mockContentTypeRepo) List() ([]contenttype.ContentType, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("List"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,6 +106,12 @@ type mockEntryRepo struct {
|
||||
nextID uint32
|
||||
createCalls int
|
||||
updateCalls int
|
||||
getCalls int
|
||||
listPageCalls int
|
||||
failGetOn int
|
||||
failUpdateOn int
|
||||
updatedIDs []uint32
|
||||
afterListPage func()
|
||||
}
|
||||
|
||||
var _ entry.Repository = &mockEntryRepo{}
|
||||
@@ -121,6 +129,10 @@ func (m *mockEntryRepo) Create(record entry.Entry) (entry.Entry, error) {
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) Get(contentTypeID uint32, id uint32) (entry.Entry, error) {
|
||||
m.getCalls++
|
||||
if m.failGetOn > 0 && m.getCalls == m.failGetOn {
|
||||
return entry.Entry{}, errRepositoryFailure
|
||||
}
|
||||
if stored, found := m.entries[id]; found && stored.ContentTypeID == contentTypeID {
|
||||
return stored, nil
|
||||
}
|
||||
@@ -128,6 +140,10 @@ func (m *mockEntryRepo) Get(contentTypeID uint32, id uint32) (entry.Entry, error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) ListPage(filters entry.Filters) (entry.PageResult, error) {
|
||||
m.listPageCalls++
|
||||
if m.afterListPage != nil {
|
||||
defer m.afterListPage()
|
||||
}
|
||||
matching := []entry.Entry{}
|
||||
for _, stored := range m.entries {
|
||||
if stored.ContentTypeID == filters.ContentTypeID {
|
||||
@@ -169,6 +185,10 @@ func sortEntries(items []entry.Entry, order entry.Sort) {
|
||||
|
||||
func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
|
||||
m.updateCalls++
|
||||
m.updatedIDs = append(m.updatedIDs, record.ID)
|
||||
if m.failUpdateOn > 0 && m.updateCalls == m.failUpdateOn {
|
||||
return entry.Entry{}, errRepositoryFailure
|
||||
}
|
||||
m.entries[record.ID] = record
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
|
||||
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
|
||||
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
|
||||
|
||||
@@ -2,7 +2,6 @@ package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
|
||||
env.seedArticle(id, "Article", true, env.urlOf(3))
|
||||
}
|
||||
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
uc := newTestMergeMedia(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -62,7 +62,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
|
||||
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
|
||||
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
|
||||
@@ -65,7 +65,7 @@ func newMediaFieldEnv(t *testing.T) *mediaFieldEnv {
|
||||
env := &mediaFieldEnv{contentTypeEnv: base, articles: newMockArticleRepo(), spy: &mfEntrySpyRepo{mockEntryRepo: base.entries}, lookups: &mfImageSpyRepo{mockImageRepo: base.images}, logs: &bytes.Buffer{}}
|
||||
settings := newMockPurgeSettingsRepo()
|
||||
now := func() time.Time { return base.clock }
|
||||
merge := useCases.NewMergeMediaUseCase(base.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), base.types, env.spy)
|
||||
merge := newTestMergeMedia(base.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), base.types, env.spy)
|
||||
container := base.container
|
||||
container.CreateEntryUseCase = useCases.NewCreateEntryUseCase(base.types, base.entries, env.lookups, now)
|
||||
container.GetEntryUseCase = useCases.NewGetEntryUseCase(base.types, base.entries, env.lookups)
|
||||
|
||||
@@ -943,7 +943,7 @@ func TestMediaMerging(t *testing.T) {
|
||||
|
||||
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
uc := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
|
||||
|
||||
@@ -971,18 +971,18 @@ func TestMediaMerging(t *testing.T) {
|
||||
expectIds(t, env.imageIds(), []uint32{1, 2, 3, 6})
|
||||
})
|
||||
|
||||
t.Run("A failure deleting a copy after the rewrite is 500 and the bodies stay switched", func(t *testing.T) {
|
||||
t.Run("A failure deleting a copy after the rewrite is 500 and the bodies are rolled back", func(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
env.images.failDeleteFor = map[uint32]error{3: errRepositoryFailure}
|
||||
keep := env.urlOf(2)
|
||||
bodiesBefore := env.bodies()
|
||||
|
||||
rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, mediaVerifiedUserId)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(env.articles.articles[1].Body, keep) || strings.Contains(env.articles.articles[1].Body, env.urlOf(3)) {
|
||||
t.Errorf("expected article 1 to use image 2's url, got %q", env.articles.articles[1].Body)
|
||||
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
|
||||
t.Errorf("expected every body back to its original text, got %v", env.bodies())
|
||||
}
|
||||
if _, ok := env.images.images[3]; !ok {
|
||||
t.Errorf("expected image 3 to still exist")
|
||||
@@ -1038,7 +1038,7 @@ func TestMediaSentinels(t *testing.T) {
|
||||
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
|
||||
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
|
||||
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
uc := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
|
||||
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeMediaReportsEntriesAndTargetsTheKeptImage(t *testing.T) {
|
||||
t.Run("A merge counts repointed entries and targets the kept image", func(t *testing.T) {
|
||||
env := newHardeningEnv(t)
|
||||
env.seedMergeFixture()
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
|
||||
|
||||
result, err := merge.Merge(context.Background(), 7, []uint32{9})
|
||||
|
||||
if err != nil || result.EntriesUpdated != 2 {
|
||||
t.Fatalf("expected 2 entries updated, got %+v %v", result, err)
|
||||
}
|
||||
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeSuccess, 1)
|
||||
if event.Target.ID != strconv.Itoa(7) || event.Target.Name != env.images.images[7].Filename {
|
||||
t.Errorf("unexpected target %+v", event.Target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed merge still targets the kept image", func(t *testing.T) {
|
||||
env := newHardeningEnv(t)
|
||||
env.seedMergeFixture()
|
||||
env.transactor.failCommit = errRepositoryFailure
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
|
||||
|
||||
_, err := merge.Merge(context.Background(), 7, []uint32{9})
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error")
|
||||
}
|
||||
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeFailed, 1)
|
||||
if event.Target.ID != "7" {
|
||||
t.Errorf("expected the kept image as target, got %+v", event.Target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A rejected request has no target", func(t *testing.T) {
|
||||
env := newHardeningEnv(t)
|
||||
env.seedMergeFixture()
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
|
||||
|
||||
_, err := merge.Merge(context.Background(), 0, []uint32{9})
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error")
|
||||
}
|
||||
for _, event := range env.recorder.events {
|
||||
if event.Target != (audit.Target{}) {
|
||||
t.Errorf("expected no target, got %+v", event.Target)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeAuditReportsFailedGroupsOnlyWhenAny(t *testing.T) {
|
||||
t.Run("A clean run omits failed_groups", func(t *testing.T) {
|
||||
env := newHardeningEnv(t)
|
||||
env.seedPurgeGroups(1)
|
||||
|
||||
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
event := env.expectEvents(t, audit.ActionMediaPurge, audit.OutcomeSuccess, 1)
|
||||
if _, present := event.Params[audit.ParamFailedGroups]; present {
|
||||
t.Errorf("unexpected failed_groups in %+v", event.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A run with a failed group reports the count", func(t *testing.T) {
|
||||
env := newHardeningEnv(t)
|
||||
env.seedPurgeGroups(2)
|
||||
env.transactor.failCommit = errRepositoryFailure
|
||||
|
||||
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
event := env.expectEvents(t, audit.ActionMediaPurge, audit.OutcomeSuccess, 1)
|
||||
if event.Params[audit.ParamFailedGroups] != 2 {
|
||||
t.Errorf("expected failed_groups 2, got %v", event.Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,160 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
)
|
||||
|
||||
type mergeSnapshotter interface {
|
||||
mergeSnapshot() func()
|
||||
}
|
||||
|
||||
func (m *mockImageRepo) mergeSnapshot() func() {
|
||||
saved, nextID := maps.Clone(m.images), m.nextID
|
||||
return func() { m.images, m.nextID = maps.Clone(saved), nextID }
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) mergeSnapshot() func() {
|
||||
saved := maps.Clone(m.articles)
|
||||
return func() { m.articles = maps.Clone(saved) }
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) mergeSnapshot() func() {
|
||||
saved := maps.Clone(m.sites)
|
||||
return func() { m.sites = maps.Clone(saved) }
|
||||
}
|
||||
|
||||
func (m *mockAuthorRepo) mergeSnapshot() func() {
|
||||
saved := maps.Clone(m.authors)
|
||||
return func() { m.authors = maps.Clone(saved) }
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) mergeSnapshot() func() {
|
||||
saved := maps.Clone(m.entries)
|
||||
return func() { m.entries = maps.Clone(saved) }
|
||||
}
|
||||
|
||||
type mockMergeTransactor struct {
|
||||
repos transaction.Repositories
|
||||
failBegin error
|
||||
failCommit error
|
||||
runCalls int
|
||||
committed bool
|
||||
afterCommit func()
|
||||
}
|
||||
|
||||
var _ transaction.Transactor = &mockMergeTransactor{}
|
||||
|
||||
func newMergeTransactor(images image.Repository, articles article.Repository, sites site.Repository, authors author.Repository, entries entry.Repository) *mockMergeTransactor {
|
||||
return &mockMergeTransactor{repos: transaction.Repositories{Articles: articles, Sites: sites, Authors: authors, Entries: entries, Images: images}}
|
||||
}
|
||||
|
||||
func (m *mockMergeTransactor) snapshots() []func() {
|
||||
var restores []func()
|
||||
for _, repo := range []any{m.repos.Articles, m.repos.Sites, m.repos.Authors, m.repos.Entries, m.repos.Images} {
|
||||
if snapshotter, ok := repo.(mergeSnapshotter); ok {
|
||||
restores = append(restores, snapshotter.mergeSnapshot())
|
||||
}
|
||||
}
|
||||
return restores
|
||||
}
|
||||
|
||||
func restoreAll(restores []func()) {
|
||||
for _, restore := range restores {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockMergeTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) (err error) {
|
||||
m.runCalls++
|
||||
m.committed = false
|
||||
if m.failBegin != nil {
|
||||
return m.failBegin
|
||||
}
|
||||
restores := m.snapshots()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
restoreAll(restores)
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
if err := work(m.repos); err != nil {
|
||||
restoreAll(restores)
|
||||
return err
|
||||
}
|
||||
if m.failCommit != nil {
|
||||
restoreAll(restores)
|
||||
return m.failCommit
|
||||
}
|
||||
m.committed = true
|
||||
if m.afterCommit != nil {
|
||||
m.afterCommit()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mergeFileCall struct {
|
||||
name string
|
||||
committed bool
|
||||
}
|
||||
|
||||
type mergeRecordingStorage struct {
|
||||
*mockImageStorage
|
||||
transactor *mockMergeTransactor
|
||||
calls []mergeFileCall
|
||||
failFor map[string]error
|
||||
}
|
||||
|
||||
var _ image.Storage = &mergeRecordingStorage{}
|
||||
|
||||
func newMergeRecordingStorage(transactor *mockMergeTransactor) *mergeRecordingStorage {
|
||||
return &mergeRecordingStorage{mockImageStorage: newMockImageStorage(), transactor: transactor, failFor: map[string]error{}}
|
||||
}
|
||||
|
||||
func (s *mergeRecordingStorage) Delete(name string) error {
|
||||
s.calls = append(s.calls, mergeFileCall{name: name, committed: s.transactor.committed})
|
||||
if err, armed := s.failFor[name]; armed {
|
||||
return err
|
||||
}
|
||||
return s.mockImageStorage.Delete(name)
|
||||
}
|
||||
|
||||
type mergeRecordingVariantFiles struct {
|
||||
*mockVariantFileRepo
|
||||
transactor *mockMergeTransactor
|
||||
calls []mergeFileCall
|
||||
failSource map[string]error
|
||||
}
|
||||
|
||||
var _ image.VariantFileRepository = &mergeRecordingVariantFiles{}
|
||||
|
||||
func newMergeRecordingVariantFiles(transactor *mockMergeTransactor) *mergeRecordingVariantFiles {
|
||||
return &mergeRecordingVariantFiles{mockVariantFileRepo: newMockVariantFileRepo(), transactor: transactor, failSource: map[string]error{}}
|
||||
}
|
||||
|
||||
func (v *mergeRecordingVariantFiles) ListBySource(sourceName string) ([]image.VariantFile, error) {
|
||||
if err, armed := v.failSource[sourceName]; armed {
|
||||
return nil, err
|
||||
}
|
||||
return v.mockVariantFileRepo.ListBySource(sourceName)
|
||||
}
|
||||
|
||||
func (v *mergeRecordingVariantFiles) Delete(id uint32) error {
|
||||
v.calls = append(v.calls, mergeFileCall{name: v.rows[id].SourceName, committed: v.transactor.committed})
|
||||
return v.mockVariantFileRepo.Delete(id)
|
||||
}
|
||||
|
||||
func newTestMergeMedia(images image.Repository, articles article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, types contenttype.Repository, entries entry.Repository) *useCases.MergeMediaUseCase {
|
||||
return useCases.NewMergeMediaUseCase(images, articles, storage, variantFiles, recorder, types, entries, newMergeTransactor(images, articles, sites, authors, entries), log.New(io.Discard, "", 0))
|
||||
}
|
||||
@@ -743,16 +743,22 @@ func TestPurgeRun(t *testing.T) {
|
||||
env.useImages(22, 3, 4)
|
||||
env.useImages(23, 3)
|
||||
env.images.failDeleteFor = map[uint32]error{2: errRepositoryFailure}
|
||||
originalA := env.bodies()[20]
|
||||
copyURL := env.urlOf(2)
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(2, 1, 1, 1, 1))
|
||||
if strings.Contains(env.bodies()[20], env.urlOf(2)) {
|
||||
t.Errorf("expected article 20 to stay switched to the kept copy")
|
||||
if env.bodies()[20] != originalA {
|
||||
t.Errorf("expected group A's bodies back to their original text")
|
||||
}
|
||||
if got := numberOf(env.preview(t)["groups"]); got != 1 {
|
||||
t.Errorf("expected group A to be planned again, got %d groups", got)
|
||||
}
|
||||
env.images.failDeleteFor = nil
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 0, 0))
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 1, 0))
|
||||
if strings.Contains(env.bodies()[20], copyURL) {
|
||||
t.Errorf("expected article 20 to use the kept copy after the next run")
|
||||
}
|
||||
expectIds(t, env.imageIds(), []uint32{1, 3})
|
||||
})
|
||||
|
||||
t.Run("Every group failing is still a 200 with the counters", func(t *testing.T) {
|
||||
|
||||
@@ -31,7 +31,7 @@ type schedulerEnv struct {
|
||||
func newSchedulerEnv(t *testing.T) *schedulerEnv {
|
||||
t.Helper()
|
||||
env := newPurgeEnv(t)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors())
|
||||
return &schedulerEnv{
|
||||
@@ -64,7 +64,7 @@ func (e *schedulerEnv) seedPlannedGroup() {
|
||||
|
||||
func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) {
|
||||
t.Helper()
|
||||
_, ran, err := e.scheduler.Tick(*purgeTime(at))
|
||||
_, ran, err := e.scheduler.Tick(testCtx, *purgeTime(at))
|
||||
return ran, err
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
result, ran, err := env.scheduler.Tick(testCtx, *purgeTime(schedulerClock))
|
||||
|
||||
want := purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1, ArticlesUpdated: 1}
|
||||
if err != nil || !ran || result != want {
|
||||
@@ -305,7 +305,7 @@ func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
|
||||
t.Run("A Tick that is not due returns an empty result", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
result, ran, err := env.scheduler.Tick(testCtx, *purgeTime(schedulerClock))
|
||||
|
||||
if err != nil || ran || result != (purge.RunResult{}) {
|
||||
t.Errorf("unexpected %+v, %v, %v", result, ran, err)
|
||||
@@ -466,7 +466,7 @@ func newSchedulerJobFake(results ...schedulerJobResult) *schedulerJobFake {
|
||||
return &schedulerJobFake{results: results, entered: make(chan time.Time, 16)}
|
||||
}
|
||||
|
||||
func (f *schedulerJobFake) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
func (f *schedulerJobFake) Tick(_ context.Context, now time.Time) (purge.RunResult, bool, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, now)
|
||||
var result schedulerJobResult
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
|
||||
|
||||
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
|
||||
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites(), plainAuthors())
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 1, 2)
|
||||
env.useImages(3, 1)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 2)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
|
||||
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
|
||||
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
|
||||
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
result, err := newTestMergeMedia(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
|
||||
if err != nil || result.Merged != 2 {
|
||||
t.Fatalf("merge returned %+v, %v", result, err)
|
||||
|
||||
@@ -146,7 +146,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
MergeMediaUseCase: newTestMergeMedia(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed merge logs the body it could not restore", func(t *testing.T) {
|
||||
t.Run("A failed merge rolls the bodies back and logs no compensation", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
e.seedBanner(5, "a.png", "same-hash")
|
||||
e.seedBanner(6, "b.png", "same-hash")
|
||||
@@ -292,14 +292,18 @@ func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
|
||||
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
|
||||
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
|
||||
buffer := captureLog(t)
|
||||
bodiesBefore := map[uint32]string{8: e.articles.articles[8].Body, 9: e.articles.articles[9].Body}
|
||||
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles}
|
||||
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
_, err := merge.Merge(t.Context(), 5, []uint32{6})
|
||||
|
||||
if err == nil || !strings.Contains(buffer.String(), "restore article body 8: body write 3 down") {
|
||||
if err == nil || buffer.Len() != 0 {
|
||||
t.Errorf("err %v log %q", err, buffer.String())
|
||||
}
|
||||
if e.articles.articles[8].Body != bodiesBefore[8] || e.articles.articles[9].Body != bodiesBefore[9] {
|
||||
t.Errorf("expected both bodies back to their original text")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -330,7 +334,7 @@ func TestSeoDataCleanerCaseInsensitiveBodyMatches(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
seed(e)
|
||||
before := e.stored(9)
|
||||
merge := useCases.NewMergeMediaUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
result, err := merge.Merge(t.Context(), 5, []uint32{6})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -355,7 +354,7 @@ func TestSeoDataMediaMerge(t *testing.T) {
|
||||
seedDuplicates(e)
|
||||
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
|
||||
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
|
||||
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
merge := newTestMergeMedia(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
result, err := merge.Merge(testCtx, 5, []uint32{6})
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
}
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
|
||||
@@ -298,7 +298,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
sites := newMockSiteRepo(p.articles)
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
|
||||
|
||||
Reference in new issue
Block a user