feat: storage, language rules and migration
This commit is contained in:
1 parent
8c4a1825ab
commit
0806733d5c
70 files changed
+1771
-132
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
domainEntry "RenewCMS/internal/domain/entry"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainLanguage "RenewCMS/internal/domain/language"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainRole "RenewCMS/internal/domain/role"
|
||||
@@ -176,6 +177,7 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
SiteRepo domainSite.Repository
|
||||
LanguageRepo domainLanguage.Repository
|
||||
ContentTypeRepo domainContentType.Repository
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.Repository
|
||||
@@ -324,6 +326,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
return &Repositories{
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
SiteRepo: persistence.NewSiteRepository(db),
|
||||
LanguageRepo: persistence.NewSiteLanguageRepository(db),
|
||||
ContentTypeRepo: persistence.NewContentTypeRepository(db),
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
@@ -380,7 +383,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo),
|
||||
|
||||
@@ -59,6 +59,8 @@ type baseArticleResponse struct {
|
||||
Authors []author.Byline `json:"authors"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
}
|
||||
|
||||
type articleResponse struct {
|
||||
@@ -117,6 +119,8 @@ func toBaseResponse(article domain.Article, format domain.BodyFormat, viewer acc
|
||||
Authors: authors,
|
||||
CreatedAt: article.CreatedAt,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
Lang: article.Lang,
|
||||
StoryID: article.StoryID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,14 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"noindex": false,
|
||||
"seo_dismissed": [],
|
||||
"created_at": "2026-03-12T09:14:00Z",
|
||||
"updated_at": "2026-09-10T07:00:00Z"
|
||||
"updated_at": "2026-09-10T07:00:00Z",
|
||||
"lang": "fr-CA",
|
||||
"story_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only: `lang` and `story_id` sent in a `POST` or `PATCH` body are ignored. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
|
||||
|
||||
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
|
||||
|
||||
Every single-article read (`GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`) carries the twelve SEO fields. Each item of `GET /v1/article/` carries `slug` and `excerpt` only, and none of the other ten.
|
||||
@@ -116,7 +120,7 @@ Limits are counted in characters (runes), not bytes. Absent fields are untouched
|
||||
|
||||
### The slug
|
||||
|
||||
A slug is unique per site (`site_id`, `slug`), never globally. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
|
||||
A slug is unique per site and language (`site_id`, `lang`, `slug`), never globally; unassigned articles (`lang` `""`) share the empty language, so they keep the per-site rule. At creation it is generated from the title: lowercase, accents folded, every run of other characters becomes one dash, dashes trimmed, cut to 100 characters without a trailing dash, and `article` when no letter or digit is left. A slug already used on the site gets `-2`, `-3`... silently, the base being truncated so the result fits 100 characters. The same rule backfills existing articles on the first start after the upgrade, in id order within each site.
|
||||
|
||||
Editing a slug that another article of the target site already uses (the new site when `site_id` is sent in the same request; moving an article also re-checks its current slug there) answers `409` with the plain-text body `slug_taken`, and the response header `X-Slug-Suggestion` holds the first free `<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.
|
||||
|
||||
|
||||
@@ -94,9 +94,9 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
|
||||
|
||||
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
|
||||
|
||||
Slugs are unique per site, enforced by the unique index `idx_articles_site_slug` on `(site_id, slug)`. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -17,7 +17,7 @@ var (
|
||||
ErrArticleOffline = errors.New("article is offline")
|
||||
|
||||
ErrInvalidSlug = errors.New("invalid slug")
|
||||
ErrSlugTaken = errors.New("another article on this site already uses that slug")
|
||||
ErrSlugTaken = errors.New("another article on this site in that language already uses that slug")
|
||||
ErrInvalidCanonicalURL = errors.New("invalid canonical url")
|
||||
)
|
||||
|
||||
@@ -134,6 +134,9 @@ type Article struct {
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
ContentRevision uint32 `json:"-"`
|
||||
}
|
||||
|
||||
func (a Article) UsesImage(imageID uint32) bool {
|
||||
|
||||
@@ -102,7 +102,7 @@ type Repository interface {
|
||||
Delete(id uint32) error
|
||||
FindByBodyContaining(filename string) ([]Article, error)
|
||||
FindByImage(imageID uint32) ([]Article, error)
|
||||
SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error)
|
||||
SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error)
|
||||
ClearImage(imageID uint32) (ImageReferences, error)
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
|
||||
@@ -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,16 +5,18 @@ import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Repositories struct {
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Languages language.Repository
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
const (
|
||||
articleSlugIndexName = "idx_articles_site_slug"
|
||||
articleSlugBackfillBatchSize = 500
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)"
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, lang, slug)"
|
||||
articleSlugLanguageMarker = "article_slug_index_per_language"
|
||||
)
|
||||
|
||||
type slugOwner struct {
|
||||
siteID uint32
|
||||
lang string
|
||||
slug string
|
||||
}
|
||||
|
||||
@@ -26,7 +28,7 @@ type slugAssignment struct {
|
||||
|
||||
func BackfillArticleSlugs(db *gorm.DB) error {
|
||||
var rows []entity.Article
|
||||
if err := db.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
if err := db.Unscoped().Select("id", "site_id", "lang", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
assignments := planSlugAssignments(rows)
|
||||
@@ -60,7 +62,7 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
|
||||
}
|
||||
continue
|
||||
}
|
||||
owner := slugOwner{row.SiteID, row.Slug}
|
||||
owner := slugOwner{row.SiteID, row.Lang, row.Slug}
|
||||
if row.Slug == "" || claimed[owner] {
|
||||
pending = append(pending, row)
|
||||
continue
|
||||
@@ -74,25 +76,41 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
|
||||
base = snapshotSlug(row.Title)
|
||||
attempt = 1
|
||||
}
|
||||
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
|
||||
claimed[slugOwner{row.SiteID, slug}] = true
|
||||
slug := firstFreeSnapshotSlug(claimed, row.SiteID, row.Lang, base, attempt)
|
||||
claimed[slugOwner{row.SiteID, row.Lang, slug}] = true
|
||||
assignments = append(assignments, slugAssignment{row.ID, slug})
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
|
||||
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, lang string, base string, attempt int) string {
|
||||
for ; ; attempt++ {
|
||||
candidate := snapshotSlugWithSuffix(base, attempt)
|
||||
if !claimed[slugOwner{siteID, candidate}] {
|
||||
if !claimed[slugOwner{siteID, lang, candidate}] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureArticleSlugIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
|
||||
replaced, err := markerApplied(db, articleSlugLanguageMarker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indexed := db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName)
|
||||
if replaced && indexed {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(articleSlugIndexStatement).Error
|
||||
if indexed {
|
||||
if err := db.Migrator().DropIndex(&entity.Article{}, articleSlugIndexName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Exec(articleSlugIndexStatement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if replaced {
|
||||
return nil
|
||||
}
|
||||
return applyMarker(db, articleSlugLanguageMarker)
|
||||
}
|
||||
@@ -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 {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}, &models.Story{}, &models.SiteLanguage{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,6 +35,15 @@ func Migrate(db *gorm.DB, publicURL string) error {
|
||||
if err := BackfillArticleSlugs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillArticleStories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SeedSiteLanguageSuggestions(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureArticleStoryLanguageIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureArticleSlugIndex(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
Lang: m.Lang,
|
||||
StoryID: m.StoryID,
|
||||
ContentRevision: m.ContentRevision,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +79,8 @@ func ArticleToModel(a article.Article) models.Article {
|
||||
SEODismissed: encodeDismissed(a.SEODismissed),
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Lang: a.Lang,
|
||||
StoryID: a.StoryID,
|
||||
ContentRevision: a.ContentRevision,
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
Lang string `gorm:"size:35;not null;default:''"`
|
||||
StoryID uint32 `gorm:"not null;default:0"`
|
||||
ContentRevision uint32 `gorm:"not null;default:1"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
const idChunkSize = 500
|
||||
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"}
|
||||
|
||||
const (
|
||||
idChunkSize = 500
|
||||
initialContentRevision = 1
|
||||
)
|
||||
|
||||
func chunkIDs(ids []uint32) [][]uint32 {
|
||||
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
|
||||
@@ -316,13 +321,17 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
created := mappers.ArticleToModel(article)
|
||||
created.ContentRevision = initialContentRevision
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
|
||||
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := openStory(tx, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
@@ -331,22 +340,34 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
|
||||
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
|
||||
return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
||||
}
|
||||
|
||||
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
func openStory(tx *gorm.DB, created *entity.Article) error {
|
||||
if created.StoryID != 0 {
|
||||
return nil
|
||||
}
|
||||
story := entity.Story{}
|
||||
if err := tx.Create(&story).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created.StoryID = story.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func articleSlugInLanguage(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
||||
var total int64
|
||||
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
|
||||
err := db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ? AND id <> ?", siteID, lang, slug, exceptID).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
|
||||
func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
|
||||
taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -356,18 +377,18 @@ func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
|
||||
func (a *ArticleRepository) explainSlugFailure(siteID uint32, lang string, slug string, exceptID uint32, cause error) error {
|
||||
if errors.Is(cause, domain.ErrSlugTaken) {
|
||||
return cause
|
||||
}
|
||||
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
|
||||
if err := ensureSlugFree(a.db, siteID, lang, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
return articleSlugInUse(a.db, siteID, slug, exceptID)
|
||||
func (a *ArticleRepository) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
||||
return articleSlugInLanguage(a.db, siteID, lang, slug, exceptID)
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
|
||||
@@ -393,7 +414,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
|
||||
}
|
||||
|
||||
localArticle.Body = body
|
||||
err = a.db.Save(&localArticle).Error
|
||||
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
@@ -419,11 +440,11 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
}
|
||||
applyUpdate(&localArticle, update)
|
||||
if update.Slug != nil || update.SiteID != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Save(&localArticle).Error; err != nil {
|
||||
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if update.AuthorIDs != nil {
|
||||
@@ -432,7 +453,7 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
@@ -478,7 +499,7 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
|
||||
}
|
||||
|
||||
localArticle.IsOnline = isOnline
|
||||
err = a.db.Save(&localArticle).Error
|
||||
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
@@ -20,11 +20,12 @@ func NewTransactor(db *gorm.DB) *Transactor {
|
||||
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
|
||||
return t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return work(domain.Repositories{
|
||||
Articles: NewArticleRepository(tx),
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
Articles: NewArticleRepository(tx),
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
Languages: NewSiteLanguageRepository(tx),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -126,11 +126,11 @@ func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.S
|
||||
if candidate.Slug == "" {
|
||||
return available, nil
|
||||
}
|
||||
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID)
|
||||
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID)
|
||||
if err != nil || !taken {
|
||||
return available, err
|
||||
}
|
||||
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt)
|
||||
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID, firstSuggestedAttempt)
|
||||
if err != nil {
|
||||
return article.SEOSlugCheck{}, err
|
||||
}
|
||||
|
||||
@@ -7,16 +7,17 @@ import (
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type CreateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
transactor transaction.Transactor
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
@@ -26,8 +27,8 @@ type CreateArticleCommand struct {
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
func NewCreateArticleUseCase(transactor transaction.Transactor, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{transactor: transactor, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
|
||||
@@ -71,7 +72,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.createWithFreeSlug(article.Article{
|
||||
created, err := g.createWithFreeSlug(ctx, article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
@@ -90,15 +91,9 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return renderArticle(g.bodyRenderer, created, createArticle.Format)
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) {
|
||||
base := article.GenerateSlug(draft.Title)
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
|
||||
slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
draft.Slug = slug
|
||||
created, err := g.articleRepository.Create(draft)
|
||||
created, err := g.createInTransaction(ctx, draft)
|
||||
if !isSlugTakenError(err) {
|
||||
return created, err
|
||||
}
|
||||
@@ -106,6 +101,24 @@ func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (articl
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
var created article.Article
|
||||
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draft.Lang = lang
|
||||
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err = tx.Articles.Create(draft)
|
||||
return err
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
func articleTarget(a article.Article) audit.Target {
|
||||
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
|
||||
}
|
||||
@@ -156,12 +156,12 @@ func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command Updat
|
||||
if slug == "" || (slug == before.Slug && target == before.SiteID) {
|
||||
return nil, nil
|
||||
}
|
||||
taken, err := g.articleRepository.SlugTaken(target, slug, before.ID)
|
||||
taken, err := g.articleRepository.SlugTaken(target, before.Lang, slug, before.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, slugConflict(g.articleRepository, target, slug, before.ID)
|
||||
return nil, slugConflict(g.articleRepository, target, before.Lang, slug, before.ID)
|
||||
}
|
||||
if slug == before.Slug {
|
||||
return nil, nil
|
||||
@@ -174,7 +174,7 @@ func (g *UpdateArticleUseCase) raceConflict(before article.Article, command Upda
|
||||
if chosen != nil {
|
||||
slug = *chosen
|
||||
}
|
||||
return slugConflict(g.articleRepository, targetSiteID(before, command), slug, before.ID)
|
||||
return slugConflict(g.articleRepository, targetSiteID(before, command), before.Lang, slug, before.ID)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
|
||||
|
||||
@@ -12,10 +12,10 @@ const (
|
||||
maxFreeSlugAttempts = 100
|
||||
)
|
||||
|
||||
func freeSlug(articles article.Repository, siteID uint32, base string, exceptID uint32, firstAttempt int) (string, error) {
|
||||
func freeSlug(articles article.Repository, siteID uint32, lang string, base string, exceptID uint32, firstAttempt int) (string, error) {
|
||||
for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ {
|
||||
candidate := article.SlugWithSuffix(base, attempt)
|
||||
taken, err := articles.SlugTaken(siteID, candidate, exceptID)
|
||||
taken, err := articles.SlugTaken(siteID, lang, candidate, exceptID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -26,8 +26,8 @@ func freeSlug(articles article.Repository, siteID uint32, base string, exceptID
|
||||
return "", article.ErrSlugTaken
|
||||
}
|
||||
|
||||
func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error {
|
||||
suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt)
|
||||
func slugConflict(articles article.Repository, siteID uint32, lang string, slug string, exceptID uint32) error {
|
||||
suggestion, err := freeSlug(articles, siteID, lang, slug, exceptID, firstSuggestedAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func seedDeleteAccountRows(t *testing.T, db *gorm.DB, creditedArticles bool) {
|
||||
&entity.APIKey{ID: 3, UserID: 8, Name: "ines", Prefix: "c", HashedKey: "h3"},
|
||||
&entity.Author{ID: 5, Name: "Marta Alves", Slug: "marta-alves", Bio: "bio", Website: &website, SocialLinks: `[{"type":"github","url":"https://github.com/marta"}]`, UserID: &martaID, AvatarID: &martaID},
|
||||
&entity.Author{ID: 6, Name: "Ines", Slug: "ines", SocialLinks: "[]", UserID: &inesID},
|
||||
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Entry{ID: 1, ContentTypeID: 1, Data: "{}", CreatedBy: 7, UpdatedBy: 7},
|
||||
&entity.Image{ID: 1, Path: "/a.png", Filename: "a.png", UploadedByID: 7},
|
||||
&entity.AuditEvent{ID: 1, Action: "article.edit", Category: "articles", ActorType: "user", ActorID: &martaID, ActorName: "marta", TargetType: "article", TargetID: "1", TargetName: "Mine", Outcome: "success", Source: "web", IP: &ip, UserAgent: &agent, Params: `{"title":"Mine"}`, Changes: `[{"field":"title","kind":"text","after":"Mine"}]`},
|
||||
|
||||
@@ -73,13 +73,13 @@ func articleIDs(listed []article.Article) []uint32 {
|
||||
func seedExportRows(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
rows := []any{
|
||||
&entity.Article{ID: 1, Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Article{ID: 2, Title: "Ines", Slug: "ines", CreatedBy: 8},
|
||||
&entity.Article{ID: 3, Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
|
||||
&entity.Article{ID: 7, Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
|
||||
&entity.Article{ID: 8, Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
|
||||
&entity.Article{ID: 4, Title: "Gone", Slug: "gone", CreatedBy: 7},
|
||||
&entity.Article{ID: 6, Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
|
||||
&entity.Article{ID: 1, StoryID: fixtureStory(), Title: "Mine", Slug: "mine", CreatedBy: 7},
|
||||
&entity.Article{ID: 2, StoryID: fixtureStory(), Title: "Ines", Slug: "ines", CreatedBy: 8},
|
||||
&entity.Article{ID: 3, StoryID: fixtureStory(), Title: "Credited", Slug: "credited", CreatedBy: 8, IsOnline: true},
|
||||
&entity.Article{ID: 7, StoryID: fixtureStory(), Title: "Credited draft", Slug: "credited-draft", CreatedBy: 8},
|
||||
&entity.Article{ID: 8, StoryID: fixtureStory(), Title: "Mine draft", Slug: "mine-draft", CreatedBy: 7},
|
||||
&entity.Article{ID: 4, StoryID: fixtureStory(), Title: "Gone", Slug: "gone", CreatedBy: 7},
|
||||
&entity.Article{ID: 6, StoryID: fixtureStory(), Title: "Credited gone", Slug: "credited-gone", CreatedBy: 8, IsOnline: true},
|
||||
&entity.ArticleAuthor{ArticleID: 3, AuthorID: 5},
|
||||
&entity.ArticleAuthor{ArticleID: 7, AuthorID: 5},
|
||||
&entity.ArticleAuthor{ArticleID: 6, AuthorID: 5},
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
|
||||
t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(newMockArticleRepo()), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
|
||||
|
||||
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"})
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
restartJobs := &fakeJobStore{}
|
||||
env.container = &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images),
|
||||
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -23,7 +23,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
getUserUseCase := useCases.NewGetUserUseCase(routerUsers)
|
||||
renderer := markdown.NewConverter()
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -22,7 +22,7 @@ func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) {
|
||||
t.Helper()
|
||||
rows := make([]entity.Article, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
rows = append(rows, entity.Article{Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
|
||||
rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed articles: %v", err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(e.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,11 @@ func openLegacyArticleDatabase(t *testing.T) *gorm.DB {
|
||||
|
||||
func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
|
||||
t.Helper()
|
||||
for index := range rows {
|
||||
if rows[index].StoryID == 0 {
|
||||
rows[index].StoryID = fixtureStory()
|
||||
}
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
@@ -79,7 +84,7 @@ func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
|
||||
func legacyArticles(count int) []entity.Article {
|
||||
rows := make([]entity.Article, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
rows = append(rows, entity.Article{Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)})
|
||||
rows = append(rows, entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -217,7 +222,7 @@ func TestBackfillArticleSlugs(t *testing.T) {
|
||||
|
||||
t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, []entity.Article{{Title: "Gone one", SiteID: 1}, {Title: "Gone two", SiteID: 1}, {Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}})
|
||||
seedLegacyArticles(t, db, []entity.Article{{StoryID: fixtureStory(), Title: "Gone one", SiteID: 1}, {StoryID: fixtureStory(), Title: "Gone two", SiteID: 1}, {StoryID: fixtureStory(), Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}})
|
||||
if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
|
||||
@@ -237,23 +237,23 @@ func (m *mockArticleRepo) GetAll() ([]article.Article, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
|
||||
func (m *mockArticleRepo) slugUsedOn(siteID uint32, lang string, slug string, exceptID uint32) bool {
|
||||
if slug == "" {
|
||||
return false
|
||||
}
|
||||
for id, other := range m.articles {
|
||||
if id != exceptID && other.SiteID == siteID && other.Slug == slug {
|
||||
if id != exceptID && other.SiteID == siteID && other.Lang == lang && other.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
func (m *mockArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
||||
if err := m.fail.trigger("SlugTaken"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return m.slugUsedOn(siteID, slug, exceptID), nil
|
||||
return m.slugUsedOn(siteID, lang, slug, exceptID), nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
|
||||
@@ -339,7 +339,7 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
}
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
|
||||
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, 0) {
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
m.nextID++
|
||||
@@ -395,7 +395,7 @@ func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Arti
|
||||
if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
if m.slugUsedOn(a.SiteID, a.Slug, id) {
|
||||
if m.slugUsedOn(a.SiteID, a.Lang, a.Slug, id) {
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
applySeoUpdate(&a, update)
|
||||
@@ -479,7 +479,7 @@ var _ article.Repository = &mockArticleRepo{}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
t.Run("Valid Title", func(t *testing.T) {
|
||||
cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"}
|
||||
|
||||
@@ -26,7 +26,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
|
||||
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
title := strings.Repeat("\U0001F600", tc.runeCount)
|
||||
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
|
||||
|
||||
@@ -20,7 +20,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -359,7 +359,7 @@ func newAuthorEnv(t *testing.T) *authorEnv {
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
|
||||
|
||||
@@ -24,7 +24,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestMCPUploadsMultipartFromBase64(t *testing.T) {
|
||||
t.Fatal(toolText(res))
|
||||
}
|
||||
call := f.last()
|
||||
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, `name="file"; filename="wall.png"`) || !strings.Contains(call.body, "PNGDATA") {
|
||||
if !strings.HasPrefix(call.ctype, "multipart/form-data") || !strings.Contains(call.body, "name=file") || !strings.Contains(call.body, "filename=wall.png") || !strings.Contains(call.body, "Content-Type: image/png") || !strings.Contains(call.body, "PNGDATA") {
|
||||
t.Errorf("sent %+v", call)
|
||||
}
|
||||
if res := f.callTool(t, "upload_media", map[string]any{}); !res.IsError {
|
||||
|
||||
@@ -67,7 +67,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images),
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
|
||||
t.Helper()
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
uploadDir := t.TempDir()
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestDriverRegistries(t *testing.T) {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("Migrate failed: %v", err)
|
||||
}
|
||||
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
|
||||
if err := connection.DB.Create(&models.Article{StoryID: fixtureStory(), Title: "t"}).Error; err != nil {
|
||||
t.Fatalf("expected the schema to exist: %v", err)
|
||||
}
|
||||
if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil {
|
||||
|
||||
@@ -35,7 +35,7 @@ func openInMemoryDb(t *testing.T) *gorm.DB {
|
||||
|
||||
func TestArticleRepositoryReportsMissingArticleAsDomainError(t *testing.T) {
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
@@ -126,7 +126,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
|
||||
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -42,7 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
|
||||
t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) {
|
||||
repo, sites, authors, _ := newRepos()
|
||||
create := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors)
|
||||
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, authors)
|
||||
created, err := create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(25, "content.create=all", "content.update:article=none")})
|
||||
if err != nil || created.CreatedBy != 25 || created.IsOnline {
|
||||
t.Fatalf("expected a draft created by 25, got %+v %v", created, err)
|
||||
@@ -493,7 +493,7 @@ func TestContentScopeCreateArticleAuthorLookupFailure(t *testing.T) {
|
||||
boom := errors.New("author store down")
|
||||
authors.fail.failNextWith("GetByUserID", boom)
|
||||
articles := newMockArticleRepo()
|
||||
create := useCases.NewCreateArticleUseCase(articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), authors)
|
||||
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), authors)
|
||||
caller := useCases.ContentCaller{UserID: 9, Grants: role.Role{Grants: grantRows("content.create=all")}.GrantSet()}
|
||||
_, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller})
|
||||
if !errors.Is(err, boom) {
|
||||
|
||||
@@ -270,7 +270,7 @@ func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
|
||||
|
||||
func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
|
||||
t.Helper()
|
||||
if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
|
||||
if err := db.Create(&models.Article{ID: id, StoryID: fixtureStory(), Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func TestBackfillCreatedBy(t *testing.T) {
|
||||
func TestNewArticlesRecordTheirCreator(t *testing.T) {
|
||||
t.Run("New articles record their creator", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -130,7 +130,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
|
||||
return &api.UseCases{
|
||||
Token: token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) {
|
||||
t.Run("Creating gives up after exactly five racing attempts", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles}
|
||||
create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
|
||||
create := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(racing), &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
|
||||
|
||||
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"})
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
|
||||
migrateSeo(t, db)
|
||||
|
||||
found, err := persistence.NewArticleRepository(db).Get(1)
|
||||
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt}
|
||||
want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt, StoryID: 1, ContentRevision: 1}
|
||||
if err != nil || !reflect.DeepEqual(found, want) {
|
||||
t.Errorf("found %+v, %v", found, err)
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestSeoDataMigrationBackfill(t *testing.T) {
|
||||
if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") {
|
||||
t.Fatal("the index is missing")
|
||||
}
|
||||
duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"}
|
||||
duplicate := entity.Article{StoryID: fixtureStory(), Title: "Again", SiteID: 1, Slug: "news"}
|
||||
if err := db.Create(&duplicate).Error; err == nil {
|
||||
t.Error("a duplicate (site_id, slug) was accepted")
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) {
|
||||
|
||||
_, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"})
|
||||
_, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"})
|
||||
rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error
|
||||
rawDuplicate := db.Create(&entity.Article{StoryID: fixtureStory(), Title: "Four", SiteID: siteID, Slug: "spring"}).Error
|
||||
|
||||
if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil {
|
||||
t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate)
|
||||
@@ -346,10 +346,10 @@ func TestSeoDataArticleRepositoryContract(t *testing.T) {
|
||||
{"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) {
|
||||
created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"})
|
||||
|
||||
own, ownErr := articles.SlugTaken(siteID, "spring", created.ID)
|
||||
other, otherErr := articles.SlugTaken(siteID, "spring", 0)
|
||||
own, ownErr := articles.SlugTaken(siteID, "", "spring", created.ID)
|
||||
other, otherErr := articles.SlugTaken(siteID, "", "spring", 0)
|
||||
deleteErr := articles.Delete(created.ID)
|
||||
afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0)
|
||||
afterDelete, afterErr := articles.SlugTaken(siteID, "", "spring", 0)
|
||||
_, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"})
|
||||
|
||||
if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, authors)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSeoFilterPerformance(t *testing.T) {
|
||||
body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8)
|
||||
rows := make([]entity.Article, 0, seoPerformanceArticles)
|
||||
for i := 1; i <= seoPerformanceArticles; i++ {
|
||||
row := entity.Article{Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
|
||||
row := entity.Article{StoryID: fixtureStory(), Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
|
||||
if i%2 == 0 {
|
||||
row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100)
|
||||
}
|
||||
|
||||
@@ -69,9 +69,9 @@ func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Coun
|
||||
return m.mockArticleRepo.CountByVisibility(onlineOnly)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
||||
m.reads["SlugTaken"]++
|
||||
return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID)
|
||||
return m.mockArticleRepo.SlugTaken(siteID, lang, slug, exceptID)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) {
|
||||
func TestSiteUseCaseRules(t *testing.T) {
|
||||
t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
|
||||
created, err := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
|
||||
if err != nil || created.SiteID != 1 {
|
||||
t.Errorf("created %+v, %v", created, err)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func TestSiteUseCaseRules(t *testing.T) {
|
||||
t.Run("Creating an article fails when no default site can be read", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := newTestMergeMedia(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(passthroughCreateTransactor(p.articles), renderer, recorder, sites, plainAuthors())
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
|
||||
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
|
||||
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
||||
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision"}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
@@ -238,7 +238,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role"} {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "migration_markers", "role", "idx_role", "stories", "site_languages", "idx_articles_story_lang"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
title := strings.Repeat("a", tc.length)
|
||||
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
|
||||
@@ -63,7 +63,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
||||
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
uc := useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
func newSeededArticleRepository(t *testing.T) *persistence.ArticleRepository {
|
||||
t.Helper()
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Story{}, &models.Author{}, &models.ArticleAuthor{}, &models.Image{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
@@ -213,7 +213,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
|
||||
Reference in new issue
Block a user