refac: atomic merge, lost-update race, RunPurge cost, duplicated walks

This commit is contained in:
Florian Sylvain committed 2026-10-21 22:47:46 +02:00
1 parent 0fc3511e2b
commit 074944c99a
45 files changed
+2192 -255

No files matched your search

+4 -1
View File
@@ -12,6 +12,7 @@ import (
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
domainSite "RenewCMS/internal/domain/site" domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing" "RenewCMS/internal/infrastructure/auditing"
@@ -160,6 +161,7 @@ type Repositories struct {
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository VariantFileRepo domainImage.VariantFileRepository
VariantCacheRepo domainImage.VariantCacheRepository VariantCacheRepo domainImage.VariantCacheRepository
@@ -302,6 +304,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db), ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db), VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: variantFiles, VariantFileRepo: variantFiles,
VariantCacheRepo: variantFiles, VariantCacheRepo: variantFiles,
@@ -332,7 +335,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) 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) 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) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
+2 -2
View File
@@ -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 article 1's body is back to its original text, article 2 is unchanged
And images 2 and 3 still exist 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 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]} When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500 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 @manual
Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md) Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md)
+1 -1
View File
@@ -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. 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. 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. 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`. 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. 18. Layering guard: `go list -deps` or a grep-style test that `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package.
+274
View File
@@ -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
+80
View File
@@ -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.
+3 -2
View File
@@ -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 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 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 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 {} When the client sends POST /v1/media/purge with {}
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1 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 "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 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
+1 -1
View File
@@ -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. 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. 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. 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`. 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`). 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`. 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) == "" 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 { func MediaFields(fields []Field) []Field {
var media []Field var media []Field
for _, field := range fields { for _, field := range fields {
+12 -7
View File
@@ -51,12 +51,12 @@ func validateField(field contenttype.Field, data map[string]any) error {
value := data[field.Key] value := data[field.Key]
if value == nil { if value == nil {
if field.Required { if field.Required {
return invalidField(field.Key, RuleRequired) return InvalidField(field.Key, RuleRequired)
} }
return nil return nil
} }
if rule := field.Violation(value); rule != "" { if rule := field.Violation(value); rule != "" {
return invalidField(field.Key, rule) return InvalidField(field.Key, rule)
} }
return nil return nil
} }
@@ -76,7 +76,7 @@ func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
return nil return nil
} }
sort.Strings(unknown) sort.Strings(unknown)
return invalidField(unknown[0], RuleUnknownField) return InvalidField(unknown[0], RuleUnknownField)
} }
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue { 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 { func MediaIDs(fields []contenttype.Field, data map[string]any) map[string]uint32 {
ids := map[string]uint32{} ids := map[string]uint32{}
for _, field := range contenttype.MediaFields(fields) { for _, ref := range contenttype.MediaRefs(fields, data) {
if id, ok := contenttype.MediaID(data[field.Key]); ok { ids[ref.Field.Key] = ref.ID
ids[field.Key] = id
}
} }
return ids return ids
} }
func MissingMedia(field contenttype.Field, unchanged bool) error {
if unchanged {
return InvalidField(field.Key, RuleRequired)
}
return InvalidField(field.Key, contenttype.RuleType)
}
+1 -5
View File
@@ -55,12 +55,8 @@ func (e FieldError) Unwrap() error {
return e.Cause 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 { func InvalidField(key string, rule string) error {
return invalidField(key, rule) return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
} }
func UniqueViolation(key string) error { func UniqueViolation(key string) error {
+49
View File
@@ -246,3 +246,52 @@ type Image struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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),
})
})
}
+6 -6
View File
@@ -16,7 +16,7 @@ const (
) )
type Job interface { 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 { type CleanupJob interface {
@@ -24,15 +24,15 @@ type CleanupJob interface {
} }
type task 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 { type purgeTask struct {
job Job job Job
} }
func (p purgeTask) tick(now time.Time) (string, bool, error) { func (p purgeTask) tick(ctx context.Context, now time.Time) (string, bool, error) {
result, ran, err := p.job.Tick(now) result, ran, err := p.job.Tick(ctx, now)
if errors.Is(err, purge.ErrPurgeBusy) { if errors.Is(err, purge.ErrPurgeBusy) {
return "", false, nil return "", false, nil
} }
@@ -44,7 +44,7 @@ type cleanupTask struct {
job CleanupJob 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) result, ran, err := c.job.Tick(now)
return fmt.Sprintf("removed=%d", result.Removed), ran, err return fmt.Sprintf("removed=%d", result.Removed), ran, err
} }
@@ -119,7 +119,7 @@ func (r *Runner) tick(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
summary, ran, err := r.task.tick(r.now()) summary, ran, err := r.task.tick(ctx, r.now())
if err != nil { if err != nil {
r.logger.Printf("scheduler: %s failed: %v", r.label, err) r.logger.Printf("scheduler: %s failed: %v", r.label, err)
return return
@@ -8,7 +8,6 @@ import (
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"slices" "slices"
"strings"
) )
const ( const (
@@ -111,7 +110,7 @@ func (u *GetMediaUsageUseCase) articleUsages(img image.Image) ([]MediaUsage, err
} }
byID := map[uint32]*MediaUsage{} byID := map[uint32]*MediaUsage{}
for _, found := range inBody { for _, found := range inBody {
if !strings.Contains(found.Body, storedName) { if !image.ContainsStoredName(found.Body, storedName) {
continue continue
} }
entry := usageFor(byID, found) 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 { 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)
} }
+87 -87
View File
@@ -3,17 +3,14 @@ package useCases
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/transaction"
"cmp"
"context" "context"
"errors" "errors"
"log"
"slices" "slices"
"strconv"
"strings"
) )
type MergeMediaResult struct { type MergeMediaResult struct {
@@ -30,13 +27,13 @@ type MergeMediaUseCase struct {
articleRepository article.Repository articleRepository article.Repository
remover mediaRemover remover mediaRemover
recorder audit.Recorder recorder audit.Recorder
sites site.Repository
authors author.Repository
entries *entryMediaScanner 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 { 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, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)} 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 { func copyIdsOf(copies []image.Image) []uint32 {
@@ -47,22 +44,8 @@ func copyIdsOf(copies []image.Image) []uint32 {
return copyIds 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) { 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) event := audit.NewEvent(audit.ActionMediaMerge, audit.CategoryMedia)
if kept, found := u.keptImage(keepId); found { if kept, found := u.keptImage(keepId); found {
event.Target = imageTarget(kept) event.Target = imageTarget(kept)
@@ -84,7 +67,7 @@ func (u *MergeMediaUseCase) keptImage(keepId uint32) (image.Image, bool) {
return kept, err == nil 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 { if err := validateMergeRequest(keepId, mergeIds); err != nil {
return MergeMediaResult{}, err return MergeMediaResult{}, err
} }
@@ -98,51 +81,94 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
return MergeMediaResult{}, err return MergeMediaResult{}, err
} }
rewrites, err := u.planRewrites(kept, copies) planned, err := u.planRewrites(copies)
if err != nil { if err != nil {
return MergeMediaResult{}, err return MergeMediaResult{}, err
} }
if err := u.applyRewrites(rewrites); err != nil { if index == nil {
if index, err = u.entries.index(); err != nil {
return MergeMediaResult{}, err 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) result, err := u.applyMerge(ctx, kept, copies, planned, index.referencing(copyIdsOf(copies)))
if err != nil { if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err return MergeMediaResult{}, err
} }
u.discardCopies(copies)
repointedEntries, err := u.entries.repoint(copyIdsOf(copies), kept.ID) return result, nil
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
} }
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 { for _, duplicate := range copies {
if err := u.remover.remove(duplicate); err != nil { if err := tx.Images.Delete(duplicate.ID); err != nil {
return MergeMediaResult{}, err 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)
}
} }
} }
updatedIds := updatedArticleIds(rewrites, repointedArticles) func rewriteBodies(articles article.Repository, planned []uint32, copies []image.Image, kept image.Image) ([]uint32, error) {
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, EntriesUpdated: len(repointedEntries), UpdatedArticleIDs: updatedIds}, nil 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(rewrites []bodyRewrite, repointed []uint32) []uint32 { func updatedArticleIds(rewritten []uint32, repointed []uint32) []uint32 {
ids := slices.Clone(repointed) ids := append(slices.Clone(repointed), rewritten...)
for _, plan := range rewrites {
ids = append(ids, plan.articleId)
}
slices.Sort(ids) slices.Sort(ids)
return slices.Compact(ids) return slices.Compact(ids)
} }
@@ -179,10 +205,8 @@ func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]i
return copies, nil return copies, nil
} }
func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image) ([]bodyRewrite, error) { func (u *MergeMediaUseCase) planRewrites(copies []image.Image) ([]uint32, error) {
keptName := image.StoredName(kept.Path) seen := map[uint32]bool{}
rewritten := map[uint32]*bodyRewrite{}
for _, duplicate := range copies { for _, duplicate := range copies {
copyName := image.StoredName(duplicate.Path) copyName := image.StoredName(duplicate.Path)
articles, err := u.articleRepository.FindByBodyContaining(copyName) articles, err := u.articleRepository.FindByBodyContaining(copyName)
@@ -190,39 +214,15 @@ func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image)
return nil, err return nil, err
} }
for _, a := range articles { for _, a := range articles {
if !strings.Contains(a.Body, copyName) { if image.ContainsStoredName(a.Body, copyName) {
continue seen[a.ID] = true
}
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)
} }
} }
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) }) planned := make([]uint32, 0, len(seen))
return plans, nil for id := range seen {
} planned = append(planned, id)
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)
} }
slices.Sort(planned)
return planned, nil
} }
@@ -9,7 +9,7 @@ import (
) )
type PurgeRunner interface { type PurgeRunner interface {
Execute(fingerprint *string) (purge.RunResult, error) Execute(ctx context.Context, fingerprint *string) (purge.RunResult, error)
} }
type PurgeSchedulerUseCase struct { type PurgeSchedulerUseCase struct {
@@ -22,7 +22,7 @@ func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner, r
return &PurgeSchedulerUseCase{repository, runner, recorder} 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() settings, err := u.repository.Get()
if err != nil { if err != nil {
return purge.RunResult{}, false, err 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) { if next == nil || now.Before(*next) {
return purge.RunResult{}, false, nil return purge.RunResult{}, false, nil
} }
result, err := u.runner.Execute(nil) result, err := u.runner.Execute(ctx, nil)
if err != nil { if err != nil {
u.recordFailedRun(err) u.recordFailedRun(err)
return purge.RunResult{}, false, err return purge.RunResult{}, false, err
+12 -6
View File
@@ -36,7 +36,7 @@ func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository arti
} }
func (u *RunPurgeUseCase) Run(ctx context.Context, fingerprint *string) (purge.RunResult, error) { 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) event := audit.NewEvent(audit.ActionMediaPurge, audit.CategoryMedia)
if err != nil { if err != nil {
recordFailure(ctx, u.recorder, event, err) recordFailure(ctx, u.recorder, event, err)
@@ -55,7 +55,7 @@ func purgeParams(result purge.RunResult) map[string]any {
return params 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() { if !u.guard.TryLock() {
return purge.RunResult{}, purge.ErrPurgeBusy return purge.RunResult{}, purge.ErrPurgeBusy
} }
@@ -69,7 +69,10 @@ func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error)
return purge.RunResult{}, purge.ErrPurgeConflict 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 { if err := u.recordRun(result); err != nil {
u.logger.Printf("purge: could not record the run result: %v", err) u.logger.Printf("purge: could not record the run result: %v", err)
return purge.RunResult{}, err return purge.RunResult{}, err
@@ -77,11 +80,14 @@ func (u *RunPurgeUseCase) Execute(fingerprint *string) (purge.RunResult, error)
return result, nil 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)} result := purge.RunResult{GroupsTotal: len(plan.groups)}
updated := map[uint32]bool{} updated := map[uint32]bool{}
for _, group := range plan.groups { 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 { if err != nil {
result.FailedGroups++ result.FailedGroups++
continue continue
@@ -95,7 +101,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
} }
} }
result.ArticlesUpdated = len(updated) result.ArticlesUpdated = len(updated)
return result return result, nil
} }
func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error { func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error {
+84 -70
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"cmp" "cmp"
"errors" "errors"
"maps"
"slices" "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) { func (r *mediaResolver) resolve(owner contenttype.ContentType, record entry.Entry) (map[string]EntryMedia, error) {
resolved := map[string]EntryMedia{} resolved := map[string]EntryMedia{}
for _, field := range contenttype.MediaFields(owner.Fields) { for _, ref := range contenttype.MediaRefs(owner.Fields, record.Data) {
id, set := contenttype.MediaID(record.Data[field.Key]) media, err := r.lookup(ref.ID)
if !set {
continue
}
media, err := r.lookup(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolved[field.Key] = media resolved[ref.Field.Key] = media
} }
return resolved, nil return resolved, nil
} }
@@ -73,6 +70,63 @@ type mediaEntry struct {
record entry.Entry 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 { type entryMediaScanner struct {
contentTypes contenttype.Repository contentTypes contenttype.Repository
entries entry.Repository entries entry.Repository
@@ -82,7 +136,7 @@ func newEntryMediaScanner(contentTypes contenttype.Repository, entries entry.Rep
return &entryMediaScanner{contentTypes: contentTypes, entries: entries} return &entryMediaScanner{contentTypes: contentTypes, entries: entries}
} }
func (s *entryMediaScanner) scan() ([]mediaEntry, error) { func (s *entryMediaScanner) index() (*entryMediaIndex, error) {
types, err := s.contentTypes.List() types, err := s.contentTypes.List()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -100,91 +154,51 @@ func (s *entryMediaScanner) scan() ([]mediaEntry, error) {
found = append(found, mediaEntry{owner: owner, record: record}) found = append(found, mediaEntry{owner: owner, record: record})
} }
} }
slices.SortFunc(found, func(a, b mediaEntry) int { return newEntryMediaIndex(found), nil
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
} }
func (s *entryMediaScanner) usageCounts() (map[uint32]int, error) { func (s *entryMediaScanner) usageCounts() (map[uint32]int, error) {
found, err := s.scan() index, err := s.index()
if err != nil { if err != nil {
return nil, err return nil, err
} }
counts := map[uint32]int{} return index.usageCounts(), nil
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
} }
func (s *entryMediaScanner) referencing(imageID uint32) ([]mediaEntry, error) { func (s *entryMediaScanner) referencing(imageID uint32) ([]mediaEntry, error) {
found, err := s.scan() index, err := s.index()
if err != nil { if err != nil {
return nil, err return nil, err
} }
targets := map[uint32]bool{imageID: true} return index.referencing([]uint32{imageID}), nil
return slices.DeleteFunc(found, func(candidate mediaEntry) bool {
return len(candidate.keysPointingAt(targets)) == 0
}), nil
} }
type entryRepoint struct { func repointEntries(entries entry.Repository, candidates []mediaEntry, copyIDs []uint32, keptID uint32) (int, error) {
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
}
copies := map[uint32]bool{} copies := map[uint32]bool{}
for _, id := range copyIDs { for _, id := range copyIDs {
copies[id] = true copies[id] = true
} }
applied := []entryRepoint{} updated := 0
for _, candidate := range found { for _, candidate := range candidates {
keys := candidate.keysPointingAt(copies) 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 { if len(keys) == 0 {
continue continue
} }
change := entryRepoint{original: candidate.record, updated: candidate.record} current.Data = maps.Clone(current.Data)
change.updated.Data = make(map[string]any, len(candidate.record.Data))
for key, value := range candidate.record.Data {
change.updated.Data[key] = value
}
for _, key := range keys { for _, key := range keys {
change.updated.Data[key] = float64(keptID) current.Data[key] = float64(keptID)
} }
if _, err := s.entries.Update(change.updated); err != nil { if _, err := entries.Update(current); err != nil {
s.restore(applied) return updated, err
return nil, err
} }
applied = append(applied, change) updated++
}
return applied, nil
}
func (s *entryMediaScanner) restore(applied []entryRepoint) {
for _, change := range applied {
_, err := s.entries.Update(change.original)
logCompensation("restore entry media", err)
} }
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 { func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
storedIDs := entry.MediaIDs(owner.Fields, stored) storedIDs := entry.MediaIDs(owner.Fields, stored)
for _, field := range contenttype.MediaFields(owner.Fields) { for _, ref := range contenttype.MediaRefs(owner.Fields, data) {
id, set := contenttype.MediaID(data[field.Key]) unchanged := stored != nil && storedIDs[ref.Field.Key] == ref.ID
if !set { if unchanged && !ref.Field.Required {
continue continue
} }
unchanged := stored != nil && storedIDs[field.Key] == id exists, err := imageExists(images, ref.ID)
if unchanged && !field.Required {
continue
}
exists, err := imageExists(images, id)
if err != nil { if err != nil {
return err return err
} }
if exists { if !exists {
continue return entry.MissingMedia(ref.Field, unchanged)
} }
if unchanged {
return entry.InvalidField(field.Key, entry.RuleRequired)
}
return entry.InvalidField(field.Key, contenttype.RuleType)
} }
return nil return nil
} }
@@ -1,6 +1,9 @@
package useCases package useCases
import "RenewCMS/internal/domain/image" import (
"RenewCMS/internal/domain/image"
"errors"
)
type mediaRemover struct { type mediaRemover struct {
imageRepository image.Repository imageRepository image.Repository
@@ -22,3 +25,8 @@ func (r mediaRemover) remove(img image.Image) error {
} }
return r.imageRepository.Delete(img.ID) 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))
}
+11 -2
View File
@@ -23,6 +23,7 @@ type purgePlan struct {
copies int copies int
articles int articles int
fingerprint string fingerprint string
entries *entryMediaIndex
} }
type purgePlanner struct { type purgePlanner struct {
@@ -43,7 +44,15 @@ func (p *purgePlanner) plan() (purgePlan, error) {
return purgePlan{}, err 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 { if err != nil {
return purgePlan{}, err 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) }) 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 { 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) { 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) usage, err := siteImageUsage(sites)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -31,11 +35,11 @@ func imageUsageCount(sites site.Repository, authors author.Repository, entries *
for imageID, total := range avatars { for imageID, total := range avatars {
usage[imageID] += total usage[imageID] += total
} }
entryCounts, err := entries.usageCounts() counts, err := entryCounts()
if err != nil { if err != nil {
return nil, err return nil, err
} }
for imageID, total := range entryCounts { for imageID, total := range counts {
usage[imageID] += total usage[imageID] += total
} }
return usage, nil return usage, nil
+8 -8
View File
@@ -58,7 +58,7 @@ type producerPurgeRunner struct {
var _ useCases.PurgeRunner = &producerPurgeRunner{} var _ useCases.PurgeRunner = &producerPurgeRunner{}
func (r *producerPurgeRunner) Execute(*string) (purge.RunResult, error) { func (r *producerPurgeRunner) Execute(context.Context, *string) (purge.RunResult, error) {
r.calls++ r.calls++
return r.result, r.err return r.result, r.err
} }
@@ -245,7 +245,7 @@ func TestActivityLogProducersFailureReasons(t *testing.T) {
t.Helper() t.Helper()
h := newActivityHarness(t) h := newActivityHarness(t)
scheduler := useCases.NewPurgeSchedulerUseCase(dueSettings(), &producerPurgeRunner{err: err}, h.recorder) 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) t.Fatalf("tick err = %v", tickErr)
} }
return onlyEvent(t, h) 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}} runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 2, GroupsPurged: 2, Merged: 3, ArticlesUpdated: 5}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder) 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) 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}} runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 3, GroupsPurged: 2, FailedGroups: 1, Merged: 2, ArticlesUpdated: 2}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder) 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) t.Fatal(err)
} }
@@ -356,10 +356,10 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
notDue := newMockPurgeSettingsRepo() notDue := newMockPurgeSettingsRepo()
runner := &producerPurgeRunner{} 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) 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) t.Fatalf("ran = %v, err = %v", ran, err)
} }
assertNoEvents(t, h) assertNoEvents(t, h)
@@ -369,7 +369,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
runner := &producerPurgeRunner{err: errRepositoryFailure} 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") 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{})} images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo() articles := newMockArticleRepo()
storage := newMockImageStorage() 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()) 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{}) finished := make(chan struct{})
+1 -1
View File
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
mail := newMockMailRepo() mail := newMockMailRepo()
env.mail = mail env.mail = mail
renderer := &fakeBodyRenderer{} 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() purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{} restartJobs := &fakeJobStore{}
env.container = &api.UseCases{ env.container = &api.UseCases{
+6
View File
@@ -25,6 +25,8 @@ type mockArticleRepo struct {
authors *mockAuthorRepo authors *mockAuthorRepo
reportSlugRace bool reportSlugRace bool
createAlwaysRaces bool createAlwaysRaces bool
afterFindByBody func()
updateBodyIDs []uint32
} }
func newMockArticleRepo() *mockArticleRepo { 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) { 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 { if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err return article.Article{}, err
} }
@@ -427,6 +430,9 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
result = append(result, a) result = append(result, a)
} }
} }
if m.afterFindByBody != nil {
defer m.afterFindByBody()
}
return result, nil return result, nil
} }
+4 -1
View File
@@ -321,6 +321,9 @@ func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error
} }
func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) { func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
if err := m.fail.trigger("RepointAvatar"); err != nil {
return 0, err
}
changed := 0 changed := 0
for id, a := range m.authors { for id, a := range m.authors {
if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) { if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) {
@@ -355,7 +358,7 @@ func newAuthorEnv(t *testing.T) *authorEnv {
sites := plainSites() sites := plainSites()
recorder := p.audit.recorder recorder := p.audit.recorder
renderer := &fakeBodyRenderer{} 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 := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
+20
View File
@@ -37,6 +37,7 @@ type mockContentTypeRepo struct {
createCalls int createCalls int
updateCalls int updateCalls int
deleteCalls int deleteCalls int
listCalls int
fail failSwitch fail failSwitch
} }
@@ -70,6 +71,7 @@ func (m *mockContentTypeRepo) GetByKey(key string) (contenttype.ContentType, err
} }
func (m *mockContentTypeRepo) List() ([]contenttype.ContentType, error) { func (m *mockContentTypeRepo) List() ([]contenttype.ContentType, error) {
m.listCalls++
if err := m.fail.trigger("List"); err != nil { if err := m.fail.trigger("List"); err != nil {
return nil, err return nil, err
} }
@@ -104,6 +106,12 @@ type mockEntryRepo struct {
nextID uint32 nextID uint32
createCalls int createCalls int
updateCalls int updateCalls int
getCalls int
listPageCalls int
failGetOn int
failUpdateOn int
updatedIDs []uint32
afterListPage func()
} }
var _ entry.Repository = &mockEntryRepo{} 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) { 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 { if stored, found := m.entries[id]; found && stored.ContentTypeID == contentTypeID {
return stored, nil 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) { func (m *mockEntryRepo) ListPage(filters entry.Filters) (entry.PageResult, error) {
m.listPageCalls++
if m.afterListPage != nil {
defer m.afterListPage()
}
matching := []entry.Entry{} matching := []entry.Entry{}
for _, stored := range m.entries { for _, stored := range m.entries {
if stored.ContentTypeID == filters.ContentTypeID { 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) { func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
m.updateCalls++ 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 m.entries[record.ID] = record
return record, nil return record, nil
} }
+1 -1
View File
@@ -115,7 +115,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles), 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), ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder), CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder), UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
+1 -2
View File
@@ -2,7 +2,6 @@ package test
import ( import (
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"reflect" "reflect"
"testing" "testing"
) )
@@ -25,7 +24,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3)) env.seedArticle(id, "Article", true, env.urlOf(3))
} }
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles} 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 { if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
+1 -1
View File
@@ -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[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1} 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 } now := func() time.Time { return env.clock }
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
+1 -1
View File
@@ -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{}} env := &mediaFieldEnv{contentTypeEnv: base, articles: newMockArticleRepo(), spy: &mfEntrySpyRepo{mockEntryRepo: base.entries}, lookups: &mfImageSpyRepo{mockImageRepo: base.images}, logs: &bytes.Buffer{}}
settings := newMockPurgeSettingsRepo() settings := newMockPurgeSettingsRepo()
now := func() time.Time { return base.clock } 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 := base.container
container.CreateEntryUseCase = useCases.NewCreateEntryUseCase(base.types, base.entries, env.lookups, now) container.CreateEntryUseCase = useCases.NewCreateEntryUseCase(base.types, base.entries, env.lookups, now)
container.GetEntryUseCase = useCases.NewGetEntryUseCase(base.types, base.entries, env.lookups) container.GetEntryUseCase = useCases.NewGetEntryUseCase(base.types, base.entries, env.lookups)
+6 -6
View File
@@ -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) { t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(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}) _, 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}) 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 := newEnv(t)
env.images.failDeleteFor = map[uint32]error{3: errRepositoryFailure} 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) rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code) 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)) { if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
t.Errorf("expected article 1 to use image 2's url, got %q", env.articles.articles[1].Body) t.Errorf("expected every body back to its original text, got %v", env.bodies())
} }
if _, ok := env.images.images[3]; !ok { if _, ok := env.images.images[3]; !ok {
t.Errorf("expected image 3 to still exist") 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(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1) env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.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) { if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err) t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+97
View File
@@ -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
+160
View File
@@ -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))
}
+9 -3
View File
@@ -743,16 +743,22 @@ func TestPurgeRun(t *testing.T) {
env.useImages(22, 3, 4) env.useImages(22, 3, 4)
env.useImages(23, 3) env.useImages(23, 3)
env.images.failDeleteFor = map[uint32]error{2: errRepositoryFailure} 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)) expectRaw(t, env.run(t, `{}`), 200, counters(2, 1, 1, 1, 1))
if strings.Contains(env.bodies()[20], env.urlOf(2)) { if env.bodies()[20] != originalA {
t.Errorf("expected article 20 to stay switched to the kept copy") t.Errorf("expected group A's bodies back to their original text")
} }
if got := numberOf(env.preview(t)["groups"]); got != 1 { if got := numberOf(env.preview(t)["groups"]); got != 1 {
t.Errorf("expected group A to be planned again, got %d groups", got) t.Errorf("expected group A to be planned again, got %d groups", got)
} }
env.images.failDeleteFor = nil 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) { t.Run("Every group failing is still a 200 with the counters", func(t *testing.T) {
+5 -5
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv { func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper() t.Helper()
env := newPurgeEnv(t) 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 } 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()) runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors())
return &schedulerEnv{ return &schedulerEnv{
@@ -64,7 +64,7 @@ func (e *schedulerEnv) seedPlannedGroup() {
func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) { func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) {
t.Helper() t.Helper()
_, ran, err := e.scheduler.Tick(*purgeTime(at)) _, ran, err := e.scheduler.Tick(testCtx, *purgeTime(at))
return ran, err return ran, err
} }
@@ -294,7 +294,7 @@ func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
env.configureDue() env.configureDue()
env.seedPlannedGroup() 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} want := purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1, ArticlesUpdated: 1}
if err != nil || !ran || result != want { 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) { t.Run("A Tick that is not due returns an empty result", func(t *testing.T) {
env := newSchedulerEnv(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{}) { if err != nil || ran || result != (purge.RunResult{}) {
t.Errorf("unexpected %+v, %v, %v", result, ran, err) 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)} 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.mu.Lock()
f.calls = append(f.calls, now) f.calls = append(f.calls, now)
var result schedulerJobResult var result schedulerJobResult
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) { func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase { 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()) 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(1, 2)
env.useImages(2, 1, 2) env.useImages(2, 1, 2)
env.useImages(3, 1) 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}) result, err := merge.Merge(testCtx, 1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2) env.useImages(1, 2)
env.useImages(2, 2) env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure} 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}) result, err := merge.Merge(testCtx, 1, []uint32{2})
+1 -1
View File
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder) create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create) 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 { if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err) t.Fatalf("merge returned %+v, %v", result, err)
+1 -1
View File
@@ -146,7 +146,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()), 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()), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
} }
} }
+8 -4
View File
@@ -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 := newSeoEnv(t)
e.seedBanner(5, "a.png", "same-hash") e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.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(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) }) e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
buffer := captureLog(t) buffer := captureLog(t)
bodiesBefore := map[uint32]string{8: e.articles.articles[8].Body, 9: e.articles.articles[9].Body}
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles} 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}) _, 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()) 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) e := newSeoEnv(t)
seed(e) seed(e)
before := e.stored(9) 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}) result, err := merge.Merge(t.Context(), 5, []uint32{6})
+1 -2
View File
@@ -2,7 +2,6 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -355,7 +354,7 @@ func TestSeoDataMediaMerge(t *testing.T) {
seedDuplicates(e) seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) }) e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = 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}) result, err := merge.Merge(testCtx, 5, []uint32{6})
+1 -1
View File
@@ -53,7 +53,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
} }
recorder := p.audit.recorder recorder := p.audit.recorder
renderer := &fakeBodyRenderer{} 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 := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
+1 -1
View File
@@ -298,7 +298,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
sites := newMockSiteRepo(p.articles) sites := newMockSiteRepo(p.articles)
recorder := p.audit.recorder recorder := p.audit.recorder
renderer := &fakeBodyRenderer{} 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 := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors()) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)