feat: storage, language rules and migration

This commit is contained in:
Florian Sylvain committed 2026-11-01 17:50:28 +01:00
1 parent 8c4a1825ab
commit 0806733d5c
70 files changed
+1757 -118

No files matched your search

+4 -1
View File
@@ -10,6 +10,7 @@ import (
domainEntry "RenewCMS/internal/domain/entry" domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainLanguage "RenewCMS/internal/domain/language"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role" domainRole "RenewCMS/internal/domain/role"
@@ -176,6 +177,7 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
type Repositories struct { type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
SiteRepo domainSite.Repository SiteRepo domainSite.Repository
LanguageRepo domainLanguage.Repository
ContentTypeRepo domainContentType.Repository ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository AuthorRepo domainAuthor.Repository
@@ -324,6 +326,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
return &Repositories{ return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db), SiteRepo: persistence.NewSiteRepository(db),
LanguageRepo: persistence.NewSiteLanguageRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db), ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db), EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db), AuthorRepo: persistence.NewAuthorRepository(db),
@@ -380,7 +383,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)), 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), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
+4
View File
@@ -59,6 +59,8 @@ type baseArticleResponse struct {
Authors []author.Byline `json:"authors"` Authors []author.Byline `json:"authors"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Lang string `json:"lang"`
StoryID uint32 `json:"story_id"`
} }
type articleResponse struct { type articleResponse struct {
@@ -117,6 +119,8 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
Authors: authors, Authors: authors,
CreatedAt: article.CreatedAt, CreatedAt: article.CreatedAt,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
Lang: article.Lang,
StoryID: article.StoryID,
}, nil }, nil
} }
+6 -2
View File
@@ -87,10 +87,14 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
"noindex": false, "noindex": false,
"seo_dismissed": [], "seo_dismissed": [],
"created_at": "2026-03-12T09:14:00Z", "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. `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. 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 ### 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 `<slug>-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. 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 `<slug>-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.
+2 -2
View File
@@ -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. `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-<id>` (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-<id>` (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`. 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`.
@@ -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 "<input>" is normalized
Then the result is "<result>"
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 "<locale>"
When the default language suggestion is computed
Then the suggestion is "<suggested>" and the substitution flag is <substituted>
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 "<lang>" in story 5
When it is read through "<read>"
Then the JSON contains "lang": "<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
@@ -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.
+4 -1
View File
@@ -17,7 +17,7 @@ var (
ErrArticleOffline = errors.New("article is offline") ErrArticleOffline = errors.New("article is offline")
ErrInvalidSlug = errors.New("invalid slug") 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") ErrInvalidCanonicalURL = errors.New("invalid canonical url")
) )
@@ -134,6 +134,9 @@ type Article struct {
SEODismissed []string `json:"seo_dismissed"` SEODismissed []string `json:"seo_dismissed"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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 { func (a Article) UsesImage(imageID uint32) bool {
@@ -102,7 +102,7 @@ type Repository interface {
Delete(id uint32) error Delete(id uint32) error
FindByBodyContaining(filename string) ([]Article, error) FindByBodyContaining(filename string) ([]Article, error)
FindByImage(imageID uint32) ([]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) ClearImage(imageID uint32) (ImageReferences, error)
RestoreImage(references ImageReferences, imageID uint32) error RestoreImage(references ImageReferences, imageID uint32) error
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
@@ -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
}
@@ -0,0 +1,7 @@
package language
type Repository interface {
ListBySite(siteID uint32) ([]SiteLanguage, error)
LockStartingLanguage(siteID uint32) (string, error)
ConfirmDefault(siteID uint32) error
}
@@ -5,6 +5,7 @@ import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"context" "context"
) )
@@ -15,6 +16,7 @@ type Repositories struct {
Authors author.Repository Authors author.Repository
Entries entry.Repository Entries entry.Repository
Images image.Repository Images image.Repository
Languages language.Repository
} }
type Transactor interface { type Transactor interface {
@@ -11,11 +11,13 @@ import (
const ( const (
articleSlugIndexName = "idx_articles_site_slug" articleSlugIndexName = "idx_articles_site_slug"
articleSlugBackfillBatchSize = 500 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 { type slugOwner struct {
siteID uint32 siteID uint32
lang string
slug string slug string
} }
@@ -26,7 +28,7 @@ type slugAssignment struct {
func BackfillArticleSlugs(db *gorm.DB) error { func BackfillArticleSlugs(db *gorm.DB) error {
var rows []entity.Article 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 return err
} }
assignments := planSlugAssignments(rows) assignments := planSlugAssignments(rows)
@@ -60,7 +62,7 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
} }
continue continue
} }
owner := slugOwner{row.SiteID, row.Slug} owner := slugOwner{row.SiteID, row.Lang, row.Slug}
if row.Slug == "" || claimed[owner] { if row.Slug == "" || claimed[owner] {
pending = append(pending, row) pending = append(pending, row)
continue continue
@@ -74,25 +76,41 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
base = snapshotSlug(row.Title) base = snapshotSlug(row.Title)
attempt = 1 attempt = 1
} }
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt) slug := firstFreeSnapshotSlug(claimed, row.SiteID, row.Lang, base, attempt)
claimed[slugOwner{row.SiteID, slug}] = true claimed[slugOwner{row.SiteID, row.Lang, slug}] = true
assignments = append(assignments, slugAssignment{row.ID, slug}) assignments = append(assignments, slugAssignment{row.ID, slug})
} }
return assignments 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++ { for ; ; attempt++ {
candidate := snapshotSlugWithSuffix(base, attempt) candidate := snapshotSlugWithSuffix(base, attempt)
if !claimed[slugOwner{siteID, candidate}] { if !claimed[slugOwner{siteID, lang, candidate}] {
return candidate return candidate
} }
} }
} }
func ensureArticleSlugIndex(db *gorm.DB) error { 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 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)
} }
@@ -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
}
@@ -7,7 +7,7 @@ import (
) )
func Migrate(db *gorm.DB, publicURL string) error { 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 { if err != nil {
return err return err
} }
@@ -35,6 +35,15 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := BackfillArticleSlugs(db); err != nil { if err := BackfillArticleSlugs(db); err != nil {
return err 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) return ensureArticleSlugIndex(db)
} }
@@ -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)
})
}
@@ -52,6 +52,9 @@ func ArticleToDomain(m models.Article) article.Article {
SEODismissed: decodeDismissed(m.SEODismissed), SEODismissed: decodeDismissed(m.SEODismissed),
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, 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), SEODismissed: encodeDismissed(a.SEODismissed),
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
Lang: a.Lang,
StoryID: a.StoryID,
ContentRevision: a.ContentRevision,
} }
} }
@@ -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,
}
}
@@ -28,4 +28,7 @@ type Article struct {
SEODismissed string `gorm:"column:seo_dismissed;type:text"` SEODismissed string `gorm:"column:seo_dismissed;type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` 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"`
} }
@@ -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"`
}
@@ -0,0 +1,8 @@
package models
import "time"
type Story struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -216,7 +216,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil 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 { func chunkIDs(ids []uint32) [][]uint32 {
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1) 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) { func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article) created := mappers.ArticleToModel(article)
created.ContentRevision = initialContentRevision
err := a.db.Transaction(func(tx *gorm.DB) error { err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 { if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil { if _, err := lockSite(tx, article.SiteID); err != nil {
return err 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 return err
} }
if err := tx.Create(&created).Error; err != nil { 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)) return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
}) })
if err != nil { 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)) 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 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 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 == "" { if slug == "" {
return nil return nil
} }
taken, err := articleSlugInUse(db, siteID, slug, exceptID) taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
if err != nil { if err != nil {
return err return err
} }
@@ -356,18 +377,18 @@ func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) er
return nil 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) { if errors.Is(cause, domain.ErrSlugTaken) {
return cause 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 err
} }
return cause return cause
} }
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) { func (a *ArticleRepository) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
return articleSlugInUse(a.db, siteID, slug, exceptID) return articleSlugInLanguage(a.db, siteID, lang, slug, exceptID)
} }
func (a *ArticleRepository) GetAll() ([]domain.Article, error) { func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
@@ -393,7 +414,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
} }
localArticle.Body = body localArticle.Body = body
err = a.db.Save(&localArticle).Error err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil { if err != nil {
return domain.Article{}, err return domain.Article{}, err
} }
@@ -419,11 +440,11 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
} }
applyUpdate(&localArticle, update) applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil { 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 return err
} }
} }
if err := tx.Save(&localArticle).Error; err != nil { if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
return err return err
} }
if update.AuthorIDs != nil { if update.AuthorIDs != nil {
@@ -432,7 +453,7 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
return nil return nil
}) })
if err != 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)) 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 localArticle.IsOnline = isOnline
err = a.db.Save(&localArticle).Error err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil { if err != nil {
return domain.Article{}, err return domain.Article{}, err
} }
@@ -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{}
@@ -25,6 +25,7 @@ func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) err
Authors: NewAuthorRepository(tx), Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx), Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx), Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx),
}) })
}) })
} }
@@ -126,11 +126,11 @@ func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.S
if candidate.Slug == "" { if candidate.Slug == "" {
return available, nil 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 { if err != nil || !taken {
return available, err 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 { if err != nil {
return article.SEOSlugCheck{}, err return article.SEOSlugCheck{}, err
} }
@@ -7,12 +7,13 @@ import (
"RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"context" "context"
"errors" "errors"
) )
type CreateArticleUseCase struct { type CreateArticleUseCase struct {
articleRepository article.Repository transactor transaction.Transactor
bodyRenderer article.BodyRenderer bodyRenderer article.BodyRenderer
recorder audit.Recorder recorder audit.Recorder
sites site.Repository sites site.Repository
@@ -26,8 +27,8 @@ type CreateArticleCommand struct {
Caller ContentCaller Caller ContentCaller
} }
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase { func NewCreateArticleUseCase(transactor transaction.Transactor, 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} return &CreateArticleUseCase{transactor: transactor, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
} }
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) { func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
@@ -71,7 +72,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err return article.Article{}, err
} }
created, err := g.createWithFreeSlug(article.Article{ created, err := g.createWithFreeSlug(ctx, article.Article{
Title: createArticle.Title, Title: createArticle.Title,
Body: createArticle.Body, Body: createArticle.Body,
IsOnline: false, IsOnline: false,
@@ -90,15 +91,9 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return renderArticle(g.bodyRenderer, created, createArticle.Format) return renderArticle(g.bodyRenderer, created, createArticle.Format)
} }
func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) { func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) {
base := article.GenerateSlug(draft.Title)
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ { for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt) created, err := g.createInTransaction(ctx, draft)
if err != nil {
return article.Article{}, err
}
draft.Slug = slug
created, err := g.articleRepository.Create(draft)
if !isSlugTakenError(err) { if !isSlugTakenError(err) {
return created, err return created, err
} }
@@ -106,6 +101,24 @@ func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (articl
return article.Article{}, article.ErrSlugTaken 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 { func articleTarget(a article.Article) audit.Target {
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title) return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
} }
@@ -156,12 +156,12 @@ func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command Updat
if slug == "" || (slug == before.Slug && target == before.SiteID) { if slug == "" || (slug == before.Slug && target == before.SiteID) {
return nil, nil 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 { if err != nil {
return nil, err return nil, err
} }
if taken { 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 { if slug == before.Slug {
return nil, nil return nil, nil
@@ -174,7 +174,7 @@ func (g *UpdateArticleUseCase) raceConflict(before article.Article, command Upda
if chosen != nil { if chosen != nil {
slug = *chosen 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) { func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
@@ -12,10 +12,10 @@ const (
maxFreeSlugAttempts = 100 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++ { for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ {
candidate := article.SlugWithSuffix(base, attempt) candidate := article.SlugWithSuffix(base, attempt)
taken, err := articles.SlugTaken(siteID, candidate, exceptID) taken, err := articles.SlugTaken(siteID, lang, candidate, exceptID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -26,8 +26,8 @@ func freeSlug(articles article.Repository, siteID uint32, base string, exceptID
return "", article.ErrSlugTaken return "", article.ErrSlugTaken
} }
func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error { func slugConflict(articles article.Repository, siteID uint32, lang string, slug string, exceptID uint32) error {
suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt) suggestion, err := freeSlug(articles, siteID, lang, slug, exceptID, firstSuggestedAttempt)
if err != nil { if err != nil {
return err return err
} }
+1 -1
View File
@@ -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.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: 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.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.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 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"}]`}, &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"}]`},
+7 -7
View File
@@ -73,13 +73,13 @@ func articleIDs(listed []article.Article) []uint32 {
func seedExportRows(t *testing.T, db *gorm.DB) { func seedExportRows(t *testing.T, db *gorm.DB) {
t.Helper() t.Helper()
rows := []any{ rows := []any{
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7}, &entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7},
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8}, &entity.Article{ID: 2, StoryID: fixtureStory(), Title: "Ines", Slug: "ines", CreatedBy: 8},
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true}, &entity.Article{ID: 3, StoryID: fixtureStory(), Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8}, &entity.Article{ID: 7, StoryID: fixtureStory(), Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7}, &entity.Article{ID: 8, StoryID: fixtureStory(), Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7}, &entity.Article{ID: 4, StoryID: fixtureStory(), Title: "Gone", Slug: "gone", CreatedBy: 7},
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true}, &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: 3, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5}, &entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5}, &entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
+1 -1
View File
@@ -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) { t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
h := newActivityHarness(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"}) _, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"})
+1 -1
View File
@@ -109,7 +109,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
restartJobs := &fakeJobStore{} restartJobs := &fakeJobStore{}
env.container = &api.UseCases{ env.container = &api.UseCases{
Token: env.token, 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), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
+1 -1
View File
@@ -43,7 +43,7 @@ const (
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux { func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -23,7 +23,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(routerUsers) getUserUseCase := useCases.NewGetUserUseCase(routerUsers)
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, renderer), useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()), useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -22,7 +22,7 @@ func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) {
t.Helper() t.Helper()
rows := make([]entity.Article, 0, count) rows := make([]entity.Article, 0, count)
for i := 1; i <= count; i++ { 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 { if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed articles: %v", err) t.Fatalf("seed articles: %v", err)
+79
View File
@@ -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)
}
})
}
+56
View File
@@ -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)
}
+1 -1
View File
@@ -35,7 +35,7 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
GetUserUseCase: useCases.NewGetUserUseCase(e.users), GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles), CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 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{}), GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
@@ -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
}
@@ -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)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+100
View File
@@ -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)
}
}
+7 -2
View File
@@ -71,6 +71,11 @@ func openLegacyArticleDatabase(t *testing.T) *gorm.DB {
func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) { func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
t.Helper() t.Helper()
for index := range rows {
if rows[index].StoryID == 0 {
rows[index].StoryID = fixtureStory()
}
}
if err := db.CreateInBatches(&rows, 100).Error; err != nil { if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed: %v", err) 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 { func legacyArticles(count int) []entity.Article {
rows := make([]entity.Article, 0, count) rows := make([]entity.Article, 0, count)
for i := 1; i <= count; i++ { 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 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) { t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) {
db := openLegacyArticleDatabase(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 { if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil {
t.Fatalf("soft delete: %v", err) t.Fatalf("soft delete: %v", err)
} }
+7 -7
View File
@@ -237,23 +237,23 @@ func (m *mockArticleRepo) GetAll() ([]article.Article, error) {
return result, nil 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 == "" { if slug == "" {
return false return false
} }
for id, other := range m.articles { 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 true
} }
} }
return false 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 { if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err 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) { 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 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 return article.Article{}, article.ErrSlugTaken
} }
m.nextID++ 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) { if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
return article.Article{}, article.ErrSlugTaken 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 return article.Article{}, article.ErrSlugTaken
} }
applySeoUpdate(&a, update) applySeoUpdate(&a, update)
@@ -479,7 +479,7 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) { func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo() 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) { t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"} cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"}
+2 -2
View File
@@ -26,7 +26,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder), useCases.NewDeleteArticleUseCase(repo, discardRecorder),
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) { t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo() 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) title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
+1 -1
View File
@@ -20,7 +20,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -359,7 +359,7 @@ func newAuthorEnv(t *testing.T) *authorEnv {
renderer := &fakeBodyRenderer{} renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) 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) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
+1 -1
View File
@@ -24,7 +24,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+103
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ func TestMCPUploadsMultipartFromBase64(t *testing.T) {
t.Fatal(toolText(res)) t.Fatal(toolText(res))
} }
call := f.last() 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) t.Errorf("sent %+v", call)
} }
if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError { if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError {
+1 -1
View File
@@ -67,7 +67,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
now := func() time.Time { return env.clock } now := func() time.Time { return env.clock }
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
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{}), GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
+1 -1
View File
@@ -18,7 +18,7 @@ import (
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) { func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
t.Helper() t.Helper()
db := openInMemoryDb(t) 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) t.Fatalf("failed to migrate: %v", err)
} }
uploadDir := t.TempDir() uploadDir := t.TempDir()
+1 -1
View File
@@ -188,7 +188,7 @@ func TestDriverRegistries(t *testing.T) {
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil { if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
t.Fatalf("Migrate failed: %v", err) 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) t.Fatalf("expected the schema to exist: %v", err)
} }
if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil { if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil {
+2 -2
View File
@@ -35,7 +35,7 @@ func openInMemoryDb(t *testing.T) *gorm.DB {
func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) { func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) {
db := openInMemoryDb(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) t.Fatalf("failed to migrate: %v", err)
} }
repo := persistence.NewArticleRepository(db) repo := persistence.NewArticleRepository(db)
@@ -126,7 +126,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -42,7 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+1 -1
View File
@@ -24,7 +24,7 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -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) { t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) {
repo, sites, authors, _ := newRepos() 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")}) 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 { if err != nil || created.CreatedBy != 25 || created.IsOnline {
t.Fatalf("expected a draft created by 25, got %+v %v", created, err) 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") boom := errors.New("author store down")
authors.fail.failNextWith("GetByUserID", boom) authors.fail.failNextWith("GetByUserID", boom)
articles := newMockArticleRepo() 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()} 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}) _, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller})
if !errors.Is(err, boom) { if !errors.Is(err, boom) {
+2 -2
View File
@@ -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) { func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper() 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) t.Fatal(err)
} }
} }
@@ -339,7 +339,7 @@ func TestBackfillCreatedBy(t *testing.T) {
func TestNewArticlesRecordTheirCreator(t *testing.T) { func TestNewArticlesRecordTheirCreator(t *testing.T) {
t.Run("New articles record their creator", func(t *testing.T) { t.Run("New articles record their creator", func(t *testing.T) {
repo := newMockArticleRepo() 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)}) created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -1
View File
@@ -130,7 +130,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{ return &api.UseCases{
Token: token, Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
+1 -1
View File
@@ -70,7 +70,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) {
t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) { t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) {
e := newSeoEnv(t) e := newSeoEnv(t)
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles} 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"}) _, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"})
+6 -6
View File
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
migrateSeo(t, db) migrateSeo(t, db)
found, err := persistence.NewArticleRepository(db).Get(1) 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) { if err != nil || !reflect.DeepEqual(found, want) {
t.Errorf("found %+v, %v", found, err) 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") { if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
t.Fatal("the index is missing") 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 { if err := db.Create(&duplicate).Error; err == nil {
t.Error("a duplicate (site_id, slug) was accepted") 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"}) _, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"})
_, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, 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 { if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil {
t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate) 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) { {"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"}) created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
own, ownErr := articles.SlugTaken(siteID, "spring", created.ID) own, ownErr := articles.SlugTaken(siteID, "", "spring", created.ID)
other, otherErr := articles.SlugTaken(siteID, "spring", 0) other, otherErr := articles.SlugTaken(siteID, "", "spring", 0)
deleteErr := articles.Delete(created.ID) 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"}) _, 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 { if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil {
+1 -1
View File
@@ -55,7 +55,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
renderer := &fakeBodyRenderer{} renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSeoFilterPerformance(t *testing.T) {
body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8) body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8)
rows := make([]entity.Article, 0, seoPerformanceArticles) rows := make([]entity.Article, 0, seoPerformanceArticles)
for i := 1; i <= seoPerformanceArticles; i++ { 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 { if i%2 == 0 {
row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100) row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100)
} }
+2 -2
View File
@@ -69,9 +69,9 @@ func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Coun
return m.mockArticleRepo.CountByVisibility(onlineOnly) 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"]++ 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) { func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) {
+2 -2
View File
@@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) {
func TestSiteUseCaseRules(t *testing.T) { func TestSiteUseCaseRules(t *testing.T) {
t.Run("Creating an article assigns it to the default site", func(t *testing.T) { t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
repo := newMockArticleRepo() 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 { if err != nil || created.SiteID != 1 {
t.Errorf("created %+v, %v", created, err) 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) { t.Run("Creating an article fails when no default site can be read", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
sites := newMockSiteRepo(repo) 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"}) _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
+1 -1
View File
@@ -300,7 +300,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
renderer := &fakeBodyRenderer{} renderer := &fakeBodyRenderer{}
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c := p.container c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors()) c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
+2 -2
View File
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"} 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) { func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper() t.Helper()
@@ -238,7 +238,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
} }
func isNewTableObject(name string) bool { 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) { if strings.HasPrefix(name, prefix) {
return true return true
} }
+1 -1
View File
@@ -23,7 +23,7 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux { func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -30,7 +30,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) { t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo() 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) title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"}) 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) { func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.fail.failNext("Create") 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"}) _, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"})
+1 -1
View File
@@ -21,7 +21,7 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux { func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
+2 -2
View File
@@ -25,7 +25,7 @@ import (
func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository { func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository {
t.Helper() t.Helper()
db := openInMemoryDb(t) 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) t.Fatalf("failed to migrate: %v", err)
} }
repo := persistence.NewArticleRepository(db) repo := persistence.NewArticleRepository(db)
@@ -213,7 +213,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()), useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder), useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),