diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 32a7b41..3e456fd 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -10,6 +10,7 @@ import ( domainEntry "RenewCMS/internal/domain/entry" domainImage "RenewCMS/internal/domain/image" domainInvite "RenewCMS/internal/domain/invite" + domainLanguage "RenewCMS/internal/domain/language" domainMail "RenewCMS/internal/domain/mail" domainPurge "RenewCMS/internal/domain/purge" domainRole "RenewCMS/internal/domain/role" @@ -176,6 +177,7 @@ func (c *UseCases) RunSchedulers(ctx context.Context) { type Repositories struct { ArticleRepo domainArticle.Repository SiteRepo domainSite.Repository + LanguageRepo domainLanguage.Repository ContentTypeRepo domainContentType.Repository EntryRepo domainEntry.Repository AuthorRepo domainAuthor.Repository @@ -324,6 +326,7 @@ func getRepositories(connection *database.Connection, settings config.Settings) return &Repositories{ ArticleRepo: persistence.NewArticleRepository(db), SiteRepo: persistence.NewSiteRepository(db), + LanguageRepo: persistence.NewSiteLanguageRepository(db), ContentTypeRepo: persistence.NewContentTypeRepository(db), EntryRepo: persistence.NewEntryRepository(db), AuthorRepo: persistence.NewAuthorRepository(db), @@ -380,7 +383,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()), Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)), - CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo), diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 78c8d56..1e0ffd3 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -59,6 +59,8 @@ type baseArticleResponse struct { Authors []author.Byline `json:"authors"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Lang string `json:"lang"` + StoryID uint32 `json:"story_id"` } type articleResponse struct { @@ -117,6 +119,8 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc Authors: authors, CreatedAt: article.CreatedAt, UpdatedAt: article.UpdatedAt, + Lang: article.Lang, + StoryID: article.StoryID, }, nil } diff --git a/docs/api/articles.md b/docs/api/articles.md index 1dc12b4..441c568 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -87,10 +87,14 @@ Breaking change: the default used to be HTML. Clients that need HTML must send ` "noindex": false, "seo_dismissed": [], "created_at": "2026-03-12T09:14:00Z", - "updated_at": "2026-09-10T07:00:00Z" + "updated_at": "2026-09-10T07:00:00Z", + "lang": "fr-CA", + "story_id": 5 } ``` +`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only: `lang` and `story_id` sent in a `POST` or `PATCH` body are ignored. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed. + `created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish. Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten. @@ -116,7 +120,7 @@ Limits are counted in characters (runes), not bytes. Absent fields are untouched ### The slug -A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site. +A slug is unique per site and language (`site_id`, `lang`, `slug`), never globally; unassigned articles (`lang` `""`) share the empty language, so they keep the per-site rule. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site. Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `-N` from 2 on that site, fitting 100 characters. `X-Slug-Suggestion` is set on this `409` only and on no other response. A request with `slug: ""` that regenerates onto another article's slug is refused the same way and never suffixed silently. Re-sending an article's own slug is not a conflict. A refused request changes nothing. diff --git a/docs/architecture.md b/docs/architecture.md index e8ab100..3563e3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,9 +94,9 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, ` `internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package. -Slugs are unique per site, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows. +Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows. -`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug, in id order, per site, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`. +`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`. Media usage has more sources than article bodies: `Article.CoverImageID` and `SocialImageID`, `Author.AvatarID` and `Site.DefaultSocialImageID`. `ListMedia`, the purge plan and `GetMediaUsage` count an article once however many places use the image. `DeleteMedia` clears the cover and social references (`article.Repository.ClearImage`, restored with `RestoreImage` when the delete fails), `MergeMedia` repoints them (`RepointImage`) and counts an article rewritten in its body and its cover once, and `GetMediaUsage` (now built with the site and author repositories) returns articles, authors and sites with `type` and `fields`. diff --git a/features/article_localization_storage.feature b/features/article_localization_storage.feature new file mode 100644 index 0000000..f43a35c --- /dev/null +++ b/features/article_localization_storage.feature @@ -0,0 +1,150 @@ +Feature: Article language, story and per-site language storage + Articles gain a full BCP 47 language tag, a story they belong to and a + content revision. Each site gets a language table with one default. Existing + articles keep their ids, bodies, publication and public API. They are + migrated without a guessed language: each gets its own story and an + unassigned language until the owner confirms the site default. + + Background: + Given the supported languages are "en", "en-GB", "en-US", "fr", "fr-CA", "pt", "pt-BR", "es", "de", "ar" + And a language tag keeps its region, "pt-BR" is never reduced to "pt" + And the domain package internal/domain/language imports only the stdlib + And persistence is portable across SQLite, MySQL and PostgreSQL + + Scenario Outline: Language tag normalization + When the tag "" is normalized + Then the result is "" + + Examples: + | input | result | + | en | en | + | EN | en | + | pt-br | pt-BR | + | PT_BR | ErrInvalidLanguage | + | fr-ca | fr-CA | + | fr | fr | + | e | ErrInvalidLanguage | + | english | ErrInvalidLanguage | + | en-ZZZZ | ErrInvalidLanguage | + | | ErrInvalidLanguage | + | xx | ErrUnsupportedLanguage | + + Scenario Outline: Suggestion for a site's default language from Site.Locale + Given a site whose Locale is "" + When the default language suggestion is computed + Then the suggestion is "" and the substitution flag is + + Examples: + | locale | suggested | substituted | + | pt-BR | pt-BR | false | + | en-GB | en-GB | false | + | fr-BE | fr | true | + | fr | fr | false | + | xx-YY | none | false | + | null | none | false | + + Scenario: Migration of an existing site and its articles + Given a database created before this change with site "default" (Locale "fr-CA") and articles 1, 2 and 3 in it, article 3 online + When database.Migrate runs + Then every article keeps its id, title, body, slug, is_online and timestamps + And each article has lang "" and its own distinct story_id + And each article has content_revision 1 + And site "default" has one site_languages row "fr-CA" with is_default true, enabled true and confirmed false + And the migration marker for the article story backfill is recorded + + Scenario: Site without a usable locale gets no suggestion row + Given a site with Locale null + When database.Migrate runs + Then the site has no site_languages row + + Scenario: Migration runs twice + Given a migrated database + When database.Migrate runs again + Then no story is created or changed, no article changes and no site_languages row changes + And the schema is identical + + Scenario: Migration is atomic + Given a database with 3 existing articles + And the backfill write fails while handling the second article + When database.Migrate runs + Then no article has a story_id and the marker is not recorded + And the next Migrate completes the whole backfill + + Scenario: Soft-deleted articles are migrated + Given an article that is soft-deleted before the change + When database.Migrate runs + Then it has its own story_id and its tombstone slug is unchanged + + Scenario: Slug is unique per site and language + Given site 1 holds an article with slug "bonjour" in language "fr" + When another article is created in site 1, language "fr", slug "bonjour" + Then the write fails with a unique violation mapped to ErrSlugTaken + When an article with slug "bonjour" is created in site 1, language "en" + Then it is stored + When an article with slug "bonjour" is created in site 2, language "fr" + Then it is stored + + Scenario: Existing unassigned articles keep the current per-site slug rule + Given two unassigned articles in site 1 + When the second is written with the first one's slug + Then ErrSlugTaken is returned + + Scenario: One version per language in a story + Given story 7 holds an article in language "fr" + When a second article is stored in story 7, language "fr" + Then the write fails with a unique violation + When an article is stored in story 7, language "en" + Then it is stored + + Scenario: Unassigned articles never collide on language + Given 3 articles each with lang "" and their own story + Then all 3 are stored + + Scenario: Creating an article before the default is confirmed + Given site "default" has an unconfirmed suggestion "fr-CA" + When a user creates an article with title "Bonjour le monde" + Then the article has lang "" + And it has a new story_id and content_revision 1 + + Scenario: Creating an article after the default is confirmed + Given site "default" has a confirmed default "fr-CA" + When a user creates an article with title "Bonjour le monde" + Then the article has lang "fr-CA" + And it has a new story_id distinct from every other article + + Scenario: Create racing a confirmation never strands an article + Given site "default" has unassigned articles + When the default is confirmed and assigned to unassigned articles in one transaction while articles are created concurrently + Then every article of the site ends with the confirmed default + And no article has a different language than the confirmed default + + Scenario: Assigning a language is idempotent + Given a site whose unassigned articles were assigned "fr-CA" + When the assignment runs again + Then no article changes + + Scenario Outline: Article reads expose lang and story_id + Given article 12 with lang "" in story 5 + When it is read through "" + Then the JSON contains "lang": "" and "story_id": 5 + And every key present before this change is present with an unchanged value and type + + Examples: + | lang | read | + | fr-CA | GET /v1/article/12 with a valid jwt | + | | GET /v1/article/12 with a valid jwt | + | fr-CA | GET /v1/article/ list item | + | fr-CA | public article read of an online article | + + Scenario: Article write payloads cannot set lang or story_id + When POST or PATCH /v1/article carries "lang" or "story_id" + Then the fields are ignored and the stored values are unchanged + + Scenario: Existing behaviour is unchanged + Then the existing article, pagination, publishing and SEO tests pass without edits + And offline articles still read as not found publicly + + Scenario: Persistence portability + Given the repository contract cases above + Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL + And no raw SQL uses a dialect-only construct diff --git a/features/article_localization_storage.qa.md b/features/article_localization_storage.qa.md new file mode 100644 index 0000000..0d334a3 --- /dev/null +++ b/features/article_localization_storage.qa.md @@ -0,0 +1,28 @@ +# QA: Article localization storage (backend) + +Backend only. Go tests in test/, black-box. No design states. No new endpoints. + +## Automated, no server (task test) +1. Language domain: table-driven over "Language tag normalization" and "Suggestion for a site's default language". Assert the sentinel errors with errors.Is. +2. Use case: with fake repositories, run "Creating an article before the default is confirmed" and "after the default is confirmed". Assert that CreateArticle runs inside the Transactor, that the starting language is obtained through LockStartingLanguage of the site language repository (under the site-row lock) and passed to Create as Lang, and that the story is created by the article repository inside the same transaction. Existing create tests stay green. +3. HTTP: NewHandler over use cases with in-memory fakes, a fresh chi.NewRouter mirroring api/router.go, validator.InitValidator(), httptest. Cover "Article reads expose lang and story_id" for GET /v1/article/{id} with a jwt cookie signed by JWT_SECRET (the test secret), the list GET /v1/article/, and the public read of an online article. Assert that old keys are unchanged, that lang and story_id are the added keys, and that unknown "lang" or "story_id" in the request body is ignored. Errors stay plain text. +4. Regression: run the full existing article, pagination, publishing and SEO tests untouched. + +## Automated, SQLite file (task test) +5. Fixture: a pre-change database built with the old models, holding a site with Locale "fr-CA", 3 articles (1 online, 1 soft-deleted) and article authors. Run Migrate and check the "Migration of an existing site" scenario. Check ids, bodies and slugs are unchanged, distinct story_ids, content_revision 1, the confirmed=false suggestion row, and the marker row. +6. Migrate twice: nothing changes. Compare sqlite_master and a hash of all rows. +7. Atomicity: inject a failing write (a wrapped *gorm.DB callback that errors on the second update). Assert no story_id is set and the marker is absent, then re-run successfully. +8. Unique indexes: (story_id, lang) and (site_id, lang, slug) reject duplicates, and lang "" rows with different stories coexist. The old (site_id, slug) index definition is replaced by (site_id, lang, slug) under the same name idx_articles_site_slug, kept so existing tests keep passing. +9. Concurrency: N goroutines create articles while the confirm-and-assign transaction runs. Assert the final state of "Create racing a confirmation". On SQLite the repository sets busy_timeout only (no WAL), so the test retries SQLITE_BUSY, counts the retries and asserts a bound. + +## Automated, real databases (task test:mysql, task test:postgres) +10. Repeat 5-9 on MySQL 8.4, MariaDB 11 and PostgreSQL. Compare the unique indexes through information_schema or pg_indexes. Record NOT RUN if the daemon is down, or use the podman shim (see memory). +11. On MySQL and MariaDB, check the index key lengths (utf8mb4): lang size is at most 35 and the slug index is within limits. + +## Build and lint +12. task test, task lint:go, and CGO_ENABLED=0 go build -tags nofrontend ./... pass. +13. docs/ gains the new article fields in the HTTP contract (lang, story_id) for the SPA slices. +14. task mutate scoped to the new use case code with MUTATE_FILES. No comments in the added Go files. + +## Manual +15. Back up a real database copy with article rows, start the new binary on it, check the articles list and one public read in a browser (no shape change), and restart to check the migration does not run twice. diff --git a/internal/domain/article/articleDomain.go b/internal/domain/article/articleDomain.go index 7583638..361e0f6 100644 --- a/internal/domain/article/articleDomain.go +++ b/internal/domain/article/articleDomain.go @@ -17,7 +17,7 @@ var ( ErrArticleOffline = errors.New("article is offline") ErrInvalidSlug = errors.New("invalid slug") - ErrSlugTaken = errors.New("another article on this site already uses that slug") + ErrSlugTaken = errors.New("another article on this site in that language already uses that slug") ErrInvalidCanonicalURL = errors.New("invalid canonical url") ) @@ -134,6 +134,9 @@ type Article struct { SEODismissed []string `json:"seo_dismissed"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Lang string `json:"lang"` + StoryID uint32 `json:"story_id"` + ContentRevision uint32 `json:"-"` } func (a Article) UsesImage(imageID uint32) bool { diff --git a/internal/domain/article/articleRepositoryInterface.go b/internal/domain/article/articleRepositoryInterface.go index c851636..883ce90 100644 --- a/internal/domain/article/articleRepositoryInterface.go +++ b/internal/domain/article/articleRepositoryInterface.go @@ -102,7 +102,7 @@ type Repository interface { Delete(id uint32) error FindByBodyContaining(filename string) ([]Article, error) FindByImage(imageID uint32) ([]Article, error) - SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) + SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) ClearImage(imageID uint32) (ImageReferences, error) RestoreImage(references ImageReferences, imageID uint32) error RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) diff --git a/internal/domain/language/languageDomain.go b/internal/domain/language/languageDomain.go new file mode 100644 index 0000000..84b7455 --- /dev/null +++ b/internal/domain/language/languageDomain.go @@ -0,0 +1,74 @@ +package language + +import ( + "errors" + "regexp" + "slices" + "strings" +) + +var ( + ErrInvalidLanguage = errors.New("invalid language tag") + ErrUnsupportedLanguage = errors.New("unsupported language") + ErrNoDefaultLanguage = errors.New("the site has no default language") +) + +const ( + Unassigned = "" + MaxTagLength = 35 +) + +var supported = []string{"en", "en-GB", "en-US", "fr", "fr-CA", "pt", "pt-BR", "es", "de", "ar"} + +var tagPattern = regexp.MustCompile(`^([A-Za-z]{2,3})(?:-([A-Za-z]{2}))?$`) + +func Supported() []string { + return slices.Clone(supported) +} + +func Normalize(tag string) (string, error) { + parts := tagPattern.FindStringSubmatch(strings.TrimSpace(tag)) + if parts == nil { + return "", ErrInvalidLanguage + } + normalized := strings.ToLower(parts[1]) + if parts[2] != "" { + normalized += "-" + strings.ToUpper(parts[2]) + } + if !slices.Contains(supported, normalized) { + return "", ErrUnsupportedLanguage + } + return normalized, nil +} + +type Suggestion struct { + Tag string + Found bool + Substituted bool +} + +func Suggest(locale *string) Suggestion { + if locale == nil { + return Suggestion{} + } + tag, err := Normalize(*locale) + if err == nil { + return Suggestion{Tag: tag, Found: true} + } + if !errors.Is(err, ErrUnsupportedLanguage) { + return Suggestion{} + } + base, _, _ := strings.Cut(strings.TrimSpace(*locale), "-") + if fallback, err := Normalize(base); err == nil { + return Suggestion{Tag: fallback, Found: true, Substituted: true} + } + return Suggestion{} +} + +type SiteLanguage struct { + SiteID uint32 + Tag string + IsDefault bool + Enabled bool + Confirmed bool +} diff --git a/internal/domain/language/languageRepositoryInterface.go b/internal/domain/language/languageRepositoryInterface.go new file mode 100644 index 0000000..407135f --- /dev/null +++ b/internal/domain/language/languageRepositoryInterface.go @@ -0,0 +1,7 @@ +package language + +type Repository interface { + ListBySite(siteID uint32) ([]SiteLanguage, error) + LockStartingLanguage(siteID uint32) (string, error) + ConfirmDefault(siteID uint32) error +} diff --git a/internal/domain/transaction/transactionDomain.go b/internal/domain/transaction/transactionDomain.go index c464ac8..e1252c2 100644 --- a/internal/domain/transaction/transactionDomain.go +++ b/internal/domain/transaction/transactionDomain.go @@ -5,16 +5,18 @@ import ( "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/image" + "RenewCMS/internal/domain/language" "RenewCMS/internal/domain/site" "context" ) type Repositories struct { - Articles article.Repository - Sites site.Repository - Authors author.Repository - Entries entry.Repository - Images image.Repository + Articles article.Repository + Sites site.Repository + Authors author.Repository + Entries entry.Repository + Images image.Repository + Languages language.Repository } type Transactor interface { diff --git a/internal/infrastructure/persistence/database/articleSlugBackfill.go b/internal/infrastructure/persistence/database/articleSlugBackfill.go index e24738c..eac2f5f 100644 --- a/internal/infrastructure/persistence/database/articleSlugBackfill.go +++ b/internal/infrastructure/persistence/database/articleSlugBackfill.go @@ -11,11 +11,13 @@ import ( const ( articleSlugIndexName = "idx_articles_site_slug" articleSlugBackfillBatchSize = 500 - articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)" + articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, lang, slug)" + articleSlugLanguageMarker = "article_slug_index_per_language" ) type slugOwner struct { siteID uint32 + lang string slug string } @@ -26,7 +28,7 @@ type slugAssignment struct { func BackfillArticleSlugs(db *gorm.DB) error { var rows []entity.Article - if err := db.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil { + if err := db.Unscoped().Select("id", "site_id", "lang", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil { return err } assignments := planSlugAssignments(rows) @@ -60,7 +62,7 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment { } continue } - owner := slugOwner{row.SiteID, row.Slug} + owner := slugOwner{row.SiteID, row.Lang, row.Slug} if row.Slug == "" || claimed[owner] { pending = append(pending, row) continue @@ -74,25 +76,41 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment { base = snapshotSlug(row.Title) attempt = 1 } - slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt) - claimed[slugOwner{row.SiteID, slug}] = true + slug := firstFreeSnapshotSlug(claimed, row.SiteID, row.Lang, base, attempt) + claimed[slugOwner{row.SiteID, row.Lang, slug}] = true assignments = append(assignments, slugAssignment{row.ID, slug}) } return assignments } -func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string { +func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, lang string, base string, attempt int) string { for ; ; attempt++ { candidate := snapshotSlugWithSuffix(base, attempt) - if !claimed[slugOwner{siteID, candidate}] { + if !claimed[slugOwner{siteID, lang, candidate}] { return candidate } } } func ensureArticleSlugIndex(db *gorm.DB) error { - if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) { + replaced, err := markerApplied(db, articleSlugLanguageMarker) + if err != nil { + return err + } + indexed := db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) + if replaced && indexed { return nil } - return db.Exec(articleSlugIndexStatement).Error + if indexed { + if err := db.Migrator().DropIndex(&entity.Article{}, articleSlugIndexName); err != nil { + return err + } + } + if err := db.Exec(articleSlugIndexStatement).Error; err != nil { + return err + } + if replaced { + return nil + } + return applyMarker(db, articleSlugLanguageMarker) } diff --git a/internal/infrastructure/persistence/database/articleStoryBackfill.go b/internal/infrastructure/persistence/database/articleStoryBackfill.go new file mode 100644 index 0000000..4cd9ed6 --- /dev/null +++ b/internal/infrastructure/persistence/database/articleStoryBackfill.go @@ -0,0 +1,43 @@ +package database + +import ( + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +const ( + articleStoriesMarker = "article_stories" + articleStoryLanguageIndex = "idx_articles_story_lang" + articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)" +) + +func BackfillArticleStories(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + applied, err := markerApplied(tx, articleStoriesMarker) + if err != nil || applied { + return err + } + var ids []uint32 + if err := tx.Unscoped().Model(&entity.Article{}).Where("story_id = ?", 0).Order("id ASC").Pluck("id", &ids).Error; err != nil { + return err + } + for _, id := range ids { + story := entity.Story{} + if err := tx.Create(&story).Error; err != nil { + return err + } + if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("story_id", story.ID).Error; err != nil { + return err + } + } + return applyMarker(tx, articleStoriesMarker) + }) +} + +func ensureArticleStoryLanguageIndex(db *gorm.DB) error { + if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) { + return nil + } + return db.Exec(articleStoryLanguageIndexSQL).Error +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go index a86ed9d..d706eeb 100644 --- a/internal/infrastructure/persistence/database/migrate.go +++ b/internal/infrastructure/persistence/database/migrate.go @@ -7,7 +7,7 @@ import ( ) func Migrate(db *gorm.DB, publicURL string) error { - err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}) + err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}, &models.Story{}, &models.SiteLanguage{}) if err != nil { return err } @@ -35,6 +35,15 @@ func Migrate(db *gorm.DB, publicURL string) error { if err := BackfillArticleSlugs(db); err != nil { return err } + if err := BackfillArticleStories(db); err != nil { + return err + } + if err := SeedSiteLanguageSuggestions(db); err != nil { + return err + } + if err := ensureArticleStoryLanguageIndex(db); err != nil { + return err + } return ensureArticleSlugIndex(db) } diff --git a/internal/infrastructure/persistence/database/siteLanguageSeed.go b/internal/infrastructure/persistence/database/siteLanguageSeed.go new file mode 100644 index 0000000..2ef7d93 --- /dev/null +++ b/internal/infrastructure/persistence/database/siteLanguageSeed.go @@ -0,0 +1,34 @@ +package database + +import ( + "RenewCMS/internal/domain/language" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +const siteLanguageSuggestionsMarker = "site_language_suggestions" + +func SeedSiteLanguageSuggestions(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + applied, err := markerApplied(tx, siteLanguageSuggestionsMarker) + if err != nil || applied { + return err + } + var sites []entity.Site + if err := tx.Order("id ASC").Find(&sites).Error; err != nil { + return err + } + for _, site := range sites { + suggestion := language.Suggest(site.Locale) + if !suggestion.Found { + continue + } + row := entity.SiteLanguage{SiteID: site.ID, Lang: suggestion.Tag, IsDefault: true, Enabled: true, Confirmed: false} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return applyMarker(tx, siteLanguageSuggestionsMarker) + }) +} diff --git a/internal/infrastructure/persistence/mappers/articleMapper.go b/internal/infrastructure/persistence/mappers/articleMapper.go index 13cfbb9..89191f6 100644 --- a/internal/infrastructure/persistence/mappers/articleMapper.go +++ b/internal/infrastructure/persistence/mappers/articleMapper.go @@ -52,6 +52,9 @@ func ArticleToDomain(m models.Article) article.Article { SEODismissed: decodeDismissed(m.SEODismissed), CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, + Lang: m.Lang, + StoryID: m.StoryID, + ContentRevision: m.ContentRevision, } } @@ -76,5 +79,8 @@ func ArticleToModel(a article.Article) models.Article { SEODismissed: encodeDismissed(a.SEODismissed), CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, + Lang: a.Lang, + StoryID: a.StoryID, + ContentRevision: a.ContentRevision, } } diff --git a/internal/infrastructure/persistence/mappers/siteLanguageMapper.go b/internal/infrastructure/persistence/mappers/siteLanguageMapper.go new file mode 100644 index 0000000..a584860 --- /dev/null +++ b/internal/infrastructure/persistence/mappers/siteLanguageMapper.go @@ -0,0 +1,16 @@ +package mappers + +import ( + "RenewCMS/internal/domain/language" + "RenewCMS/internal/infrastructure/persistence/models" +) + +func SiteLanguageToDomain(m models.SiteLanguage) language.SiteLanguage { + return language.SiteLanguage{ + SiteID: m.SiteID, + Tag: m.Lang, + IsDefault: m.IsDefault, + Enabled: m.Enabled, + Confirmed: m.Confirmed, + } +} diff --git a/internal/infrastructure/persistence/models/articleModel.go b/internal/infrastructure/persistence/models/articleModel.go index c29502f..5a65521 100644 --- a/internal/infrastructure/persistence/models/articleModel.go +++ b/internal/infrastructure/persistence/models/articleModel.go @@ -28,4 +28,7 @@ type Article struct { SEODismissed string `gorm:"column:seo_dismissed;type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` + Lang string `gorm:"size:35;not null;default:''"` + StoryID uint32 `gorm:"not null;default:0"` + ContentRevision uint32 `gorm:"not null;default:1"` } diff --git a/internal/infrastructure/persistence/models/siteLanguageModel.go b/internal/infrastructure/persistence/models/siteLanguageModel.go new file mode 100644 index 0000000..385c0b8 --- /dev/null +++ b/internal/infrastructure/persistence/models/siteLanguageModel.go @@ -0,0 +1,13 @@ +package models + +import "time" + +type SiteLanguage struct { + SiteID uint32 `gorm:"primaryKey;autoIncrement:false"` + Lang string `gorm:"primaryKey;size:35;not null"` + IsDefault bool `gorm:"not null;default:false"` + Enabled bool `gorm:"not null;default:true"` + Confirmed bool `gorm:"not null;default:false"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} diff --git a/internal/infrastructure/persistence/models/storyModel.go b/internal/infrastructure/persistence/models/storyModel.go new file mode 100644 index 0000000..a670816 --- /dev/null +++ b/internal/infrastructure/persistence/models/storyModel.go @@ -0,0 +1,8 @@ +package models + +import "time" + +type Story struct { + ID uint32 `gorm:"primaryKey;autoIncrement"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} diff --git a/internal/infrastructure/persistence/repositories/articleRepository.go b/internal/infrastructure/persistence/repositories/articleRepository.go index 2dd0917..274e5e6 100644 --- a/internal/infrastructure/persistence/repositories/articleRepository.go +++ b/internal/infrastructure/persistence/repositories/articleRepository.go @@ -216,7 +216,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) return loaded[0], nil } -const idChunkSize = 500 +var preservedArticleColumns = []string{"lang", "story_id", "content_revision"} + +const ( + idChunkSize = 500 + initialContentRevision = 1 +) func chunkIDs(ids []uint32) [][]uint32 { chunks := make([][]uint32, 0, len(ids)/idChunkSize+1) @@ -316,13 +321,17 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) { func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) { created := mappers.ArticleToModel(article) + created.ContentRevision = initialContentRevision err := a.db.Transaction(func(tx *gorm.DB) error { if article.SiteID != 0 { if _, err := lockSite(tx, article.SiteID); err != nil { return err } } - if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil { + if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil { + return err + } + if err := openStory(tx, &created); err != nil { return err } if err := tx.Create(&created).Error; err != nil { @@ -331,22 +340,34 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors)) }) if err != nil { - return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err) + return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err) } return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created)) } -func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) { +func openStory(tx *gorm.DB, created *entity.Article) error { + if created.StoryID != 0 { + return nil + } + story := entity.Story{} + if err := tx.Create(&story).Error; err != nil { + return err + } + created.StoryID = story.ID + return nil +} + +func articleSlugInLanguage(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) (bool, error) { var total int64 - err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error + err := db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ? AND id <> ?", siteID, lang, slug, exceptID).Count(&total).Error return total > 0, err } -func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error { +func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error { if slug == "" { return nil } - taken, err := articleSlugInUse(db, siteID, slug, exceptID) + taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID) if err != nil { return err } @@ -356,18 +377,18 @@ func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) er return nil } -func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error { +func (a *ArticleRepository) explainSlugFailure(siteID uint32, lang string, slug string, exceptID uint32, cause error) error { if errors.Is(cause, domain.ErrSlugTaken) { return cause } - if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) { + if err := ensureSlugFree(a.db, siteID, lang, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) { return err } return cause } -func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) { - return articleSlugInUse(a.db, siteID, slug, exceptID) +func (a *ArticleRepository) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) { + return articleSlugInLanguage(a.db, siteID, lang, slug, exceptID) } func (a *ArticleRepository) GetAll() ([]domain.Article, error) { @@ -393,7 +414,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, } localArticle.Body = body - err = a.db.Save(&localArticle).Error + err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error if err != nil { return domain.Article{}, err } @@ -419,11 +440,11 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti } applyUpdate(&localArticle, update) if update.Slug != nil || update.SiteID != nil { - if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil { + if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil { return err } } - if err := tx.Save(&localArticle).Error; err != nil { + if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil { return err } if update.AuthorIDs != nil { @@ -432,7 +453,7 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti return nil }) if err != nil { - return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err) + return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err) } return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle)) } @@ -478,7 +499,7 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art } localArticle.IsOnline = isOnline - err = a.db.Save(&localArticle).Error + err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error if err != nil { return domain.Article{}, err } diff --git a/internal/infrastructure/persistence/repositories/siteLanguageRepository.go b/internal/infrastructure/persistence/repositories/siteLanguageRepository.go new file mode 100644 index 0000000..2accc89 --- /dev/null +++ b/internal/infrastructure/persistence/repositories/siteLanguageRepository.go @@ -0,0 +1,88 @@ +package persistence + +import ( + domain "RenewCMS/internal/domain/language" + "RenewCMS/internal/infrastructure/persistence/mappers" + entity "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +type SiteLanguageRepository struct { + db *gorm.DB +} + +func NewSiteLanguageRepository(db *gorm.DB) *SiteLanguageRepository { + return &SiteLanguageRepository{db} +} + +func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguage, error) { + var found []entity.SiteLanguage + if err := s.db.Where("site_id = ?", siteID).Order("is_default DESC, lang ASC").Find(&found).Error; err != nil { + return nil, err + } + languages := make([]domain.SiteLanguage, 0, len(found)) + for _, row := range found { + languages = append(languages, mappers.SiteLanguageToDomain(row)) + } + return languages, nil +} + +func (s *SiteLanguageRepository) LockStartingLanguage(siteID uint32) (string, error) { + if _, err := lockSite(s.db, siteID); err != nil { + return "", err + } + defaultLanguage, found, err := findDefaultLanguage(s.db, siteID) + if err != nil || !found || !defaultLanguage.Confirmed { + return domain.Unassigned, err + } + return defaultLanguage.Lang, nil +} + +func (s *SiteLanguageRepository) ConfirmDefault(siteID uint32) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if _, err := lockSite(tx, siteID); err != nil { + return err + } + defaultLanguage, err := requireDefaultLanguage(tx, siteID) + if err != nil { + return err + } + if defaultLanguage.Confirmed { + return nil + } + if err := markConfirmed(tx, defaultLanguage); err != nil { + return err + } + return assignUnassignedArticles(tx, defaultLanguage) + }) +} + +func findDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, bool, error) { + var defaults []entity.SiteLanguage + if err := db.Where("site_id = ? AND is_default = ?", siteID, true).Limit(1).Find(&defaults).Error; err != nil || len(defaults) == 0 { + return entity.SiteLanguage{}, false, err + } + return defaults[0], true, nil +} + +func requireDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, error) { + defaultLanguage, found, err := findDefaultLanguage(db, siteID) + if err != nil { + return entity.SiteLanguage{}, err + } + if !found { + return entity.SiteLanguage{}, domain.ErrNoDefaultLanguage + } + return defaultLanguage, nil +} + +func markConfirmed(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error { + return tx.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, defaultLanguage.Lang).UpdateColumn("confirmed", true).Error +} + +func assignUnassignedArticles(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error { + return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, domain.Unassigned).UpdateColumn("lang", defaultLanguage.Lang).Error +} + +var _ domain.Repository = &SiteLanguageRepository{} diff --git a/internal/infrastructure/persistence/repositories/transactor.go b/internal/infrastructure/persistence/repositories/transactor.go index b522fc7..7cfdd1a 100644 --- a/internal/infrastructure/persistence/repositories/transactor.go +++ b/internal/infrastructure/persistence/repositories/transactor.go @@ -20,11 +20,12 @@ func NewTransactor(db *gorm.DB) *Transactor { 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), + Articles: NewArticleRepository(tx), + Sites: NewSiteRepository(tx), + Authors: NewAuthorRepository(tx), + Entries: NewEntryRepository(tx), + Images: NewImageRepository(tx), + Languages: NewSiteLanguageRepository(tx), }) }) } diff --git a/internal/infrastructure/useCases/CheckArticleSEO.go b/internal/infrastructure/useCases/CheckArticleSEO.go index 09caa7a..aeae336 100644 --- a/internal/infrastructure/useCases/CheckArticleSEO.go +++ b/internal/infrastructure/useCases/CheckArticleSEO.go @@ -126,11 +126,11 @@ func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.S if candidate.Slug == "" { return available, nil } - taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID) + taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID) if err != nil || !taken { return available, err } - suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt) + suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID, firstSuggestedAttempt) if err != nil { return article.SEOSlugCheck{}, err } diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index e1f2739..f8f6945 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -7,16 +7,17 @@ import ( "RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/site" + "RenewCMS/internal/domain/transaction" "context" "errors" ) type CreateArticleUseCase struct { - articleRepository article.Repository - bodyRenderer article.BodyRenderer - recorder audit.Recorder - sites site.Repository - authors author.Repository + transactor transaction.Transactor + bodyRenderer article.BodyRenderer + recorder audit.Recorder + sites site.Repository + authors author.Repository } type CreateArticleCommand struct { @@ -26,8 +27,8 @@ type CreateArticleCommand struct { Caller ContentCaller } -func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase { - return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors} +func NewCreateArticleUseCase(transactor transaction.Transactor, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase { + return &CreateArticleUseCase{transactor: transactor, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors} } func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) { @@ -71,7 +72,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle return article.Article{}, err } - created, err := g.createWithFreeSlug(article.Article{ + created, err := g.createWithFreeSlug(ctx, article.Article{ Title: createArticle.Title, Body: createArticle.Body, IsOnline: false, @@ -90,15 +91,9 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle return renderArticle(g.bodyRenderer, created, createArticle.Format) } -func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) { - base := article.GenerateSlug(draft.Title) +func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) { for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ { - slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt) - if err != nil { - return article.Article{}, err - } - draft.Slug = slug - created, err := g.articleRepository.Create(draft) + created, err := g.createInTransaction(ctx, draft) if !isSlugTakenError(err) { return created, err } @@ -106,6 +101,24 @@ func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (articl return article.Article{}, article.ErrSlugTaken } +func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft article.Article) (article.Article, error) { + var created article.Article + err := g.transactor.Run(ctx, func(tx transaction.Repositories) error { + lang, err := tx.Languages.LockStartingLanguage(draft.SiteID) + if err != nil { + return err + } + draft.Lang = lang + draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt) + if err != nil { + return err + } + created, err = tx.Articles.Create(draft) + return err + }) + return created, err +} + func articleTarget(a article.Article) audit.Target { return audit.NewTarget(audit.TargetArticle, a.ID, a.Title) } diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go index 7cb802d..d280536 100644 --- a/internal/infrastructure/useCases/UpdateArticle.go +++ b/internal/infrastructure/useCases/UpdateArticle.go @@ -156,12 +156,12 @@ func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command Updat if slug == "" || (slug == before.Slug && target == before.SiteID) { return nil, nil } - taken, err := g.articleRepository.SlugTaken(target, slug, before.ID) + taken, err := g.articleRepository.SlugTaken(target, before.Lang, slug, before.ID) if err != nil { return nil, err } if taken { - return nil, slugConflict(g.articleRepository, target, slug, before.ID) + return nil, slugConflict(g.articleRepository, target, before.Lang, slug, before.ID) } if slug == before.Slug { return nil, nil @@ -174,7 +174,7 @@ func (g *UpdateArticleUseCase) raceConflict(before article.Article, command Upda if chosen != nil { slug = *chosen } - return slugConflict(g.articleRepository, targetSiteID(before, command), slug, before.ID) + return slugConflict(g.articleRepository, targetSiteID(before, command), before.Lang, slug, before.ID) } func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) { diff --git a/internal/infrastructure/useCases/articleSlug.go b/internal/infrastructure/useCases/articleSlug.go index d187dba..7c28a5c 100644 --- a/internal/infrastructure/useCases/articleSlug.go +++ b/internal/infrastructure/useCases/articleSlug.go @@ -12,10 +12,10 @@ const ( maxFreeSlugAttempts = 100 ) -func freeSlug(articles article.Repository, siteID uint32, base string, exceptID uint32, firstAttempt int) (string, error) { +func freeSlug(articles article.Repository, siteID uint32, lang string, base string, exceptID uint32, firstAttempt int) (string, error) { for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ { candidate := article.SlugWithSuffix(base, attempt) - taken, err := articles.SlugTaken(siteID, candidate, exceptID) + taken, err := articles.SlugTaken(siteID, lang, candidate, exceptID) if err != nil { return "", err } @@ -26,8 +26,8 @@ func freeSlug(articles article.Repository, siteID uint32, base string, exceptID return "", article.ErrSlugTaken } -func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error { - suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt) +func slugConflict(articles article.Repository, siteID uint32, lang string, slug string, exceptID uint32) error { + suggestion, err := freeSlug(articles, siteID, lang, slug, exceptID, firstSuggestedAttempt) if err != nil { return err } diff --git a/test/account_delete_persistence_test.go b/test/account_delete_persistence_test.go index ce9d1a7..e5ca501 100644 --- a/test/account_delete_persistence_test.go +++ b/test/account_delete_persistence_test.go @@ -25,7 +25,7 @@ func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) { &entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"}, &entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID}, &entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID}, - &entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7}, + &entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7}, &entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7}, &entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7}, &entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`}, diff --git a/test/account_export_data_persistence_test.go b/test/account_export_data_persistence_test.go index c5d2524..1d876cd 100644 --- a/test/account_export_data_persistence_test.go +++ b/test/account_export_data_persistence_test.go @@ -73,13 +73,13 @@ func articleIDs(listed []article.Article) []uint32 { func seedExportRows(t *testing.T, db *gorm.DB) { t.Helper() rows := []any{ - &entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7}, - &entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8}, - &entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true}, - &entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8}, - &entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7}, - &entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7}, - &entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true}, + &entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7}, + &entity.Article{ID: 2, StoryID: fixtureStory(), Title: "Ines", Slug: "ines", CreatedBy: 8}, + &entity.Article{ID: 3, StoryID: fixtureStory(), Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true}, + &entity.Article{ID: 7, StoryID: fixtureStory(), Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8}, + &entity.Article{ID: 8, StoryID: fixtureStory(), Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7}, + &entity.Article{ID: 4, StoryID: fixtureStory(), Title: "Gone", Slug: "gone", CreatedBy: 7}, + &entity.Article{ID: 6, StoryID: fixtureStory(), Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true}, &entity.ArticleAuthor{ArticleID: 3, AuthorID: 5}, &entity.ArticleAuthor{ArticleID: 7, AuthorID: 5}, &entity.ArticleAuthor{ArticleID: 6, AuthorID: 5}, diff --git a/test/activity_log_producers_test.go b/test/activity_log_producers_test.go index 457a781..1f7096b 100644 --- a/test/activity_log_producers_test.go +++ b/test/activity_log_producers_test.go @@ -187,7 +187,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) { t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) { h := newActivityHarness(t) - uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(newMockArticleRepo()), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors()) _, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"}) diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 922c0d9..8f1d0db 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -109,7 +109,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv { restartJobs := &fakeJobStore{} env.container = &api.UseCases{ Token: env.token, - CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images), diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go index d958668..285c5b1 100644 --- a/test/article_body_format_test.go +++ b/test/article_body_format_test.go @@ -43,7 +43,7 @@ const ( func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/article_body_unlimited_http_test.go b/test/article_body_unlimited_http_test.go index e23eb12..8a733c2 100644 --- a/test/article_body_unlimited_http_test.go +++ b/test/article_body_unlimited_http_test.go @@ -23,7 +23,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) getUserUseCase := useCases.NewGetUserUseCase(routerUsers) renderer := markdown.NewConverter() articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/article_chunked_lookups_test.go b/test/article_chunked_lookups_test.go index 3415660..a6d35b1 100644 --- a/test/article_chunked_lookups_test.go +++ b/test/article_chunked_lookups_test.go @@ -22,7 +22,7 @@ func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) { t.Helper() rows := make([]entity.Article, 0, count) for i := 1; i <= count; i++ { - rows = append(rows, entity.Article{Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true}) + rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true}) } if err := db.CreateInBatches(&rows, 100).Error; err != nil { t.Fatalf("seed articles: %v", err) diff --git a/test/article_create_language_test.go b/test/article_create_language_test.go new file mode 100644 index 0000000..6d37494 --- /dev/null +++ b/test/article_create_language_test.go @@ -0,0 +1,79 @@ +package test + +import ( + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/language" + "RenewCMS/internal/infrastructure/useCases" + "context" + "errors" + "testing" +) + +const ( + createLanguageSiteID = 1 + createLanguageTitle = "Bonjour le monde" + confirmedDefaultTag = "fr-CA" +) + +func newCreateLanguageFixture(confirmed map[uint32]string) (*useCases.CreateArticleUseCase, *mockCreateTransactor, *mockArticleRepo) { + articles := newMockArticleRepo() + transactor := passthroughCreateTransactor(articles) + transactor.languages.confirmed = confirmed + return useCases.NewCreateArticleUseCase(transactor, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), transactor, articles +} + +func createLanguageCommand() useCases.CreateArticleCommand { + return useCases.CreateArticleCommand{Title: createLanguageTitle, Caller: ownerCaller(1)} +} + +func TestCreateArticleStartingLanguage(t *testing.T) { + t.Run("Creating an article before the default is confirmed", func(t *testing.T) { + create, transactor, articles := newCreateLanguageFixture(map[uint32]string{}) + + created, err := create.CreateArticle(context.Background(), createLanguageCommand()) + + if err != nil || articles.articles[created.ID].Lang != language.Unassigned { + t.Fatalf("expected an unassigned article, got %+v %v", articles.articles[created.ID], err) + } + if len(transactor.languages.locked) != 1 || transactor.languages.locked[0] != createLanguageSiteID { + t.Errorf("the site language must be read once under the site lock: %v", transactor.languages.locked) + } + }) + + t.Run("Creating an article after the default is confirmed", func(t *testing.T) { + create, _, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag}) + + created, err := create.CreateArticle(context.Background(), createLanguageCommand()) + + if err != nil || articles.articles[created.ID].Lang != confirmedDefaultTag { + t.Fatalf("expected lang %s, got %+v %v", confirmedDefaultTag, articles.articles[created.ID], err) + } + }) + + t.Run("A slug is unique per language when a new article takes the confirmed default", func(t *testing.T) { + create, _, articles := newCreateLanguageFixture(map[uint32]string{createLanguageSiteID: confirmedDefaultTag}) + articles.articles[50] = article.Article{ID: 50, SiteID: createLanguageSiteID, Lang: "en", Slug: "bonjour-le-monde"} + + first, firstErr := create.CreateArticle(context.Background(), createLanguageCommand()) + second, secondErr := create.CreateArticle(context.Background(), createLanguageCommand()) + + if firstErr != nil || secondErr != nil { + t.Fatalf("unexpected errors %v %v", firstErr, secondErr) + } + if first.Slug != "bonjour-le-monde" || second.Slug != "bonjour-le-monde-2" { + t.Errorf("slugs %q then %q", first.Slug, second.Slug) + } + }) + + t.Run("A failure reading the site language aborts the creation", func(t *testing.T) { + create, transactor, articles := newCreateLanguageFixture(map[uint32]string{}) + failure := errors.New("language read failed") + transactor.languages.failWith = failure + + _, err := create.CreateArticle(context.Background(), createLanguageCommand()) + + if !errors.Is(err, failure) || articles.createCalled { + t.Errorf("expected %v without a create, got %v", failure, err) + } + }) +} diff --git a/test/article_create_support_test.go b/test/article_create_support_test.go new file mode 100644 index 0000000..2131179 --- /dev/null +++ b/test/article_create_support_test.go @@ -0,0 +1,56 @@ +package test + +import ( + "RenewCMS/internal/domain/article" + "RenewCMS/internal/domain/language" + "RenewCMS/internal/domain/transaction" + "context" + "sync/atomic" +) + +type mockStartingLanguages struct { + confirmed map[uint32]string + locked []uint32 + failWith error +} + +var _ language.Repository = &mockStartingLanguages{} + +func (m *mockStartingLanguages) ListBySite(siteID uint32) ([]language.SiteLanguage, error) { + return nil, nil +} + +func (m *mockStartingLanguages) ConfirmDefault(siteID uint32) error { + return nil +} + +func (m *mockStartingLanguages) LockStartingLanguage(siteID uint32) (string, error) { + m.locked = append(m.locked, siteID) + if m.failWith != nil { + return "", m.failWith + } + return m.confirmed[siteID], nil +} + +type mockCreateTransactor struct { + articles article.Repository + languages *mockStartingLanguages + runs int +} + +var _ transaction.Transactor = &mockCreateTransactor{} + +func (m *mockCreateTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error { + m.runs++ + return work(transaction.Repositories{Articles: m.articles, Languages: m.languages}) +} + +func passthroughCreateTransactor(articles article.Repository) *mockCreateTransactor { + return &mockCreateTransactor{articles: articles, languages: &mockStartingLanguages{confirmed: map[uint32]string{}}} +} + +var fixtureStoryCounter atomic.Uint32 + +func fixtureStory() uint32 { + return 1000 + fixtureStoryCounter.Add(1) +} diff --git a/test/article_created_by_http_test.go b/test/article_created_by_http_test.go index 81f571d..00b86da 100644 --- a/test/article_created_by_http_test.go +++ b/test/article_created_by_http_test.go @@ -35,7 +35,7 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) { GetUserUseCase: useCases.NewGetUserUseCase(e.users), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), - CreateArticleUseCase: useCases.NewCreateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(e.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}), ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder), diff --git a/test/article_localization_storage_http_test.go b/test/article_localization_storage_http_test.go new file mode 100644 index 0000000..109e0b2 --- /dev/null +++ b/test/article_localization_storage_http_test.go @@ -0,0 +1,122 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + "encoding/json" + "net/http" + "testing" +) + +const ( + localizedArticleID = 12 + localizedArticleStory = 5 +) + +var legacyListItemKeys = []string{"id", "title", "body", "body_format", "is_online", "site_id", "authors", "created_at", "updated_at", "excerpt", "slug", "seo_status", "seo_suggestions"} + +var legacyArticleKeys = []string{"id", "title", "body", "is_online", "site_id", "authors", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "created_at", "updated_at"} + +func localizedArticle(lang string, online bool) domainArticle.Article { + return domainArticle.Article{ID: localizedArticleID, Title: "Bonjour le monde", IsOnline: online, CreatedBy: createdByWriterID, Lang: lang, StoryID: localizedArticleStory} +} + +func TestArticleLocalizationStorageHTTP(t *testing.T) { + assertFields := func(t *testing.T, fields map[string]string, lang string, legacyKeys []string) { + t.Helper() + if fields["lang"] != `"`+lang+`"` || fields["story_id"] != "5" { + t.Errorf(`expected lang %q and story_id 5, got lang=%s story_id=%s`, lang, fields["lang"], fields["story_id"]) + } + for _, key := range legacyKeys { + if _, present := fields[key]; !present { + t.Errorf("legacy key %q is missing", key) + } + } + } + readers := []struct { + name string + lang string + path string + auth bool + list bool + on bool + }{ + {"GET /v1/article/12 with a valid jwt", "fr-CA", "/v1/article/12", true, false, false}, + {"GET /v1/article/12 with a valid jwt and no language", "", "/v1/article/12", true, false, false}, + {"GET /v1/article/ list item", "fr-CA", "/v1/article/", true, true, false}, + {"public article read of an online article", "fr-CA", "/v1/article/12", false, false, true}, + } + for _, reader := range readers { + t.Run("Article reads expose lang and story_id "+reader.name, func(t *testing.T) { + e, router := newCreatedByEnv(t) + e.articles.articles[localizedArticleID] = localizedArticle(reader.lang, reader.on) + var options []activityRequest + if reader.auth { + options = append(options, asUser(e, activityOwnerID)) + } + rec := sendCreatedBy(router, http.MethodGet, reader.path, "", options...) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + var fields map[string]string + if reader.list { + for _, item := range decodeArticleItems(t, rec.Body.Bytes()) { + if string(item["id"]) == "12" { + fields = rawFields(item) + } + } + } else { + fields = rawFields(decodeArticleFields(t, rec.Body.Bytes())) + } + if fields == nil { + t.Fatalf("article 12 not found in %s", rec.Body) + } + legacyKeys := legacyArticleKeys + if reader.list { + legacyKeys = legacyListItemKeys + } + assertFields(t, fields, reader.lang, legacyKeys) + }) + } + + t.Run("Article write payloads cannot set lang or story_id", func(t *testing.T) { + e, router := newCreatedByEnv(t) + e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false) + + created := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":"","lang":"en","story_id":99}`, asUser(e, createdByWriterID)) + patched := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"excerpt":"Short","lang":"en","story_id":99}`, asUser(e, activityOwnerID)) + + if created.Code != http.StatusCreated || patched.Code != http.StatusOK { + t.Fatalf("statuses %d and %d: %s %s", created.Code, patched.Code, created.Body, patched.Body) + } + createdFields := rawFields(decodeArticleFields(t, created.Body.Bytes())) + if createdFields["lang"] == `"en"` || createdFields["story_id"] == "99" { + t.Errorf("the create payload set lang or story_id: %v", createdFields) + } + assertFields(t, rawFields(decodeArticleFields(t, patched.Body.Bytes())), "fr-CA", legacyArticleKeys) + if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory { + t.Errorf("the stored language or story changed: %+v", stored) + } + }) + t.Run("A patch carrying only lang and story_id is refused like a patch with no updatable field", func(t *testing.T) { + e, router := newCreatedByEnv(t) + e.articles.articles[localizedArticleID] = localizedArticle("fr-CA", false) + + empty := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{}`, asUser(e, activityOwnerID)) + localizationOnly := sendCreatedBy(router, http.MethodPatch, "/v1/article/12", `{"lang":"en","story_id":99}`, asUser(e, activityOwnerID)) + + if empty.Code != http.StatusBadRequest || localizationOnly.Code != empty.Code { + t.Fatalf("expected both to be refused with 400, got %d and %d", empty.Code, localizationOnly.Code) + } + if stored := e.articles.articles[localizedArticleID]; stored.Lang != "fr-CA" || stored.StoryID != localizedArticleStory { + t.Errorf("the stored language or story changed: %+v", stored) + } + }) +} + +func rawFields(object map[string]json.RawMessage) map[string]string { + flat := make(map[string]string, len(object)) + for key, value := range object { + flat[key] = string(value) + } + return flat +} diff --git a/test/article_localization_storage_persistence_test.go b/test/article_localization_storage_persistence_test.go new file mode 100644 index 0000000..0df2957 --- /dev/null +++ b/test/article_localization_storage_persistence_test.go @@ -0,0 +1,626 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + authordomain "RenewCMS/internal/domain/author" + "RenewCMS/internal/domain/language" + "RenewCMS/internal/infrastructure/persistence/database" + entity "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/useCases" + "context" + "errors" + "fmt" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "gorm.io/gorm" +) + +const ( + storiesBackfillMarker = "article_stories" + languageSuggestionsMarker = "site_language_suggestions" + storyLanguageIndexName = "idx_articles_story_lang" + siteSlugIndexName = "idx_articles_site_slug" + slugPerLanguageMarker = "article_slug_index_per_language" + injectedBackfillFailure = "injected backfill failure" + raceCreators = 8 + raceRetries = 60 + raceRetriesPerWriter = 4 + raceRetryBudget = raceRetriesPerWriter * (raceCreators + 1) + storyRollbackCallback = "test:fail-credit-write" + storyRollbackFailure = "injected credit failure" + racePauseBetweenRetries = 10 * time.Millisecond +) + +func localeOf(value string) *string { + return &value +} + +func storageDefaultSite(t *testing.T, db *gorm.DB) entity.Site { + t.Helper() + var site entity.Site + if err := db.Where("is_default = ?", true).First(&site).Error; err != nil { + t.Fatalf("default site: %v", err) + } + return site +} + +func storageSetLocale(t *testing.T, db *gorm.DB, siteID uint32, locale *string) { + t.Helper() + if err := db.Model(&entity.Site{}).Where("id = ?", siteID).UpdateColumn("locale", locale).Error; err != nil { + t.Fatalf("set locale: %v", err) + } +} + +func regressToPreLocalizationSchema(t *testing.T, db *gorm.DB) { + t.Helper() + migrator := db.Migrator() + for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} { + if migrator.HasIndex(&entity.Article{}, name) { + if err := migrator.DropIndex(&entity.Article{}, name); err != nil { + t.Fatalf("drop %s: %v", name, err) + } + } + } + if err := db.Exec("CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)").Error; err != nil { + t.Fatalf("recreate the pre-change slug index: %v", err) + } + statements := []string{ + "UPDATE articles SET story_id = 0, lang = ''", + "DELETE FROM stories", + "DELETE FROM site_languages", + "DELETE FROM migration_markers WHERE name IN ('" + storiesBackfillMarker + "', '" + languageSuggestionsMarker + "', '" + slugPerLanguageMarker + "')", + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("%s: %v", statement, err) + } + } +} + +func seedPreLocalizationArticles(t *testing.T, db *gorm.DB, siteID uint32, softDeleted ...uint32) { + t.Helper() + for id := uint32(1); id <= 3; id++ { + row := entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Article %d", id), Body: fmt.Sprintf("Body %d", id), SiteID: siteID, Slug: fmt.Sprintf("article-%d", id), IsOnline: id == 3} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed article %d: %v", id, err) + } + } + for _, id := range softDeleted { + if err := db.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", domainArticle.Tombstone(id)).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(&entity.Article{}, id).Error; err != nil { + t.Fatal(err) + } + } + regressToPreLocalizationSchema(t, db) +} + +func legacyDatabase(t *testing.T, open func(*testing.T) *gorm.DB, locale *string, softDeleted ...uint32) (*gorm.DB, entity.Site) { + t.Helper() + db := open(t) + site := storageDefaultSite(t, db) + storageSetLocale(t, db, site.ID, locale) + seedPreLocalizationArticles(t, db, site.ID, softDeleted...) + return db, site +} + +func migrateStorage(t *testing.T, db *gorm.DB) { + t.Helper() + if err := database.Migrate(db, migrationPublicURL); err != nil { + t.Fatalf("migrate: %v", err) + } +} + +func storedArticles(t *testing.T, db *gorm.DB) []entity.Article { + t.Helper() + var rows []entity.Article + if err := db.Unscoped().Order("id ASC").Find(&rows).Error; err != nil { + t.Fatal(err) + } + return rows +} + +func storedArticle(t *testing.T, db *gorm.DB, id uint32) entity.Article { + t.Helper() + var row entity.Article + if err := db.Unscoped().First(&row, id).Error; err != nil { + t.Fatal(err) + } + return row +} + +func storageSnapshot(t *testing.T, db *gorm.DB) string { + t.Helper() + var stories []entity.Story + var languages []entity.SiteLanguage + var markers []entity.MigrationMarker + for _, target := range []any{&stories, &languages, &markers} { + if err := db.Order("1").Find(target).Error; err != nil { + t.Fatal(err) + } + } + indexes, err := db.Migrator().GetIndexes(&entity.Article{}) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, index := range indexes { + unique, _ := index.Unique() + names = append(names, fmt.Sprintf("%s:%v:%v", index.Name(), unique, index.Columns())) + } + slices.Sort(names) + return fmt.Sprintf("%v|%v|%v|%v|%v", storedArticles(t, db), stories, languages, markers, names) +} + +func markerRecorded(t *testing.T, db *gorm.DB, name string) bool { + t.Helper() + var count int64 + if err := db.Model(&entity.MigrationMarker{}).Where("name = ?", name).Count(&count).Error; err != nil { + t.Fatal(err) + } + return count > 0 +} + +func createThroughUseCase(db *gorm.DB) *useCases.CreateArticleUseCase { + return useCases.NewCreateArticleUseCase(persistence.NewTransactor(db), &fakeBodyRenderer{}, discardRecorder, persistence.NewSiteRepository(db), persistence.NewAuthorRepository(db)) +} + +func createTitled(db *gorm.DB, title string) (domainArticle.Article, error) { + return createThroughUseCase(db).CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: title, Caller: ownerCaller(1)}) +} + +func newStorageArticle(siteID uint32, lang string, slug string, storyID uint32) domainArticle.Article { + return domainArticle.Article{Title: "Article " + slug, Body: "Body", SiteID: siteID, Lang: lang, Slug: slug, StoryID: storyID, SEODismissed: []string{}} +} + +func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB) { + t.Run("Migration of an existing site and its articles", func(t *testing.T) { + db, site := legacyDatabase(t, open, localeOf("fr-CA")) + before := storedArticles(t, db) + + migrateStorage(t, db) + + after := storedArticles(t, db) + seen := map[uint32]bool{} + for index, row := range after { + if row.ID != before[index].ID || row.Title != before[index].Title || row.Body != before[index].Body || row.Slug != before[index].Slug || row.IsOnline != before[index].IsOnline || !row.CreatedAt.Equal(before[index].CreatedAt) { + t.Errorf("article %d changed: %+v", row.ID, row) + } + if row.Lang != "" || row.StoryID == 0 || seen[row.StoryID] || row.ContentRevision != 1 { + t.Errorf("article %d must have lang '', its own story and revision 1: %+v", row.ID, row) + } + seen[row.StoryID] = true + } + var languages []entity.SiteLanguage + if err := db.Where("site_id = ?", site.ID).Find(&languages).Error; err != nil { + t.Fatal(err) + } + if len(languages) != 1 || languages[0].Lang != "fr-CA" || !languages[0].IsDefault || !languages[0].Enabled || languages[0].Confirmed { + t.Errorf("expected one unconfirmed default fr-CA row, got %+v", languages) + } + if !markerRecorded(t, db, storiesBackfillMarker) { + t.Error("the story backfill marker must be recorded") + } + }) + + t.Run("Bare language fallback is stored as the suggestion", func(t *testing.T) { + db, site := legacyDatabase(t, open, localeOf("fr-BE")) + + migrateStorage(t, db) + + languages, err := persistence.NewSiteLanguageRepository(db).ListBySite(site.ID) + if err != nil || len(languages) != 1 || languages[0].Tag != "fr" || languages[0].Confirmed { + t.Errorf("expected one unconfirmed fr row, got %+v %v", languages, err) + } + }) + + t.Run("Site without a usable locale gets no suggestion row", func(t *testing.T) { + for _, locale := range []*string{nil, localeOf("xx-YY")} { + db, site := legacyDatabase(t, open, locale) + + migrateStorage(t, db) + + var count int64 + if err := db.Model(&entity.SiteLanguage{}).Where("site_id = ?", site.ID).Count(&count).Error; err != nil || count != 0 { + t.Errorf("expected no row, got %d %v", count, err) + } + } + }) + + t.Run("Migration runs twice", func(t *testing.T) { + db, _ := legacyDatabase(t, open, localeOf("fr-CA")) + migrateStorage(t, db) + first := storageSnapshot(t, db) + + migrateStorage(t, db) + + if second := storageSnapshot(t, db); second != first { + t.Errorf("the second migration changed the database:\n%s\n%s", first, second) + } + }) + + t.Run("Migration is atomic", func(t *testing.T) { + db, _ := legacyDatabase(t, open, localeOf("fr-CA")) + updates := 0 + const callbackName = "test:fail-second-story-assignment" + err := db.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table != "articles" { + return + } + updates++ + if updates == 2 { + _ = tx.AddError(errors.New(injectedBackfillFailure)) + } + }) + if err != nil { + t.Fatal(err) + } + + failure := database.Migrate(db, migrationPublicURL) + + if failure == nil { + t.Fatal("the injected failure must abort the migration") + } + for _, row := range storedArticles(t, db) { + if row.StoryID != 0 { + t.Errorf("article %d kept a story_id after the rollback", row.ID) + } + } + var stories int64 + if err := db.Model(&entity.Story{}).Count(&stories).Error; err != nil || stories != 0 { + t.Errorf("the rolled back stories must not exist: %d %v", stories, err) + } + if markerRecorded(t, db, storiesBackfillMarker) { + t.Error("the marker must not be recorded") + } + if err := db.Callback().Update().Remove(callbackName); err != nil { + t.Fatal(err) + } + + migrateStorage(t, db) + + for _, row := range storedArticles(t, db) { + if row.StoryID == 0 { + t.Errorf("article %d must have a story after the retry", row.ID) + } + } + if !markerRecorded(t, db, storiesBackfillMarker) { + t.Error("the marker must be recorded after the retry") + } + }) + + t.Run("Soft-deleted articles are migrated", func(t *testing.T) { + db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2) + + migrateStorage(t, db) + + for _, row := range storedArticles(t, db) { + if row.StoryID == 0 { + t.Errorf("article %d has no story", row.ID) + } + if row.ID == 2 && (!row.DeletedAt.Valid || row.Slug != domainArticle.Tombstone(2)) { + t.Errorf("the tombstone must be unchanged: %+v", row) + } + } + }) + + t.Run("Slug is unique per site and language", func(t *testing.T) { + db := open(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + other := entity.Site{Name: "Other", Key: "other", BaseURL: "https://other.example.com", DefaultRobots: "index"} + if err := db.Create(&other).Error; err != nil { + t.Fatal(err) + } + if _, err := articles.Create(newStorageArticle(site.ID, "fr", "bonjour", 0)); err != nil { + t.Fatalf("first create: %v", err) + } + + _, duplicate := articles.Create(newStorageArticle(site.ID, "fr", "bonjour", 0)) + _, otherLanguage := articles.Create(newStorageArticle(site.ID, "en", "bonjour", 0)) + _, otherSite := articles.Create(newStorageArticle(other.ID, "fr", "bonjour", 0)) + + if !errors.Is(duplicate, domainArticle.ErrSlugTaken) { + t.Errorf("expected ErrSlugTaken, got %v", duplicate) + } + if otherLanguage != nil || otherSite != nil { + t.Errorf("language and site variants must be stored: %v %v", otherLanguage, otherSite) + } + }) + + t.Run("Existing unassigned articles keep the current per-site slug rule", func(t *testing.T) { + db := open(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + if _, err := articles.Create(newStorageArticle(site.ID, "", "same", 0)); err != nil { + t.Fatal(err) + } + second, err := articles.Create(newStorageArticle(site.ID, "", "other", 0)) + if err != nil { + t.Fatal(err) + } + + _, updateErr := articles.Update(second.ID, domainArticle.Update{Slug: localeOf("same")}) + + if !errors.Is(updateErr, domainArticle.ErrSlugTaken) { + t.Errorf("expected ErrSlugTaken, got %v", updateErr) + } + }) + + t.Run("One version per language in a story", func(t *testing.T) { + db := open(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + story := entity.Story{} + if err := db.Create(&story).Error; err != nil { + t.Fatal(err) + } + if _, err := articles.Create(newStorageArticle(site.ID, "fr", "un", story.ID)); err != nil { + t.Fatal(err) + } + + _, duplicate := articles.Create(newStorageArticle(site.ID, "fr", "deux", story.ID)) + _, otherLanguage := articles.Create(newStorageArticle(site.ID, "en", "three", story.ID)) + + if duplicate == nil { + t.Error("a second fr version in the same story must be rejected") + } + if otherLanguage != nil { + t.Errorf("an en version must be stored: %v", otherLanguage) + } + }) + + t.Run("Unassigned articles never collide on language", func(t *testing.T) { + db := open(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + stories := map[uint32]bool{} + for index := 0; index < 3; index++ { + created, err := articles.Create(newStorageArticle(site.ID, "", fmt.Sprintf("slug-%d", index), 0)) + if err != nil || created.StoryID == 0 || stories[created.StoryID] || created.Lang != "" { + t.Fatalf("article %d: %+v %v", index, created, err) + } + stories[created.StoryID] = true + } + }) + + t.Run("Creating an article before the default is confirmed", func(t *testing.T) { + db, _ := legacyDatabase(t, open, localeOf("fr-CA")) + migrateStorage(t, db) + + created, err := createTitled(db, "Bonjour le monde") + stored := storedArticle(t, db, created.ID) + + if err != nil || stored.Lang != "" || stored.StoryID == 0 || stored.ContentRevision != 1 || created.ContentRevision != 1 { + t.Errorf("expected lang '' with a new story and revision 1, got %+v %v", stored, err) + } + }) + + t.Run("Creating an article after the default is confirmed", func(t *testing.T) { + db, site := legacyDatabase(t, open, localeOf("fr-CA")) + migrateStorage(t, db) + if err := persistence.NewSiteLanguageRepository(db).ConfirmDefault(site.ID); err != nil { + t.Fatal(err) + } + + created, err := createTitled(db, "Bonjour le monde") + stored := storedArticle(t, db, created.ID) + + if err != nil || stored.Lang != "fr-CA" { + t.Fatalf("expected lang fr-CA, got %+v %v", stored, err) + } + for _, row := range storedArticles(t, db) { + if row.ID != stored.ID && row.StoryID == stored.StoryID { + t.Errorf("the new story must be distinct from article %d", row.ID) + } + } + }) + + t.Run("Create racing a confirmation never strands an article", func(t *testing.T) { + db, site := legacyDatabase(t, open, localeOf("fr-CA")) + migrateStorage(t, db) + languages := persistence.NewSiteLanguageRepository(db) + var group sync.WaitGroup + var retries atomic.Int64 + start := make(chan struct{}) + retry := func(work func() error) error { + var err error + for attempt := 0; attempt < raceRetries; attempt++ { + if err = work(); err == nil { + return nil + } + retries.Add(1) + time.Sleep(racePauseBetweenRetries) + } + return err + } + failures := make(chan error, raceCreators+1) + for index := 0; index < raceCreators; index++ { + group.Add(1) + go func() { + defer group.Done() + <-start + failures <- retry(func() error { + _, err := createTitled(db, fmt.Sprintf("Race article %d", index)) + return err + }) + }() + } + group.Add(1) + go func() { + defer group.Done() + <-start + failures <- retry(func() error { return languages.ConfirmDefault(site.ID) }) + }() + close(start) + group.Wait() + close(failures) + + for err := range failures { + if err != nil { + t.Fatalf("a racing write never succeeded: %v", err) + } + } + t.Logf("busy retries: %d of a budget of %d", retries.Load(), raceRetryBudget) + if retries.Load() > raceRetryBudget { + t.Errorf("%d retries exceed the budget of %d: the lock is not serializing the writers", retries.Load(), raceRetryBudget) + } + rows := storedArticles(t, db) + if len(rows) != 3+raceCreators { + t.Fatalf("expected %d articles, got %d", 3+raceCreators, len(rows)) + } + for _, row := range rows { + if row.Lang != "fr-CA" { + t.Errorf("article %d ended with lang %q", row.ID, row.Lang) + } + } + }) + + t.Run("A failed create leaves no orphan story", func(t *testing.T) { + db := open(t) + site := storageDefaultSite(t, db) + var before int64 + if err := db.Model(&entity.Story{}).Count(&before).Error; err != nil { + t.Fatal(err) + } + err := db.Callback().Create().Before("gorm:create").Register(storyRollbackCallback, func(tx *gorm.DB) { + if tx.Statement.Table == "article_authors" { + _ = tx.AddError(errors.New(storyRollbackFailure)) + } + }) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := db.Callback().Create().Remove(storyRollbackCallback); err != nil { + t.Fatal(err) + } + }() + draft := newStorageArticle(site.ID, "", "orphan", 0) + draft.Authors = []authordomain.Byline{{ID: 1}} + + _, createErr := persistence.NewArticleRepository(db).Create(draft) + + var after int64 + if err := db.Model(&entity.Story{}).Count(&after).Error; err != nil { + t.Fatal(err) + } + if createErr == nil || after != before { + t.Errorf("the story must roll back with the failed create: error %v, stories %d then %d", createErr, before, after) + } + }) + + t.Run("Migrating twice keeps the same slug in two languages of one site", func(t *testing.T) { + db := open(t) + site := storageDefaultSite(t, db) + articles := persistence.NewArticleRepository(db) + for _, lang := range []string{"fr", "en"} { + if _, err := articles.Create(newStorageArticle(site.ID, lang, "bonjour", 0)); err != nil { + t.Fatalf("create %s: %v", lang, err) + } + } + + migrateStorage(t, db) + migrateStorage(t, db) + + for _, row := range storedArticles(t, db) { + if row.Slug != "bonjour" { + t.Errorf("article %d (%s) was renamed to %q", row.ID, row.Lang, row.Slug) + } + } + }) + + t.Run("Assigning a language is idempotent", func(t *testing.T) { + db, site := legacyDatabase(t, open, localeOf("fr-CA")) + migrateStorage(t, db) + languages := persistence.NewSiteLanguageRepository(db) + if err := languages.ConfirmDefault(site.ID); err != nil { + t.Fatal(err) + } + before := storageSnapshot(t, db) + + err := languages.ConfirmDefault(site.ID) + + if err != nil || storageSnapshot(t, db) != before { + t.Errorf("the second assignment must change nothing: %v", err) + } + for _, row := range storedArticles(t, db) { + if row.Lang != "fr-CA" { + t.Errorf("article %d has lang %q", row.ID, row.Lang) + } + } + }) + + t.Run("Confirming a site without a default language is refused", func(t *testing.T) { + db := open(t) + site := storageDefaultSite(t, db) + + err := persistence.NewSiteLanguageRepository(db).ConfirmDefault(site.ID) + + if !errors.Is(err, language.ErrNoDefaultLanguage) { + t.Errorf("expected ErrNoDefaultLanguage, got %v", err) + } + }) + + t.Run("Unique indexes replace the per-site slug index", func(t *testing.T) { + db := open(t) + for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} { + if !db.Migrator().HasIndex(&entity.Article{}, name) { + t.Errorf("index %s is missing", name) + } + } + indexes, err := db.Migrator().GetIndexes(&entity.Article{}) + if err != nil { + t.Fatal(err) + } + for _, index := range indexes { + if index.Name() == siteSlugIndexName && !slices.Equal(slices.Sorted(slices.Values(index.Columns())), []string{"lang", "site_id", "slug"}) { + t.Errorf("the slug index must cover (site_id, lang, slug), got %v", index.Columns()) + } + } + }) + + t.Run("The per-language slug index is recorded and survives a second migration", func(t *testing.T) { + db := open(t) + if !markerRecorded(t, db, slugPerLanguageMarker) { + t.Error("the per-language slug index marker must be recorded") + } + + migrateStorage(t, db) + + if !db.Migrator().HasIndex(&entity.Article{}, siteSlugIndexName) || !markerRecorded(t, db, slugPerLanguageMarker) { + t.Error("a second migration must keep the index and its marker") + } + }) + + t.Run("A missing per-language slug index is recreated once the marker exists", func(t *testing.T) { + db := open(t) + if err := db.Migrator().DropIndex(&entity.Article{}, siteSlugIndexName); err != nil { + t.Fatal(err) + } + + migrateStorage(t, db) + + if !db.Migrator().HasIndex(&entity.Article{}, siteSlugIndexName) { + t.Error("the slug index must be recreated") + } + }) +} + +func TestArticleLocalizationStorageSQLite(t *testing.T) { + runArticleLocalizationStorage(t, openContractSQLite) +} + +func TestArticleLocalizationStorageMySQL(t *testing.T) { + runArticleLocalizationStorage(t, openContractMySQL) +} + +func TestArticleLocalizationStoragePostgres(t *testing.T) { + runArticleLocalizationStorage(t, openContractPostgres) +} diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index a0f20c0..4850889 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -23,7 +23,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/article_repository_site_lock_test.go b/test/article_repository_site_lock_test.go new file mode 100644 index 0000000..2767da4 --- /dev/null +++ b/test/article_repository_site_lock_test.go @@ -0,0 +1,100 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + domainSite "RenewCMS/internal/domain/site" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "errors" + "testing" +) + +func TestArticleRepositoryLocksOnlyWhenASiteIsInvolved(t *testing.T) { + t.Run("Creating an article without a site does not look up a site", func(t *testing.T) { + articles := persistence.NewArticleRepository(openContractSQLite(t)) + + created, err := articles.Create(newStorageArticle(0, "", "orphan", 0)) + + if err != nil { + t.Fatalf("expected the siteless article to be created, got %v", err) + } + if created.ID == 0 || created.SiteID != 0 { + t.Errorf("unexpected created article: %+v", created) + } + }) + + t.Run("Creating an article on a missing site fails", func(t *testing.T) { + articles := persistence.NewArticleRepository(openContractSQLite(t)) + + _, err := articles.Create(newStorageArticle(9999, "", "ghost", 0)) + + if !errors.Is(err, domainSite.ErrSiteNotFound) { + t.Errorf("expected ErrSiteNotFound, got %v", err) + } + }) + + t.Run("Updating the excerpt of a siteless article does not look up a site", func(t *testing.T) { + articles := persistence.NewArticleRepository(openContractSQLite(t)) + created, err := articles.Create(newStorageArticle(0, "", "orphan", 0)) + if err != nil { + t.Fatal(err) + } + + updated, err := articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")}) + + if err != nil || updated.Excerpt != "Renamed" { + t.Errorf("expected the excerpt update to succeed, got %+v %v", updated, err) + } + }) + + t.Run("Moving a siteless article to a site while setting a slug locks only the target", func(t *testing.T) { + db := openContractSQLite(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + created, err := articles.Create(newStorageArticle(0, "", "orphan", 0)) + if err != nil { + t.Fatal(err) + } + + updated, err := articles.Update(created.ID, domainArticle.Update{SiteID: &site.ID, Slug: localeOf("moved")}) + + if err != nil || updated.SiteID != site.ID || updated.Slug != "moved" { + t.Errorf("expected the move to succeed, got %+v %v", updated, err) + } + if stored := storedArticle(t, db, created.ID); stored.SiteID != site.ID || stored.Slug != "moved" { + t.Errorf("the move must be persisted, got site %d slug %q", stored.SiteID, stored.Slug) + } + }) + + t.Run("Setting a slug on a siteless article needs its site", func(t *testing.T) { + articles := persistence.NewArticleRepository(openContractSQLite(t)) + created, err := articles.Create(newStorageArticle(0, "", "orphan", 0)) + if err != nil { + t.Fatal(err) + } + + _, err = articles.Update(created.ID, domainArticle.Update{Slug: localeOf("renamed")}) + + if !errors.Is(err, domainSite.ErrSiteNotFound) { + t.Errorf("expected ErrSiteNotFound, got %v", err) + } + }) +} + +func TestArticleRepositoryPersistsASlugChange(t *testing.T) { + db := openContractSQLite(t) + articles := persistence.NewArticleRepository(db) + site := storageDefaultSite(t, db) + created, err := articles.Create(newStorageArticle(site.ID, "", "before", 0)) + if err != nil { + t.Fatal(err) + } + + updated, err := articles.Update(created.ID, domainArticle.Update{Slug: localeOf("after")}) + + if err != nil || updated.Slug != "after" { + t.Fatalf("expected the slug change to succeed, got %+v %v", updated, err) + } + if stored := storedArticle(t, db, created.ID); stored.Slug != "after" { + t.Errorf("the slug change must be persisted, got %q", stored.Slug) + } +} diff --git a/test/article_slug_backfill_test.go b/test/article_slug_backfill_test.go index 6a09eb8..2a92f6e 100644 --- a/test/article_slug_backfill_test.go +++ b/test/article_slug_backfill_test.go @@ -71,6 +71,11 @@ func openLegacyArticleDatabase(t *testing.T) *gorm.DB { func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) { t.Helper() + for index := range rows { + if rows[index].StoryID == 0 { + rows[index].StoryID = fixtureStory() + } + } if err := db.CreateInBatches(&rows, 100).Error; err != nil { t.Fatalf("seed: %v", err) } @@ -79,7 +84,7 @@ func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) { func legacyArticles(count int) []entity.Article { rows := make([]entity.Article, 0, count) for i := 1; i <= count; i++ { - rows = append(rows, entity.Article{Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)}) + rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)}) } return rows } @@ -217,7 +222,7 @@ func TestBackfillArticleSlugs(t *testing.T) { t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) { db := openLegacyArticleDatabase(t) - seedLegacyArticles(t, db, []entity.Article{{Title: "Gone one", SiteID: 1}, {Title: "Gone two", SiteID: 1}, {Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}}) + seedLegacyArticles(t, db, []entity.Article{{StoryID: fixtureStory(), Title: "Gone one", SiteID: 1}, {StoryID: fixtureStory(), Title: "Gone two", SiteID: 1}, {StoryID: fixtureStory(), Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}}) if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil { t.Fatalf("soft delete: %v", err) } diff --git a/test/article_test.go b/test/article_test.go index 5530d17..5b0509f 100644 --- a/test/article_test.go +++ b/test/article_test.go @@ -237,23 +237,23 @@ func (m *mockArticleRepo) GetAll() ([]article.Article, error) { return result, nil } -func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool { +func (m *mockArticleRepo) slugUsedOn(siteID uint32, lang string, slug string, exceptID uint32) bool { if slug == "" { return false } for id, other := range m.articles { - if id != exceptID && other.SiteID == siteID && other.Slug == slug { + if id != exceptID && other.SiteID == siteID && other.Lang == lang && other.Slug == slug { return true } } return false } -func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) { +func (m *mockArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) { if err := m.fail.trigger("SlugTaken"); err != nil { return false, err } - return m.slugUsedOn(siteID, slug, exceptID), nil + return m.slugUsedOn(siteID, lang, slug, exceptID), nil } func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) { @@ -339,7 +339,7 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) { } return article.Article{}, article.ErrSlugTaken } - if m.slugUsedOn(a.SiteID, a.Slug, 0) { + if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) { return article.Article{}, article.ErrSlugTaken } m.nextID++ @@ -395,7 +395,7 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) { return article.Article{}, article.ErrSlugTaken } - if m.slugUsedOn(a.SiteID, a.Slug, id) { + if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, id) { return article.Article{}, article.ErrSlugTaken } applySeoUpdate(&a, update) @@ -479,7 +479,7 @@ var _ article.Repository = &mockArticleRepo{} func TestCreateArticle(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) t.Run("Valid Title", func(t *testing.T) { cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"} diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index bd59c64..f814b02 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -26,7 +26,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(repo, discardRecorder), @@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) { for _, tc := range cases { t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) title := strings.Repeat("\U0001F600", tc.runeCount) result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index 8ac463f..ab35230 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -20,7 +20,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/author_support_test.go b/test/author_support_test.go index c1a3183..4ce88b5 100644 --- a/test/author_support_test.go +++ b/test/author_support_test.go @@ -359,7 +359,7 @@ func newAuthorEnv(t *testing.T) *authorEnv { renderer := &fakeBodyRenderer{} mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c := p.container - c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) + c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles) diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index 27e761c..463f53a 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -24,7 +24,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/language_domain_test.go b/test/language_domain_test.go new file mode 100644 index 0000000..917dc0d --- /dev/null +++ b/test/language_domain_test.go @@ -0,0 +1,103 @@ +package test + +import ( + "RenewCMS/internal/domain/language" + entity "RenewCMS/internal/infrastructure/persistence/models" + "errors" + "sync" + "testing" + + "gorm.io/gorm/schema" +) + +func TestLanguageNormalization(t *testing.T) { + cases := []struct { + input string + want string + err error + }{ + {"en", "en", nil}, + {"EN", "en", nil}, + {"pt-br", "pt-BR", nil}, + {"PT_BR", "", language.ErrInvalidLanguage}, + {"fr-ca", "fr-CA", nil}, + {" fr", "fr", nil}, + {"e", "", language.ErrInvalidLanguage}, + {"english", "", language.ErrInvalidLanguage}, + {"en-ZZZZ", "", language.ErrInvalidLanguage}, + {"", "", language.ErrInvalidLanguage}, + {"xx", "", language.ErrUnsupportedLanguage}, + } + for _, c := range cases { + t.Run("Language tag normalization "+c.input, func(t *testing.T) { + got, err := language.Normalize(c.input) + if got != c.want || !errors.Is(err, c.err) { + t.Errorf("Normalize(%q) = %q, %v; want %q, %v", c.input, got, err, c.want, c.err) + } + }) + } +} + +func TestLanguageSuggestion(t *testing.T) { + text := func(value string) *string { return &value } + cases := []struct { + locale *string + suggested string + substituted bool + }{ + {text("pt-BR"), "pt-BR", false}, + {text("en-GB"), "en-GB", false}, + {text("fr-BE"), "fr", true}, + {text("fr"), "fr", false}, + {text("xx-YY"), "", false}, + {nil, "", false}, + } + for _, c := range cases { + name := "none" + if c.locale != nil { + name = *c.locale + } + t.Run("Suggestion for a site's default language "+name, func(t *testing.T) { + got := language.Suggest(c.locale) + if got.Tag != c.suggested || got.Substituted != c.substituted || got.Found != (c.suggested != "") { + t.Errorf("Suggest = %+v; want %q substituted %v", got, c.suggested, c.substituted) + } + }) + } +} + +func TestLanguageSupportedReturnsIndependentCopy(t *testing.T) { + first := language.Supported() + if len(first) == 0 { + t.Fatal("expected supported languages") + } + first[0] = "xx" + second := language.Supported() + if second[0] == "xx" { + t.Fatal("Supported must return a copy") + } + if _, err := language.Normalize(second[0]); err != nil { + t.Fatalf("supported tag %q must normalize: %v", second[0], err) + } +} + +func TestLanguageColumnsMatchTheDomainTagLength(t *testing.T) { + columns := []struct { + model any + field string + column string + }{ + {&entity.Article{}, "Lang", "lang"}, + {&entity.SiteLanguage{}, "Lang", "lang"}, + {&entity.Site{}, "Locale", "locale"}, + } + for _, c := range columns { + parsed, err := schema.Parse(c.model, &sync.Map{}, schema.NamingStrategy{}) + if err != nil { + t.Fatal(err) + } + if size := parsed.LookUpField(c.field).Size; size != language.MaxTagLength { + t.Errorf("%s.%s has size %d, expected %d", parsed.Table, c.column, size, language.MaxTagLength) + } + } +} diff --git a/test/mcp_server_test.go b/test/mcp_server_test.go index 0b25e36..080a81d 100644 --- a/test/mcp_server_test.go +++ b/test/mcp_server_test.go @@ -128,7 +128,7 @@ func TestMCPUploadsMultipartFromBase64(t *testing.T) { t.Fatal(toolText(res)) } call := f.last() - if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") { + if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, "name=file") || !strings.Contains(call.body, "filename=wall.png") || !strings.Contains(call.body, "Content-Type: image/png") || !strings.Contains(call.body, "PNGDATA") { t.Errorf("sent %+v", call) } if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError { diff --git a/test/media_env_test.go b/test/media_env_test.go index 727f3a8..004b84c 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -67,7 +67,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { now := func() time.Time { return env.clock } container := &api.UseCases{ Token: env.token, - CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images), diff --git a/test/media_persistence_test.go b/test/media_persistence_test.go index 9cc92b7..733b868 100644 --- a/test/media_persistence_test.go +++ b/test/media_persistence_test.go @@ -18,7 +18,7 @@ import ( func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) { t.Helper() db := openInMemoryDb(t) - if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil { + if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil { t.Fatalf("failed to migrate: %v", err) } uploadDir := t.TempDir() diff --git a/test/media_storage_orchestration_test.go b/test/media_storage_orchestration_test.go index b998a79..56a3137 100644 --- a/test/media_storage_orchestration_test.go +++ b/test/media_storage_orchestration_test.go @@ -188,7 +188,7 @@ func TestDriverRegistries(t *testing.T) { if err := database.Migrate(connection.DB, migrationPublicURL); err != nil { t.Fatalf("Migrate failed: %v", err) } - if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil { + if err := connection.DB.Create(&models.Article{StoryID: fixtureStory(), Title: "t"}).Error; err != nil { t.Fatalf("expected the schema to exist: %v", err) } if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil { diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 1425d60..a53fdd6 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -35,7 +35,7 @@ func openInMemoryDb(t *testing.T) *gorm.DB { func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) { db := openInMemoryDb(t) - if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil { + if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewArticleRepository(db) @@ -126,7 +126,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 9663ede..f782fc8 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -42,7 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index 4e48083..2c6426f 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -24,7 +24,7 @@ import ( func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/roles_content_scope_usecases_test.go b/test/roles_content_scope_usecases_test.go index 018dd75..f38fac0 100644 --- a/test/roles_content_scope_usecases_test.go +++ b/test/roles_content_scope_usecases_test.go @@ -294,7 +294,7 @@ func TestContentScopeArticleUseCases(t *testing.T) { t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) { repo, sites, authors, _ := newRepos() - create := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors) + create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, authors) created, err := create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(25, "content.create=all", "content.update:article=none")}) if err != nil || created.CreatedBy != 25 || created.IsOnline { t.Fatalf("expected a draft created by 25, got %+v %v", created, err) @@ -493,7 +493,7 @@ func TestContentScopeCreateArticleAuthorLookupFailure(t *testing.T) { boom := errors.New("author store down") authors.fail.failNextWith("GetByUserID", boom) articles := newMockArticleRepo() - create := useCases.NewCreateArticleUseCase(articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), authors) + create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), authors) caller := useCases.ContentCaller{UserID: 9, Grants: role.Role{Grants: grantRows("content.create=all")}.GrantSet()} _, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller}) if !errors.Is(err, boom) { diff --git a/test/roles_permissions_foundation_test.go b/test/roles_permissions_foundation_test.go index cce4c4e..8db2c30 100644 --- a/test/roles_permissions_foundation_test.go +++ b/test/roles_permissions_foundation_test.go @@ -270,7 +270,7 @@ func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 { func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) { t.Helper() - if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil { + if err := db.Create(&models.Article{ID: id, StoryID: fixtureStory(), Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil { t.Fatal(err) } } @@ -339,7 +339,7 @@ func TestBackfillCreatedBy(t *testing.T) { func TestNewArticlesRecordTheirCreator(t *testing.T) { t.Run("New articles record their creator", func(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)}) if err != nil { t.Fatal(err) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 42bff13..bcc0e11 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -130,7 +130,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { return &api.UseCases{ Token: token, - CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), diff --git a/test/seo_data_cleaner_test.go b/test/seo_data_cleaner_test.go index a5ca029..d585e43 100644 --- a/test/seo_data_cleaner_test.go +++ b/test/seo_data_cleaner_test.go @@ -70,7 +70,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) { t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) { e := newSeoEnv(t) racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles} - create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors) + create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(racing), &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors) _, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"}) diff --git a/test/seo_data_persistence_test.go b/test/seo_data_persistence_test.go index 7ebcd0b..29f1802 100644 --- a/test/seo_data_persistence_test.go +++ b/test/seo_data_persistence_test.go @@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) { migrateSeo(t, db) found, err := persistence.NewArticleRepository(db).Get(1) - want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt} + want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1} if err != nil || !reflect.DeepEqual(found, want) { t.Errorf("found %+v, %v", found, err) } @@ -251,7 +251,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) { if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") { t.Fatal("the index is missing") } - duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"} + duplicate := entity.Article{StoryID: fixtureStory(), Title: "Again", SiteID: 1, Slug: "news"} if err := db.Create(&duplicate).Error; err == nil { t.Error("a duplicate (site_id, slug) was accepted") } @@ -299,7 +299,7 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) { _, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"}) _, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"}) - rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error + rawDuplicate := db.Create(&entity.Article{StoryID: fixtureStory(), Title: "Four", SiteID: siteID, Slug: "spring"}).Error if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil { t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate) @@ -346,10 +346,10 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) { {"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}) - own, ownErr := articles.SlugTaken(siteID, "spring", created.ID) - other, otherErr := articles.SlugTaken(siteID, "spring", 0) + own, ownErr := articles.SlugTaken(siteID, "", "spring", created.ID) + other, otherErr := articles.SlugTaken(siteID, "", "spring", 0) deleteErr := articles.Delete(created.ID) - afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0) + afterDelete, afterErr := articles.SlugTaken(siteID, "", "spring", 0) _, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"}) if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil { diff --git a/test/seo_data_support_test.go b/test/seo_data_support_test.go index 9ca8e9c..7b0c746 100644 --- a/test/seo_data_support_test.go +++ b/test/seo_data_support_test.go @@ -55,7 +55,7 @@ func newSeoEnv(t *testing.T) *seoEnv { renderer := &fakeBodyRenderer{} mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c := p.container - c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) + c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) diff --git a/test/seo_filter_performance_test.go b/test/seo_filter_performance_test.go index 6cc1d01..a399205 100644 --- a/test/seo_filter_performance_test.go +++ b/test/seo_filter_performance_test.go @@ -29,7 +29,7 @@ func TestSeoFilterPerformance(t *testing.T) { body := strings.Repeat("

"+strings.Repeat("word ", 120)+"

", 8) rows := make([]entity.Article, 0, seoPerformanceArticles) for i := 1; i <= seoPerformanceArticles; i++ { - row := entity.Article{Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"} + row := entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"} if i%2 == 0 { row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100) } diff --git a/test/seo_output_support_test.go b/test/seo_output_support_test.go index 0bcafd4..fe685da 100644 --- a/test/seo_output_support_test.go +++ b/test/seo_output_support_test.go @@ -69,9 +69,9 @@ func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Coun return m.mockArticleRepo.CountByVisibility(onlineOnly) } -func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) { +func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) { m.reads["SlugTaken"]++ - return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID) + return m.mockArticleRepo.SlugTaken(siteID, lang, slug, exceptID) } func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) { diff --git a/test/site_domain_test.go b/test/site_domain_test.go index 080d79e..36db3b2 100644 --- a/test/site_domain_test.go +++ b/test/site_domain_test.go @@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) { func TestSiteUseCaseRules(t *testing.T) { t.Run("Creating an article assigns it to the default site", func(t *testing.T) { repo := newMockArticleRepo() - created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"}) + created, err := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"}) if err != nil || created.SiteID != 1 { t.Errorf("created %+v, %v", created, err) } @@ -102,7 +102,7 @@ func TestSiteUseCaseRules(t *testing.T) { t.Run("Creating an article fails when no default site can be read", func(t *testing.T) { repo := newMockArticleRepo() sites := newMockSiteRepo(repo) - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors()) _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"}) diff --git a/test/site_support_test.go b/test/site_support_test.go index 06123d2..d12eb7b 100644 --- a/test/site_support_test.go +++ b/test/site_support_test.go @@ -300,7 +300,7 @@ func newSiteEnv(t *testing.T) *siteEnv { renderer := &fakeBodyRenderer{} mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) c := p.container - c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors()) + c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors()) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) diff --git a/test/sqlite_schema_unchanged_test.go b/test/sqlite_schema_unchanged_test.go index b9ab9ab..f3d0873 100644 --- a/test/sqlite_schema_unchanged_test.go +++ b/test/sqlite_schema_unchanged_test.go @@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) { var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"} -var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"} +var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision"} func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { t.Helper() @@ -238,7 +238,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { } func isNewTableObject(name string) bool { - for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role"} { + for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role", "stories", "site_languages", "idx_articles_story_lang"} { if strings.HasPrefix(name, prefix) { return true } diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index 41199d7..6be8cd3 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -23,7 +23,7 @@ import ( func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index bc20ab9..71b64c6 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -30,7 +30,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) { for _, tc := range cases { t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) title := strings.Repeat("a", tc.length) result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) @@ -63,7 +63,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) { func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) { repo := newMockArticleRepo() repo.fail.failNext("Create") - uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) + uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()) _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"}) diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index ac7c232..0090083 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -21,7 +21,7 @@ import ( func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index bf456ce..174649a 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -25,7 +25,7 @@ import ( func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository { t.Helper() db := openInMemoryDb(t) - if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil { + if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil { t.Fatalf("failed to migrate: %v", err) } repo := persistence.NewArticleRepository(db) @@ -213,7 +213,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), + useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),