refac: article reads, story delete, no leaks, stored SEO status, rate limit, cleanup (%site%, article repo split, old lang repos, one default per site rule to domain)

This commit is contained in:
Florian Sylvain committed 2026-11-03 16:01:52 +01:00
1 parent 9d284e65a9
commit 7c15206170
85 files changed
+1738 -543

No files matched your search

+2 -2
View File
@@ -79,7 +79,7 @@ tasks:
return 1 return 1
fi fi
echo "== $IMAGE on 127.0.0.1:$PORT" echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run 'MySQL|TestLocalizationPersistence' -count=1 {{.CLI_ARGS}} RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run 'MySQL|TestLocalizationPersistence' -count=1 -timeout 20m {{.CLI_ARGS}}
RESULT=$? RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1 docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT return $RESULT
@@ -119,7 +119,7 @@ tasks:
return 1 return 1
fi fi
echo "== $IMAGE on 127.0.0.1:$PORT" echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run 'Postgres|TestLocalizationPersistence' -count=1 {{.CLI_ARGS}} RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run 'Postgres|TestLocalizationPersistence' -count=1 -timeout 20m {{.CLI_ARGS}}
RESULT=$? RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1 docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT return $RESULT
+10 -6
View File
@@ -10,7 +10,6 @@ import (
domainEntry "RenewCMS/internal/domain/entry" domainEntry "RenewCMS/internal/domain/entry"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainLanguage "RenewCMS/internal/domain/language"
domainLocalization "RenewCMS/internal/domain/localization" domainLocalization "RenewCMS/internal/domain/localization"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
@@ -47,6 +46,8 @@ import (
type UseCases struct { type UseCases struct {
apiKeyAuthOnce sync.Once apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler apiKeyAuthMiddleware func(http.Handler) http.Handler
publicReadLimitOnce sync.Once
publicReadLimitMiddleware func(http.Handler) http.Handler
Token *authtoken.Auth Token *authtoken.Auth
PurgeScheduler *scheduler.Runner PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner ActivityCleanupScheduler *scheduler.Runner
@@ -63,6 +64,7 @@ type UseCases struct {
CreateTranslationUseCase *useCases.CreateTranslationUseCase CreateTranslationUseCase *useCases.CreateTranslationUseCase
DescribeArticleUseCase *useCases.DescribeArticleUseCase DescribeArticleUseCase *useCases.DescribeArticleUseCase
MoveStoryUseCase *useCases.MoveStoryUseCase MoveStoryUseCase *useCases.MoveStoryUseCase
DeleteStoryUseCase *useCases.DeleteStoryUseCase
ListAuthorsUseCase *useCases.ListAuthorsUseCase ListAuthorsUseCase *useCases.ListAuthorsUseCase
CreateAuthorUseCase *useCases.CreateAuthorUseCase CreateAuthorUseCase *useCases.CreateAuthorUseCase
CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase CreateMyAuthorUseCase *useCases.CreateMyAuthorUseCase
@@ -184,9 +186,8 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
} }
type Repositories struct { type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.ListingRepository
SiteRepo domainSite.Repository SiteRepo domainSite.Repository
LanguageRepo domainLanguage.Repository
LocalizationRepo domainLocalization.Repository LocalizationRepo domainLocalization.Repository
ContentTypeRepo domainContentType.Repository ContentTypeRepo domainContentType.Repository
EntryRepo domainEntry.Repository EntryRepo domainEntry.Repository
@@ -336,7 +337,6 @@ func getRepositories(connection *database.Connection, settings config.Settings)
return &Repositories{ return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
SiteRepo: persistence.NewSiteRepository(db), SiteRepo: persistence.NewSiteRepository(db),
LanguageRepo: persistence.NewSiteLanguageRepository(db),
LocalizationRepo: persistence.NewLocalizationRepository(db), LocalizationRepo: persistence.NewLocalizationRepository(db),
ContentTypeRepo: persistence.NewContentTypeRepository(db), ContentTypeRepo: persistence.NewContentTypeRepository(db),
EntryRepo: persistence.NewEntryRepository(db), EntryRepo: persistence.NewEntryRepository(db),
@@ -404,8 +404,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder), CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo), DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(repos.ArticleRepo, repos.LocalizationRepo),
MoveStoryUseCase: moveStory, MoveStoryUseCase: moveStory,
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(repos.Transactor, repos.ArticleRepo, recorder),
ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo), ResolveArticleSEOUseCase: useCases.NewResolveArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo), CheckArticleSEOUseCase: useCases.NewCheckArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.ImageRepo, repos.LocalizationRepo),
ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo), ListSitemapUseCase: useCases.NewListSitemapUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo),
ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer), ResolvePublicArticleUseCase: useCases.NewResolvePublicArticleUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo, renderer),
ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo), ListAuthorsUseCase: useCases.NewListAuthorsUseCase(repos.AuthorRepo, repos.UserRepo),
@@ -419,7 +420,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(), ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo), ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder), UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder, repos.ArticleRepo),
GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo), GetSiteLanguagesUseCase: useCases.NewGetSiteLanguagesUseCase(repos.SiteRepo, repos.LocalizationRepo),
UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder), UpdateSiteLanguagesUseCase: useCases.NewUpdateSiteLanguagesUseCase(repos.Transactor, recorder),
GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo), GetLanguageSetupUseCase: useCases.NewGetLanguageSetupUseCase(repos.SiteRepo, repos.LocalizationRepo),
@@ -552,6 +553,9 @@ func InitContainer(settings config.Settings) (*UseCases, func() error) {
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil { if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error()) panic("Failed to backfill image metadata: " + err.Error())
} }
if err := useCases.NewRefreshArticleSEOUseCase(repos.ArticleRepo, repos.SiteRepo, repos.LocalizationRepo).RefreshStale(); err != nil {
panic("Failed to backfill the article SEO status: " + err.Error())
}
container := getUseCases(repos, imageStorage, settings) container := getUseCases(repos, imageStorage, settings)
return container, container.runUsageTracker(connection.Close) return container, container.runUsageTracker(connection.Close)
} }
+25 -2
View File
@@ -97,6 +97,26 @@ type articleResponse struct {
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
Translations []domain.Translation `json:"translations"` Translations []domain.Translation `json:"translations"`
Source *domain.Source `json:"source"` Source *domain.Source `json:"source"`
*editorialContext
}
type editorialContext struct {
SourceUnavailable bool `json:"source_unavailable"`
MissingLangs []string `json:"missing_langs"`
ExcludedFromPublic bool `json:"excluded_from_public"`
PartialView bool `json:"partial_view"`
}
func editorialContextFor(viewer access.Caller, described useCases.ArticleContext) *editorialContext {
if viewer.UserID == 0 {
return nil
}
return &editorialContext{
SourceUnavailable: described.SourceUnavailable,
MissingLangs: described.MissingLangs,
ExcludedFromPublic: described.ExcludedFromPublic,
PartialView: described.PartialView,
}
} }
type listItemResponse struct { type listItemResponse struct {
@@ -171,6 +191,7 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat, viewer
Capabilities: described.Capabilities, Capabilities: described.Capabilities,
Translations: described.Translations, Translations: described.Translations,
Source: described.Source, Source: described.Source,
editorialContext: editorialContextFor(viewer, described),
}, nil }, nil
} }
@@ -588,6 +609,7 @@ type storyItemResponse struct {
StoryAuthors []author.Byline `json:"story_authors"` StoryAuthors []author.Byline `json:"story_authors"`
Coverage []domain.Translation `json:"coverage"` Coverage []domain.Translation `json:"coverage"`
MissingLangs []string `json:"missing_langs"` MissingLangs []string `json:"missing_langs"`
ExcludedFromPublic bool `json:"excluded_from_public"`
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
} }
@@ -704,7 +726,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{ page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Format: format, Format: format,
Caller: access.CallerOf(r), Caller: access.CallerOf(r),
Lang: query.Lang, Lang: stringOrEmpty(query.Lang),
Author: query.Author, Author: query.Author,
Page: query.Page, Page: query.Page,
Size: query.Size, Size: query.Size,
@@ -776,6 +798,7 @@ func (h *Handler) listStories(w http.ResponseWriter, r *http.Request, format dom
StoryAuthors: row.Authors, StoryAuthors: row.Authors,
Coverage: row.Coverage, Coverage: row.Coverage,
MissingLangs: row.MissingLangs, MissingLangs: row.MissingLangs,
ExcludedFromPublic: row.ExcludedFromPublic,
Capabilities: row.Capabilities, Capabilities: row.Capabilities,
}) })
} }
@@ -798,7 +821,7 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil { if err := h.deleteUseCase.DeleteArticle(r.Context(), id, access.CallerOf(r)); err != nil {
if !writeKnownArticleError(w, err) { if !writeKnownArticleError(w, err) {
http.Error(w, "Failed to delete article", http.StatusInternalServerError) http.Error(w, apperr.DeleteArticleFailedErrorMessage, http.StatusInternalServerError)
} }
return return
} }
+26 -7
View File
@@ -15,10 +15,16 @@ import (
type Handler struct { type Handler struct {
MoveUseCase *useCases.MoveStoryUseCase MoveUseCase *useCases.MoveStoryUseCase
DeleteUseCase *useCases.DeleteStoryUseCase
} }
func NewHandler(move *useCases.MoveStoryUseCase) *Handler { func NewHandler(move *useCases.MoveStoryUseCase, delete *useCases.DeleteStoryUseCase) *Handler {
return &Handler{MoveUseCase: move} return &Handler{MoveUseCase: move, DeleteUseCase: delete}
}
func parseStoryID(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32)
return uint32(id), err
} }
type moveRequest struct { type moveRequest struct {
@@ -33,7 +39,7 @@ type moveResponse struct {
} }
func (h *Handler) Move(w http.ResponseWriter, r *http.Request) { func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(chi.URLParam(r, "story_id"), 10, 32) id, err := parseStoryID(r)
if err != nil { if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return return
@@ -43,9 +49,9 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return return
} }
result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: uint32(id), SiteKey: *request.Site, Caller: access.CallerOf(r)}) result, err := h.MoveUseCase.MoveStory(r.Context(), useCases.MoveStoryCommand{StoryID: id, SiteKey: *request.Site, Caller: access.CallerOf(r)})
if err != nil { if err != nil {
writeError(w, err) writeError(w, err, apperr.MoveStoryFailedErrorMessage)
return return
} }
body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs}) body, err := json.Marshal(moveResponse{StoryID: result.StoryID, Site: result.Site.Key, SiteID: result.Site.ID, ArticleIDs: result.ArticleIDs})
@@ -58,12 +64,25 @@ func (h *Handler) Move(w http.ResponseWriter, r *http.Request) {
} }
} }
func writeError(w http.ResponseWriter, err error) { func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseStoryID(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if err := h.DeleteUseCase.DeleteStory(r.Context(), useCases.DeleteStoryCommand{StoryID: id, Caller: access.CallerOf(r)}); err != nil {
writeError(w, err, apperr.DeleteArticleFailedErrorMessage)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeError(w http.ResponseWriter, err error, failure string) {
if access.WriteIfMissingPermission(w, err) { if access.WriteIfMissingPermission(w, err) {
return return
} }
if articleerr.Write(w, err) { if articleerr.Write(w, err) {
return return
} }
http.Error(w, "Failed to move story", http.StatusInternalServerError) http.Error(w, failure, http.StatusInternalServerError)
} }
+2
View File
@@ -172,6 +172,8 @@ const ConflictingArticlesHeader = "X-Conflicting-Articles"
const StoryMoveRequiredErrorMessage = "story_move_required" const StoryMoveRequiredErrorMessage = "story_move_required"
const StoryBusyErrorMessage = "story_busy" const StoryBusyErrorMessage = "story_busy"
const TargetSiteDeletedErrorMessage = "target_site_deleted" const TargetSiteDeletedErrorMessage = "target_site_deleted"
const MoveStoryFailedErrorMessage = "Failed to move story"
const DeleteArticleFailedErrorMessage = "Failed to delete article"
const ArticleNotFoundCode = "article_not_found" const ArticleNotFoundCode = "article_not_found"
const LanguageNotAvailableCode = "language_not_available" const LanguageNotAvailableCode = "language_not_available"
+20 -4
View File
@@ -55,6 +55,9 @@ const (
apiKeyMaxRequests = 120 apiKeyMaxRequests = 120
apiKeyWindow = time.Minute apiKeyWindow = time.Minute
publicReadMaxRequests = apiKeyMaxRequests
publicReadWindow = apiKeyWindow
emailChangeMaxRequests = 5 emailChangeMaxRequests = 5
emailChangeWindow = 15 * time.Minute emailChangeWindow = 15 * time.Minute
@@ -71,6 +74,17 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware return c.apiKeyAuthMiddleware
} }
func (c *UseCases) publicReadLimit(trustedProxies []string) func(http.Handler) http.Handler {
c.publicReadLimitOnce.Do(func() {
realIP := middleware.TrustedProxyRealIP(trustedProxies)
limiter := middleware.NewRateLimiter(publicReadMaxRequests, publicReadWindow)
c.publicReadLimitMiddleware = func(next http.Handler) http.Handler {
return realIP(limiter(next))
}
})
return c.publicReadLimitMiddleware
}
func (c *UseCases) require(key string) func(http.Handler) http.Handler { func (c *UseCases) require(key string) func(http.Handler) http.Handler {
return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key) return middleware.RequirePermission(c.GetUserUseCase, c.CheckPermissionUseCase, key)
} }
@@ -121,8 +135,9 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
return r return r
} }
func NewPublicRouter(container *UseCases) http.Handler { func NewPublicRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(container.publicReadLimit(trustedProxies))
publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase) publicHandler := article.NewPublicHandler(container.ResolvePublicArticleUseCase)
@@ -136,13 +151,14 @@ func NewStoryRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
storyHandler := story.NewHandler(container.MoveStoryUseCase) storyHandler := story.NewHandler(container.MoveStoryUseCase, container.DeleteStoryUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Authenticator) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.With(container.requireArticle(role.KeyContentUpdate)).Post("/{story_id}/move", storyHandler.Move) r.With(container.requireArticle(role.KeyContentUpdate)).Post("/{story_id}/move", storyHandler.Move)
r.With(container.requireArticle(role.KeyContentDelete)).Delete("/{story_id}", storyHandler.Delete)
}) })
return r return r
} }
@@ -328,7 +344,7 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase) ownershipHandler := ownership.NewHandler(container.TransferOwnershipUseCase)
r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.With(container.publicReadLimit(trustedProxies)).Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
@@ -637,7 +653,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies)) r.Mount("/story", NewStoryRouter(container, settings.TrustedProxies))
}) })
r.Mount("/public", NewPublicRouter(container)) r.Mount("/public", NewPublicRouter(container, settings.TrustedProxies))
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
+1 -1
View File
@@ -178,7 +178,7 @@ RenewCMS renders no page and injects no meta tag: it returns the data a frontend
| `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. | | `POST /v1/article/{id}/seo/check` | verified session or API key (401 without credentials, 403 unverified) | Evaluates the article without saving. The body is `{}` or any of the twelve SEO fields of `PATCH` plus `body`; each replaces the saved value for this check only. `site_id`, `author_ids`, unknown fields and `null` (except on `cover_image_id` and `social_image_id`) are 400 with the body-mismatch message. The other validation is the one of `PATCH` (`invalid_slug`, `invalid_url` as 422, `image_not_found`, validator text). Answers `{ status, suggestions, slug }` where `suggestions` is `[{ code, field, params }]` and `slug` is `{ available, suggestion }`. A taken slug is a 200 with `available: false` and the first free `<slug>-N` in `suggestion`, never a 409. |
| `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex, lang }]` of the eligible versions (published, language enabled) of the site with that key, by id ascending. `url` is the localized address when the site has a Public URL pattern, otherwise `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. | | `GET /v1/site/{key}/sitemap` | public | A JSON array `[{ url, modified_at, noindex, lang }]` of the eligible versions (published, language enabled) of the site with that key, by id ascending. `url` is the localized address when the site has a Public URL pattern, otherwise `base_url/slug` (never the canonical override). `page` (default 1) and `size` (25, 50, 100 or 500, default 100); an invalid value is 400 with the body-mismatch message. The total is in the `X-Total-Count` header. An unknown key, an id included, is 404 `site_not_found`. |
`GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter is evaluated in memory over every matching article, so its cost grows with the number of articles on the site; without it only the returned page is assessed. `GET /v1/article/` also takes `seo=ready|suggestions|hidden` (any other value, an empty one included, is 400 with the body-mismatch message, after the `format` check). It is applied before paging and composes with `q`, `status`, `site`, `sort`, `page` and `size`; `total` follows it and `counts` ignore it. Every item carries `seo_status` (`ready`, `suggestions` or `hidden`) and `seo_suggestions` (the number of suggestions that are not dismissed). The filter reads the status stored on each article (see [SEO status and suggestions](api/articles.md#seo-status-and-suggestions)), in SQL, before paging.
`X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it. `X-Total-Count` is added to `Access-Control-Expose-Headers` next to `Link`, so a browser on an allowed origin can read it.
+65 -12
View File
@@ -32,7 +32,7 @@ The headers are set on those responses only. Same-origin SPA code reads them dir
| 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes | | 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes |
| 400 | `invalid_slug` | a `PATCH` slug that is not a slug | | 400 | `invalid_slug` | a `PATCH` slug that is not a slug |
| 401 | `You must be logged in to access this resource.` | no valid session or key | | 401 | `You must be logged in to access this resource.` | no valid session or key |
| 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`) | | 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`); on `DELETE /v1/story/{story_id}`, a `content.delete` that is not at scope `all` |
| 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it | | 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it |
| 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both | | 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both |
| 404 | `site_not_found` | `POST /v1/story/{story_id}/move`, or `POST /v1/article/` with `site`, naming an unknown site key | | 404 | `site_not_found` | `POST /v1/story/{story_id}/move`, or `POST /v1/article/` with `site`, naming an unknown site key |
@@ -43,7 +43,7 @@ The headers are set on those responses only. Same-origin SPA code reads them dir
| 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) | | 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) |
| 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) | | 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) |
| 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions | | 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions |
| 409 | `target_site_deleted` | `POST /v1/article/`: the target site was deleted after it was looked up and before the article was stored; choose another site | | 409 | `target_site_deleted` | `POST /v1/article/`: the target site was deleted after it was looked up and before the article was stored; nothing is stored, choose another site and retry. The SPA has no copy for it yet |
| 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry | | 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
| 422 | `invalid_language` | the tag is not a language tag | | 422 | `invalid_language` | the tag is not a language tag |
| 422 | `unsupported_language` | a valid tag outside the supported list | | 422 | `unsupported_language` | a valid tag outside the supported list |
@@ -63,11 +63,15 @@ Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish
"revision": 3, "revision": 3,
"capabilities": ["read", "edit", "publish", "delete", "create_translation"], "capabilities": ["read", "edit", "publish", "delete", "create_translation"],
"translations": [ "translations": [
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}, {"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "excluded_from_public": false, "source_article_id": null, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]}, {"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "excluded_from_public": false, "source_article_id": 10, "capabilities": ["read", "create_translation"]},
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]} {"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "excluded_from_public": true, "source_article_id": 10, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
], ],
"source": {"article_id": 10, "revision": 4} "source": {"article_id": 10, "revision": 4},
"source_unavailable": false,
"missing_langs": ["de"],
"excluded_from_public": false,
"partial_view": false
} }
``` ```
@@ -75,6 +79,28 @@ Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish
- `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`. - `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`.
- `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`. - `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`.
- `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy. - `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy.
- `translations[].excluded_from_public` and `excluded_from_public` (the version being read) are `true` when the language keeps the version off the public language surfaces even once published: the language is disabled on the site, or the version has no language yet (`lang` `""`, the site default is not confirmed). The SPA reads this flag instead of re-deriving it from `language_enabled` and the site languages. A `false` flag says nothing about publication: drafts are off the public surfaces anyway.
- `translations[].source_article_id` is the id of the version this entry tracks when that source is itself listed in `translations` (so readable), otherwise `null`.
- The four keys below are sent to signed-in callers only; an anonymous read has none of them (its shape is unchanged).
- `source_unavailable`: see [Versions the caller cannot read](#versions-the-caller-cannot-read).
- `missing_langs`: the enabled languages of the site without a readable version in the story, default first then by tag, the same rule as the editorial list (`[]` when the caller cannot create, and `[]` for a story whose only versions have no language).
- `excluded_from_public`: the flag above, for the version being read.
- `partial_view`: see [Versions the caller cannot read](#versions-the-caller-cannot-read).
## Versions the caller cannot read
No answer reveals the existence, id, title or language of a version the caller cannot read. Where the editor needs to explain an absence, the API gives one neutral answer that is the same whether the version was deleted or is hidden from the caller, or a flag that depends on the caller's own permissions only.
| Need | Answer | Same for |
| --- | --- | --- |
| The tracked source of a version is gone | `"source": null` and `"source_unavailable": true`. The editor shows "The source isn't available to you" and asks to choose a new source | a deleted source and a source the caller cannot read |
| Creating from a source that is gone | `404 source_unavailable` | a deleted, unknown or unreadable source |
| Deleting a version that others track ("Review tracking will pause") | the dialog names the entries of `translations` whose `source_article_id` is the id being deleted | trackers the caller cannot read are never listed or counted, because `translations` lists readable versions only and their `source_article_id` is never sent |
| Telling the caller that some versions may be out of view ("Versions you can't read aren't listed or counted.") | `partial_view` | `true` whenever the caller's `content.read` is not at scope `all`, whether or not the story holds a version the caller cannot read |
| Deleting the whole article while some versions are out of view | `DELETE /v1/story/{story_id}` needs `content.delete` at scope `all` (`403 missing_permission` otherwise, decided before anything is read), deletes every version, readable or not, and answers `204` | a story with or without hidden versions |
| Moving or editing a story with hidden versions | `article_ids` and the `X-Missing-Languages` and `X-Conflicting-Articles` headers name readable versions only (see [Moving a story](#moving-a-story-post-v1storystory_idmove)) | |
`partial_view` never depends on the stored data, so the note "Versions you can't read aren't listed or counted." shows for such a caller on every story, also when nothing is hidden. Showing it only when a hidden version exists would reveal that version.
## What raises revision ## What raises revision
@@ -129,7 +155,9 @@ The new version joins the story of `{id}`, in the same site, as an offline draft
Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized. Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized.
### `POST /v1/article/` with `story_id` ### `POST /v1/article/` with `story_id` (deprecated)
Deprecated: use `POST /v1/article/{id}/translations`. Both entry points run the same use case and keep working; new clients should not send `story_id`.
```json ```json
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10} {"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
@@ -145,7 +173,7 @@ It creates a new story with one offline version, on the default site unless the
{"title": "Neuf", "lang": "fr", "site": "atelier"} {"title": "Neuf", "lang": "fr", "site": "atelier"}
``` ```
- `site` is optional: the site key. Absent or `null`, the default site is used exactly as before. A present `site` is validated like any site key (`400` with the plain-text body `invalid_site_key` for `""` or `Bad Key!`), then looked up (`404 site_not_found` for an unknown key, a plain text body, nothing stored). A site deleted after that lookup and before the article is stored answers `409 target_site_deleted` (plain text, nothing stored). Every caller who may create articles may create on every site: `content.create` is not scoped to a site. - `site` is optional: the site key. Absent or `null`, the default site is used exactly as before. A present `site` is validated like any site key (`400` with the plain-text body `invalid_site_key` for `""` or `Bad Key!`), then looked up (`404 site_not_found` for an unknown key, a plain text body, nothing stored). A site deleted after that lookup and before the article is stored answers `409 target_site_deleted` (plain text, nothing stored). Every caller who may create articles may create on every site: `content.create` is not scoped to a site, because a role grants each permission at a scope of `none`, `own` or `all` and has no per-site scope (see [Per-site permissions](#per-site-permissions)).
- `lang` is optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled on that site; `409 setup_required` before that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another. - `lang` is optional. Absent, the version gets the starting language of the chosen site; present, it is normalized and must be an enabled language of the chosen site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled on that site; `409 setup_required` before that site's default language is confirmed). The same tag can therefore be accepted on one site and refused on another.
- The slug is generated from the title and made free within the chosen site and language only. - The slug is generated from the title and made free within the chosen site and language only.
- `site` with `story_id`, `source_article_id` or `copy` answers `400` with the body mismatch message and stores nothing. `POST /v1/article/{id}/translations` has no `site` field: the translation keeps the site of its source and an unknown `site` key in its body is ignored. - `site` with `story_id`, `source_article_id` or `copy` answers `400` with the body mismatch message and stores nothing. `POST /v1/article/{id}/translations` has no `site` field: the translation keeps the site of its source and an unknown `site` key in its body is ignored.
@@ -156,6 +184,20 @@ It creates a new story with one offline version, on the default site unless the
`DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site. `DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
### Deleting the whole article: `DELETE /v1/story/{story_id}`
Verified session or API key with `content.delete` at scope `all` ("delete permission on every version"). Order of checks: path id (`400` with the uint32 message), `content.delete` held (`403 missing_permission`, `X-Missing-Permission: content.delete`), `content.delete` at scope `all` (`403 missing_permission`, same header; decided from the caller's role only, before any read), at least one readable version (`404 Article not found`, the same answer for an unknown story). Then every version of the story, readable or not, is deleted in one transaction under the locks of its sites, and the story row is removed: `204`, empty body.
- Atomic: a failure answers `500 Failed to delete article` and removes nothing; every version stays.
- Each version is deleted like `DELETE /v1/article/{id}` does it (slug tombstone, soft delete, credits removed), so its slug is free again and its language still counts as in use for removing the language from the site.
- Versions that tracked a deleted version read `source_unavailable: true` afterwards.
- Activity: one `article.delete` per deleted version, oldest first, with `params.lang` and `params.story_id`. A failure records one failed `article.delete` on the oldest readable version; `403` and `404` record nothing.
- A caller whose `content.delete` is at scope `own` deletes versions one by one; deleting the last one removes the story.
### Per-site permissions
Roles grant each permission at scope `none`, `own` or `all` and have no site dimension, so `content.create`, `content.delete` and the other content permissions apply to every site. A per-site permission (for example "may create on Journal only") is not supported; it needs a site scope in the role model first.
## Moving a story: `POST /v1/story/{story_id}/move` ## Moving a story: `POST /v1/story/{story_id}/move`
```json ```json
@@ -186,7 +228,7 @@ An opt-in shape of the list for a signed-in, verified caller whose role resolves
Only versions the caller can read count: they are titled, credited, listed in `coverage`, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row. Only versions the caller can read count: they are titled, credited, listed in `coverage`, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.
**Rows.** With no `lang` a row is a story: `total` counts stories. With `lang` a row is the version of that language (`tab=all`), and the whole story's `coverage`, `story_authors` and `missing_langs` still ride on the row. With `tab=missing` rows are stories again: `lang=fr` lists the stories on a site that enables `fr` and that have a readable version but none in `fr`; without `lang` it lists the stories that lack at least one enabled language, and a caller who cannot create (no `content.create`) gets `total` 0. Articles without language (`lang` `""`) are one story each and are never listed as missing a language. A version of the story must satisfy `status`, `author`, `q` and `seo` together to make the story match; `q` ignores case and accents. **Rows.** With no `lang` a row is a story: `total` counts stories. With `lang` a row is the version of that language (`tab=all`), and the whole story's `coverage`, `story_authors` and `missing_langs` still ride on the row. With `tab=missing` rows are stories again: `lang=fr` lists the stories on a site that enables `fr` and that have a readable version with a language but none in `fr`; without `lang` it lists the stories that lack at least one enabled language, and a caller who cannot create (no `content.create`) gets `total` 0. A story whose readable versions all lack a language (`lang` `""`) is never listed as missing a language. A mixed story (a version without language plus at least one version with a language) is listed under Missing like any other: the version without language fills no language. A version of the story must satisfy `status`, `author`, `q` and `seo` together to make the story match; `q` ignores case and accents.
**Item.** The article list item of the representative version (the one in `lang`, otherwise the site default language version, otherwise the first readable one by language tag), plus: **Item.** The article list item of the representative version (the one in `lang`, otherwise the site default language version, otherwise the first readable one by language tag), plus:
@@ -194,14 +236,17 @@ Only versions the caller can read count: they are titled, credited, listed in `c
| --- | --- | --- | | --- | --- | --- |
| `story_updated_at` | RFC 3339 | The latest `updated_at` among the readable versions of the story | | `story_updated_at` | RFC 3339 | The latest `updated_at` among the readable versions of the story |
| `story_authors` | array of bylines | The union of the bylines of the readable versions, without duplicates | | `story_authors` | array of bylines | The union of the bylines of the readable versions, without duplicates |
| `coverage` | array | One entry per readable version, in the order of `translations` (default language, other enabled tags, disabled tags): `{ id, lang, title, is_online, language_enabled, capabilities }` | | `coverage` | array | One entry per readable version, in the order of `translations` (default language, other enabled tags, disabled tags), with the same keys as a `translations` entry: `{ id, lang, title, is_online, language_enabled, excluded_from_public, source_article_id, capabilities }` |
| `missing_langs` | array of tags | Enabled languages without a readable version, default first then by tag; `[]` when the caller cannot create | | `missing_langs` | array of tags | Enabled languages without a readable version, default first then by tag; `[]` when the caller cannot create or when no readable version has a language |
| `excluded_from_public` | boolean | The flag of the representative version (see [Fields added to an article read](#fields-added-to-an-article-read)) |
| `capabilities` | array | The capabilities of the representative version | | `capabilities` | array | The capabilities of the representative version |
**Sort.** The whitelist is the flat one. `updated_at` uses the latest readable version of the story, `created_at` the earliest, `title` and `status` the representative; with `lang` the version itself. Ties end with `story_id` descending. **Sort.** The whitelist is the flat one. `updated_at` uses the latest readable version of the story, `created_at` the earliest, `title` and `status` the representative; with `lang` the version itself. Ties end with `story_id` descending.
**Counts.** `{ all, online, draft, suggestions, missing }`. They follow `lang`, `q`, `author`, `seo` and `site` but never `status` or `tab`, and count the unit of the mode (stories, or versions of the selected language). `online` and `draft` count the units with a readable online or draft version. `missing` is the `total` the not-in view would give under the same filters (0 without `lang` for a caller who cannot create). `suggestions` is the number of stories with a readable version whose `seo_status` is `suggestions`, ignoring every parameter. **Counts.** `{ all, online, draft, suggestions, missing }`. They follow `lang`, `q`, `author`, `seo` and `site` but never `status` or `tab`, and count the unit of the mode (stories, or versions of the selected language). `online` and `draft` count the units with a readable online or draft version. `missing` is the `total` the not-in view would give under the same filters (0 without `lang` for a caller who cannot create). `suggestions` is the number of stories with a readable version whose `seo_status` is `suggestions`, ignoring every parameter.
`seo_status`, `seo_suggestions`, the `seo` filter and `counts.suggestions` read the stored SEO status of each version (see [SEO status and suggestions](articles.md#seo-status-and-suggestions)); no request reads the bodies of the visible versions to compute them.
A repository failure at any step, counts included, answers 500 `Failed to retrieve articles`. A repository failure at any step, counts included, answers 500 `Failed to retrieve articles`.
## Mock-ready examples ## Mock-ready examples
@@ -213,6 +258,9 @@ Statuses of the new and changed calls. Bodies are plain text unless shown as JSO
| `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` | | `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` | | `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` |
| `DELETE /v1/article/12` | 204 | | empty | | `DELETE /v1/article/12` | 204 | | empty |
| `DELETE /v1/story/5` by the owner | 204 | | empty |
| `DELETE /v1/story/5` by a user holding `content.delete` at `own` | 403 | `X-Missing-Permission: content.delete` | `missing_permission` |
| `DELETE /v1/story/5` when the database fails | 500 | | `Failed to delete article` |
| `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` | | `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` | | `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` |
| `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` | | `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` |
@@ -243,6 +291,8 @@ No new action code. The existing events carry `params.lang` (the language of the
Contract for `features/localization_public_api_and_surfaces.feature`. Both routes are open to everyone: a `jwt` cookie or an API key is ignored, so a draft is never returned, whoever asks. They sit under `/v1/public` and are read only. Contract for `features/localization_public_api_and_surfaces.feature`. Both routes are open to everyone: a `jwt` cookie or an API key is ignored, so a draft is never returned, whoever asks. They sit under `/v1/public` and are read only.
They are rate limited per client address together with `GET /v1/site/{key}/sitemap`: 120 requests per minute in total, the same budget an API key gets on the authenticated routes. The client address is the connection address, or `X-Real-IP` when the connection comes from one of `TRUSTED_PROXIES`. Over the budget the answer is `429` with `Retry-After` (seconds) and the body `Too many requests. Please try again later.`
A version is **published** when `is_online` is true, and **eligible** when it is published and its language is enabled on the site. A disabled language stays readable on both routes; only the alternates leave it out. A version is **published** when `is_online` is true, and **eligible** when it is published and its language is enabled on the site. A disabled language stays readable on both routes; only the alternates leave it out.
### Exact lookup: `GET /v1/public/site/{key}/lang/{lang}/article/{slug}` ### Exact lookup: `GET /v1/public/site/{key}/lang/{lang}/article/{slug}`
@@ -308,6 +358,7 @@ Plain text, the body is the code followed by a line feed. `language_disabled` is
| 404 | `article_not_found` | exact lookup: no published version for the site, language and slug | | 404 | `article_not_found` | exact lookup: no published version for the site, language and slug |
| 404 | `language_not_available` | story resolution: no version can be served under the rules above | | 404 | `language_not_available` | story resolution: no version can be served under the rules above |
| 404 | `site_not_found` | unknown site key | | 404 | `site_not_found` | unknown site key |
| 429 | `Too many requests. Please try again later.` | more than 120 requests in a minute from one client address on the public routes and the sitemap together (`Retry-After` in seconds) |
| 500 | `Internal server error` | a repository failure | | 500 | `Internal server error` | a repository failure |
## Resolved SEO and sitemap, language aware ## Resolved SEO and sitemap, language aware
@@ -327,7 +378,9 @@ Plain text, the body is the code followed by a line feed. `language_disabled` is
**Fallbacks in the language of the version.** The title and description chains are unchanged except for the site text: `default_description` is treated as written in the current default language of the site, so it is used only by an unassigned article or a version in that language. For any other language the description stays empty, `fallbacks.description` is `none` and `fallbacks.site_text_skipped` is `true`. Changing the default language changes which versions may use the site text. `title_template`, `site_name` and the image chain stay shared; `cover_image_alt` stays per version. **Fallbacks in the language of the version.** The title and description chains are unchanged except for the site text: `default_description` is treated as written in the current default language of the site, so it is used only by an unassigned article or a version in that language. For any other language the description stays empty, `fallbacks.description` is `none` and `fallbacks.site_text_skipped` is `true`. Changing the default language changes which versions may use the site text. `title_template`, `site_name` and the image chain stay shared; `cover_image_alt` stays per version.
**Sitemap.** `GET /v1/site/{key}/sitemap` lists every eligible version once (published, language enabled; unassigned published articles keep their legacy entry). Each item is `{url, modified_at, noindex, lang}`; `url` is the localized address when the site has a pattern, otherwise `base_url/slug`. `X-Total-Count` counts the listed items. **Sitemap.** `GET /v1/site/{key}/sitemap` lists every eligible version once (published, language enabled; unassigned published articles keep their legacy entry). Each item is `{url, modified_at, noindex, lang}`; `url` is the localized address when the site has a pattern, otherwise `base_url/slug`. `X-Total-Count` counts the listed items. The route shares the rate limit of the public language routes.
**SEO check and status.** `POST /v1/article/{id}/seo/check`, the stored `seo_status` of the lists and the `seo` filter apply the same language rule as the resolved description: the site `default_description` can fill the description (and so avoid `desc_short` or raise `desc_long`) only for a version in the current default language of the site or without language.
## Media usage ## Media usage
+5 -1
View File
@@ -44,7 +44,7 @@ Anonymous and unverified callers only see online articles, and an offline articl
- `items` is `[]` when empty, never `null`. Each item is the article object below. - `items` is `[]` when empty, never `null`. Each item is the article object below.
- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap. - Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap.
- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied. - `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied.
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`. - `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size` (and `suggestions` also ignores `lang` and `author`), so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not. - Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
- A page beyond the last answers `200` with `items: []` and the real `total`. - A page beyond the last answers `200` with `items: []` and the real `total`.
- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first. - An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first.
@@ -167,6 +167,10 @@ The fallback chain, first non-empty wins:
Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article). Suggestions come in that order. Codes in `seo_dismissed` are never reported or counted. The status is `hidden` when `noindex` is true, else `suggestions` when at least one suggestion remains, else `ready`. The list items carry `seo_status` and `seo_suggestions` (the count, also for a hidden article).
The resolved description uses the site `default_description` only for a version in the current default language of its site or without language, so `desc_short` and `desc_long` follow the language of the version, in the check and in the lists alike.
**Stored status.** Each version stores its `seo_status` and `seo_suggestions`. Every write that can change them clears the stored value in the same statement: an edit of the version (title, body, slug, SEO fields, authors, site), a cover or social image deleted, restored or merged, a credited author deleted, a move to another site (story move or site deletion with reassignment), a language assigned by the setup, a change of the site `default_description`, and a change of the site default language. The lists recompute the cleared versions (and only those) before answering, and the server recomputes them at startup, so `seo_status`, `?seo=` and `counts.suggestions` always reflect the current data without reading every visible body per request. A value computed from data that changed in the meantime is never stored.
## Markdown output ## Markdown output
Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline. Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline.
+1 -1
View File
@@ -73,7 +73,7 @@ Checks run in this order:
| 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) | | 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) |
| any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry | | any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry |
Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6. Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A site always has exactly one default language: the rows are planned with one default and checked again before they are written. Changing the default language re-evaluates the stored SEO status of every article of the site, because the site `default_description` only counts for the default language (see [SEO status and suggestions](articles.md#seo-status-and-suggestions)). A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6.
## First-time language setup ## First-time language setup
+3 -1
View File
@@ -94,7 +94,9 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package. `internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site 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.LockSiteLanguages` (which locks the site row and returns the site's language rows), lets `language.StartingTag` pick 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. When the site row is gone by then (deleted after the use case looked it up), the use case answers `article.ErrTargetSiteDeleted` (`409 target_site_deleted`) instead of the lookup's `404`. `ArticleRepository.Create` only stores what it receives, checks that a given story row exists and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `ConfirmDefaultLanguageUseCase` (lock, read the rows, `language.DefaultOf`, refuse with `ErrNoDefaultLanguage`, flag it confirmed through `MarkConfirmed`, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits; it is not wired to a route (the HTTP setup goes through `ApplyLanguageSetupUseCase`, which takes the site locks with `NOWAIT`). `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 and leaves uniqueness to the two indexes: each driver says whether an error is a unique violation (`Driver.IsUniqueViolation`: SQLite extended codes `2067` and `1555`, MySQL `1062` and `1586`, PostgreSQL `23505`, behind `database.IsUniqueViolation`), and the repository then re-reads which rule was hit and returns `ErrSlugTaken` or `ErrLanguageTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. 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`, locks the site row (`site.Repository.LockSite`), reads the site's language rows through the `localization.Repository` port, lets `localization.ConfirmedDefaultTag` pick 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. `localization.Repository` is the only port for site languages; the former `language.Repository` and its `SiteLanguageRepository` adapter are gone. When the site row is gone by then (deleted after the use case looked it up), the use case answers `article.ErrTargetSiteDeleted` (`409 target_site_deleted`) instead of the lookup's `404`. `ArticleRepository.Create` only stores what it receives, checks that a given story row exists and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `ApplyLanguageSetupUseCase` confirms a default and assigns the unassigned articles under the same site locks (taken with `NOWAIT`), so no article is left unassigned once the confirmation commits. The one-default-per-site invariant lives in the domain (`localization.Plan` builds rows with one default, `localization.RequireSingleDefault` checks it before `UpdateSiteLanguagesUseCase` writes); `LocalizationRepository.Replace` only writes the rows it receives. `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 and leaves uniqueness to the two indexes: each driver says whether an error is a unique violation (`Driver.IsUniqueViolation`: SQLite extended codes `2067` and `1555`, MySQL `1062` and `1586`, PostgreSQL `23505`, behind `database.IsUniqueViolation`), and the repository then re-reads which rule was hit and returns `ErrSlugTaken` or `ErrLanguageTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. 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. `DeleteStoryUseCase` (`DELETE /v1/story/{story_id}`) refuses a caller whose `content.delete` is not at scope `all` before reading anything, locks every site of the story (re-reading until the set is stabLine truncated
The article persistence is split into ports by use: `article.Repository` (single versions and writes), `article.StoryReader` (the editorial list by story: pages, counts, versions of a page) and `article.SEOIndex` (the stored SEO status: `StaleSEO`, `StoreSEO`, `MarkSiteSEOStale`). `article.ListingRepository` joins the three for `ListArticlesUseCase`, the only use case that needs all of them; `ArticleRepository` implements every port, and `transaction.Repositories` exposes `ArticleSEO` so a transaction that changes the default language can clear the statuses of its site. The rules that compute the status stay in the use cases (they parse HTML through `golang.org/x/net/html`), so the repositories only clear and store it; see [performance.md](performance.md#the-seo-article-filter).
`database.Migrate` first sets `source_revision` to `0` where an older nullable column holds `NULL` (`BackfillNullSourceRevisions`, a no-op once no `NULL` is left), so `AutoMigrate` can make it `NOT NULL`; it then 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 `|`. `database.Migrate` first sets `source_revision` to `0` where an older nullable column holds `NULL` (`BackfillNullSourceRevisions`, a no-op once no `NULL` is left), so `AutoMigrate` can make it `NOT NULL`; it then 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 `|`.
+4
View File
@@ -32,6 +32,10 @@ They belong to the MCP binary only and are not server variables.
Routes that need a browser session, such as creating a site, are not exposed. API errors come back as tool errors with the status and the plain-text message. Routes that need a browser session, such as creating a site, are not exposed. API errors come back as tool errors with the status and the plain-text message.
## Why it calls the HTTP API
`renewcms-mcp` runs on the machine of the person using the model (Claude Desktop, Cursor), usually not on the server, and talks to a remote instance. Calling the use cases directly would need the database, the storage and the secrets of the instance in that process, and would skip what the HTTP layer enforces for every client: API-key authentication and its rate limit, the role permissions checked by the router middleware, the activity context, validation of the request bodies and the plain-text error contract. Going through the published routes keeps the model inside the same limits as any API key and keeps one contract to test. It uses the authenticated routes (`/v1/article`, `/v1/media`, `/v1/site`), not the public `/v1/public` routes, so drafts stay reachable for the key's user. Running it in-process with the server is not supported.
## Resources and prompts ## Resources and prompts
The resource `renewcms://docs/llms-full.txt` is the instance's own [`/llms-full.txt`](llms.md), so the model reads the documentation of the exact version it talks to. The prompt `write_and_publish_article` drafts, checks the SEO and asks before publishing. The resource `renewcms://docs/llms-full.txt` is the instance's own [`/llms-full.txt`](llms.md), so the model reads the documentation of the exact version it talks to. The prompt `write_and_publish_article` drafts, checks the SEO and asks before publishing.
+7 -6
View File
@@ -2,17 +2,18 @@
## The `?seo=` article filter ## The `?seo=` article filter
`GET /v1/article/` assesses the SEO status of articles in memory, because the status depends on the body, the site template and the authors. Every request assesses every visible article once, to fill `counts.suggestions`, and `?seo=` reuses that assessment to filter before paging. The cost therefore grows with the number of articles, not with the page size. The SEO status of an article depends on its body, its fields, its authors, its site (`default_description`) and the site default language. It is computed by `RefreshArticleSEOUseCase` and stored on the article (`seo_status`, `seo_suggestions`). Every repository write to one of those inputs clears the stored status and raises `seo_version` in the same `UPDATE` (`withStaleSEO`); site-level changes (default description, default language) clear the whole site through `article.SEOIndex.MarkSiteSEOStale`. Before answering, `GET /v1/article/` recomputes the cleared articles only, in batches of 500, reading their bodies once; `StoreSEO` writes a status only while `seo_version` is still the one it read, so a concurrent edit is never overwritten by an older assessment; the site and its languages are read after each batch of stale rows, never cached across batches, so a site-level change that commits during a refresh is either seen or makes the store miss. The server recomputes everything that is cleared at startup (the backfill of existing rows after the upgrade). `?seo=` and `counts.suggestions` are then plain SQL conditions on `seo_status`, so the cost of a list no longer grows with the number of visible bodies.
`TestSeoFilterPerformance` in `test/seo_filter_performance_test.go` (skipped with `-short`) measures 5,000 articles of about 5 KB each in one site on a temporary SQLite file, every article with suggestions, slowest of 3 runs: `TestSeoFilterPerformance` in `test/seo_filter_performance_test.go` (skipped with `-short`) measures 5,000 articles of about 5 KB each in one site on a temporary SQLite file, every article with suggestions, slowest of 3 runs after the startup backfill:
| Request | Time | | Request | Time |
| --- | --- | | --- | --- |
| `?seo=suggestions&size=25` | 315 to 338 ms | | startup backfill of the 5,000 articles (once) | 270 ms |
| default page, no `?seo=` | 307 to 329 ms | | `?seo=suggestions&size=25` | 24 ms |
| default page, no `?seo=` | 23 ms |
The target is 300 ms for 5,000 articles. Both requests sit slightly above it on the development machine, so the test logs the target but only fails above 500 ms; no cache or index was added. Re-run it with `go test -tags nofrontend ./test/... -run TestSeoFilterPerformance -v -count=1` after a change to the assessment rules or the article repository. The target is 300 ms for 5,000 articles; the test fails above 500 ms. Re-run it with `go test -tags nofrontend ./test/... -run TestSeoFilterPerformance -v -count=1` after a change to the assessment rules or the article repository.
## The `?seo=` filter of the grouped list ## The `?seo=` filter of the grouped list
`GET /v1/article/?group=story` uses the same assessment. The versions whose status matches (and, with `q`, whose title matches: the title search runs once per request through `article.Repository.FindTitleMatches`) are handed to the repository as `StoryFilters.VersionIDs`. That set can be larger than any bind-parameter limit (SQLite accepts 32,766 parameters), so it never becomes an `IN (?, …)` list: the repository reads the qualifying `(story_id, id)` pairs and the ordered story rows with id-only projections and keeps the members of the set in memory before paging. `ListByStories` reads the versions of the page without their bodies and only those the caller may read; the representative's body comes from the assessment pass, which still reads every visible body because the status depends on it. `TestArticleStoryListSEOFilterAtScaleSQLite` covers 40,001 versions. `GET /v1/article/?group=story` filters on the stored status in SQL (`q.seo_status = ?` among the conditions of the qualifying versions) and counts `suggestions` with `COUNT(DISTINCT story_id)` over the readable versions, so no id list is built for the SEO filter. With `q`, the title search still runs once per request through `article.StoryReader.FindTitleMatches` (accent folding happens in Go) and its matches are handed to the repository as `StoryFilters.VersionIDs`. That set can be larger than any bind-parameter limit (SQLite accepts 32,766 parameters), so it never becomes an `IN (?, …)` list: the repository reads the qualifying `(story_id, id)` pairs and the ordered story rows with id-only projections and keeps the members of the set in memory before paging. `ListByStories` reads the versions of the page without their bodies and only those the caller may read; `ListVersions` reads the bodies of the page's representatives only. `TestArticleStoryListSEOFilterAtScaleSQLite` covers 40,001 versions: the backfill assesses each of them once, and the list requests that follow read no stale version and no page of bodies.
+15 -1
View File
@@ -122,7 +122,8 @@ Feature: Editorial article list by story and language
Given the "Journal" dataset and an owner session Given the "Journal" dataset and an owner session
When the client sends GET /v1/article/?group=story When the client sends GET /v1/article/?group=story
Then the coverage of story <story> is <coverage> Then the coverage of story <story> is <coverage>
And each entry has id, lang, title, is_online, language_enabled and capabilities, like an entry of "translations" in features/article_story_lifecycle.feature And each entry has id, lang, title, is_online, language_enabled, excluded_from_public, source_article_id and capabilities, like an entry of "translations" in features/article_story_lifecycle.feature
And each row has excluded_from_public, the flag of its representative version
And missing_langs of story <story> is <missing> And missing_langs of story <story> is <missing>
Examples: Examples:
@@ -160,6 +161,13 @@ Feature: Editorial article list by story and language
Then its row has lang "", coverage with its single version, and missing_langs [] Then its row has lang "", coverage with its single version, and missing_langs []
And it never appears in the "not in" view of any language And it never appears in the "not in" view of any language
Scenario: A mixed story is listed under Missing
Given story 31 holds article 31 (lang "") and article 32 (en), and story 33 holds article 33 (lang "") and article 34 (fr)
When the owner sends GET /v1/article/?group=story&tab=missing
Then stories 31 and 33 are listed, with the enabled languages they lack in missing_langs
And the version without language fills no language
And a story whose readable versions all lack a language is never listed
Rule: Language selected means one row per version of that language Rule: Language selected means one row per version of that language
Scenario: French selected Scenario: French selected
@@ -268,6 +276,12 @@ Feature: Editorial article list by story and language
When the owner sends GET /v1/article/?group=story&seo=suggestions When the owner sends GET /v1/article/?group=story&seo=suggestions
Then story 5 is listed once, with its own representative 501 Then story 5 is listed once, with its own representative 501
Scenario: The SEO status is stored, not recomputed from every body
Given 40,001 versions whose SEO status was computed once
When the owner requests any page of ?group=story&seo=suggestions, with or without q
Then the status filter and counts.suggestions are evaluated in SQL on the stored status
And no body of a visible version is read except the representatives of the page
Scenario: All filters combine with paging and stay stable Scenario: All filters combine with paging and stay stable
Given 12 stories match Given 12 stories match
When the owner requests pages 1, 2 and 3 of ?group=story&size=5 with the same filters When the owner requests pages 1, 2 and 3 of ?group=story&size=5 with the same filters
+3 -2
View File
@@ -6,7 +6,8 @@ Order of work is part of the procedure: the repository query and its real-databa
1. Seed the "Journal" dataset (8 stories, 17 versions, bylines, updated_at as in the feature) and the "hidden-version" variant through the real `ArticleRepository` on a temp SQLite file with `database.Migrate`. Drive the new grouped query through `article.Repository` with the new filter fields (group, lang, tab, author) and assert, table-driven over the feature's Examples: story ids per page, `total`, representative per story, coverage order, `missing_langs`, `story_updated_at`, `story_authors`, counts. 1. Seed the "Journal" dataset (8 stories, 17 versions, bylines, updated_at as in the feature) and the "hidden-version" variant through the real `ArticleRepository` on a temp SQLite file with `database.Migrate`. Drive the new grouped query through `article.Repository` with the new filter fields (group, lang, tab, author) and assert, table-driven over the feature's Examples: story ids per page, `total`, representative per story, coverage order, `missing_langs`, `story_updated_at`, `story_authors`, counts.
2. Access filter inside the query: for a caller with scope `own`, `all`-online and `online only`, assert the hidden-version dataset yields the same ids, totals and counts as a database where the hidden articles do not exist. Also check page boundaries (a hidden version must not shift a page: 5 per page of readable stories). 2. Access filter inside the query: for a caller with scope `own`, `all`-online and `online only`, assert the hidden-version dataset yields the same ids, totals and counts as a database where the hidden articles do not exist. Also check page boundaries (a hidden version must not shift a page: 5 per page of readable stories).
3. Filter semantics: the "single readable version satisfies all version-level filters" cases (status+author, status+q, author+seo), the "story_authors is the union" case, the "missing" cases (no readable version, site not enabling the language, disabled language, unassigned articles) and accent-insensitive q in grouped, language and not-in modes. 3. Filter semantics: the "single readable version satisfies all version-level filters" cases (status+author, status+q, author+seo), the "story_authors is the union" case, the "missing" cases (no readable version, site not enabling the language, disabled language, unassigned-only stories never listed, mixed stories listed: TestArticleListLocalization* "Missing membership and counts agree with the missing_langs of the rows") and accent-insensitive q in grouped, language and not-in modes.
3b. Stored SEO status: TestArticleStoryListSEOFilterAtScaleSQLite runs the startup backfill once over 40,001 versions, then asserts per request zero stale versions read, zero FindPage calls and one title search with q. TestArticleSEOIndex* (SQLite, MySQL, PostgreSQL) asserts that each write path clears the stored status and the refresh recomputes it, that a status computed before a concurrent write is not stored, and that the site description counts for the default language only and follows a default change.
4. Sort whitelist and stability: each sort of the feature, ties by story_id descending, no overlap or gap between pages 1 to 3 on a 12-story set. 4. Sort whitelist and stability: each sort of the feature, ties by story_id descending, no overlap or gap between pages 1 to 3 on a 12-story set.
## Automated, real databases (task test:mysql, task test:postgres) ## Automated, real databases (task test:mysql, task test:postgres)
@@ -21,7 +22,7 @@ Repeat items 1 to 4 against MySQL 8.4, MariaDB 11 and PostgreSQL. Compare ids, t
6. `article.NewHandler(...)` over the real use cases and fakes, a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only), `validator.InitValidator()` first, `httptest`, jwt cookies signed with a test `jwtauth.New("HS256", ...)` secret (owner, reader, restricted writer, unverified user, expired, none). 6. `article.NewHandler(...)` over the real use cases and fakes, a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only), `validator.InitValidator()` first, `httptest`, jwt cookies signed with a test `jwtauth.New("HS256", ...)` secret (owner, reader, restricted writer, unverified user, expired, none).
7. "Public lists are unchanged": bare request and a public request keep the flat keys exactly (compare the key set and values with a golden produced before the slice); `lang=fr` and `author` narrow the flat list; `group=story` for the three public callers and every invalid query in the Examples answer 400 with `apperr.BodyErrorMessage` and the fake repository records no call. 7. "Public lists are unchanged": bare request and a public request keep the flat keys exactly (compare the key set and values with a golden produced before the slice); `lang=fr` and `author` narrow the flat list; `group=story` for the three public callers and every invalid query in the Examples answer 400 with `apperr.BodyErrorMessage` and the fake repository records no call.
8. Grouped response shape: decode and assert `items[]` keys (flat item keys plus `story_updated_at`, `story_authors`, `coverage`, `missing_langs`, `capabilities`), `total`, `page`, `size`, `counts` keys (all, online, draft, suggestions, missing), `items` `[]` not null, snake_case, errors plain text. Repository failure and count failure answer 500 "Failed to retrieve articles". Docs: `docs/` gains the grouped contract (parameters, shape, counts, errors) so the SPA mocks `/v1` from it. 8. Grouped response shape: decode and assert `items[]` keys (flat item keys plus `story_updated_at`, `story_authors`, `coverage`, `missing_langs`, `excluded_from_public`, `capabilities`; coverage entries carry `excluded_from_public` and `source_article_id`), `total`, `page`, `size`, `counts` keys (all, online, draft, suggestions, missing), `items` `[]` not null, snake_case, errors plain text. Repository failure and count failure answer 500 "Failed to retrieve articles". Docs: `docs/` gains the grouped contract (parameters, shape, counts, errors) so the SPA mocks `/v1` from it.
## Automated, SPA (Vitest + Vue Test Utils, task test:web) ## Automated, SPA (Vitest + Vue Test Utils, task test:web)
+1 -1
View File
@@ -4,7 +4,7 @@ Backend only. Go tests in test/, black-box. No design states. No new endpoints.
## Automated, no server (task test) ## 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. 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. 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 it locks the site row (site.Repository.LockSite) and then reads the starting language through the localization port (localization.Repository.Rows, localization.ConfirmedDefaultTag) and passes it to Create as Lang, and that the story is created by the article repository inside the same transaction. The former language.Repository, its SiteLanguageRepository adapter and the unwired ConfirmDefaultLanguageUseCase are retired; the confirmation tests drive ApplyLanguageSetupUseCase. 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 "lang" or "story_id" in a PATCH body is ignored. POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature, and a POST without them behaves as before. Errors stay plain text. 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 "lang" or "story_id" in a PATCH body is ignored. POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature, and a POST without them behaves as before. Errors stay plain text.
4. Regression: run the full existing article, pagination, publishing and SEO tests untouched. 4. Regression: run the full existing article, pagination, publishing and SEO tests untouched.
+81 -3
View File
@@ -1,5 +1,5 @@
Feature: Capabilities, translations, publish rules, delete and story move Feature: Capabilities, translations, publish rules, delete and story move
An article read tells the editor what the caller may do with the version and which readable sibling versions exist. Publishing respects disabled languages. Deleting the last version removes its story. A story moves to another site as a whole, atomically, never split. An article read tells the editor what the caller may do with the version and which readable sibling versions exist. Publishing respects disabled languages. Deleting the last version removes its story, and a whole article can be deleted at once, atomically. A story moves to another site as a whole, atomically, never split. Nothing reveals a version the caller cannot read.
Background: Background:
Given error responses are plain text, with extra data in headers Given error responses are plain text, with extra data in headers
@@ -9,6 +9,8 @@ Feature: Capabilities, translations, publish rules, delete and story move
And "owner" holds every content permission at scope all And "owner" holds every content permission at scope all
And "writer1" (user 1) holds content.create, content.read, content.update, content.publish and content.delete at scope own, and content.read at scope all for online articles only And "writer1" (user 1) holds content.create, content.read, content.update, content.publish and content.delete at scope own, and content.read at scope all for online articles only
And "reader" holds content.read at scope all only And "reader" holds content.read at scope all only
And "restricted" holds content.create at scope all and content.read at scope own
And "deleter" holds content.delete at scope all and content.read at scope own
Scenario: Capabilities of the owner Scenario: Capabilities of the owner
When the owner sends GET /v1/article/12 When the owner sends GET /v1/article/12
@@ -30,8 +32,8 @@ Feature: Capabilities, translations, publish rules, delete and story move
Scenario: Translations list readable versions only Scenario: Translations list readable versions only
When writer1 sends GET /v1/article/10 When writer1 sends GET /v1/article/10
Then "translations" has entries for 10, 12 and 13 and none for 11 Then "translations" has entries for 10, 12 and 13 and none for 11
And each entry has id, lang, title, is_online, language_enabled and capabilities And each entry has id, lang, title, is_online, language_enabled, excluded_from_public, source_article_id and capabilities
And no count, placeholder or total names the hidden version And no entry, count, placeholder or total names the hidden version
Scenario: Translations are ordered default language first, then by tag Scenario: Translations are ordered default language first, then by tag
When the owner sends GET /v1/article/12 When the owner sends GET /v1/article/12
@@ -52,6 +54,7 @@ Feature: Capabilities, translations, publish rules, delete and story move
When an anonymous client sends GET /v1/article/10 When an anonymous client sends GET /v1/article/10
Then "translations" is [] Then "translations" is []
And "capabilities" is ["read"] And "capabilities" is ["read"]
And the response has no "missing_langs", "excluded_from_public", "source_unavailable" or "partial_view"
And the public shape is otherwise unchanged And the public shape is otherwise unchanged
Scenario: The source on the read Scenario: The source on the read
@@ -62,6 +65,47 @@ Feature: Capabilities, translations, publish rules, delete and story move
Then "source" is null Then "source" is null
And a version created empty has "source": null And a version created empty has "source": null
Scenario Outline: Missing languages are the enabled languages without a readable version
When <caller> sends GET /v1/article/10
Then "missing_langs" is <missing>
Examples:
| caller | missing |
| owner | [] |
| restricted | ["de","fr"] |
| reader | [] |
Scenario: A version in a disabled language or without language is left out of public surfaces
Given article 14 (es, offline, created by restricted) is in story 5 and es is disabled on journal
And article 30 has lang "" and its own story
When the owner sends GET /v1/article/14
Then "excluded_from_public" is true
And the translations entry of 14 has "excluded_from_public": true and the entry of 10 has false
And GET /v1/article/30 has "excluded_from_public": true
And GET /v1/article/10 has "excluded_from_public": false
Scenario: A deleted source and an unreadable source read the same
Given article 14 tracks article 12 at content revision 2
When the owner sends GET /v1/article/14
Then "source" is {"article_id":12,"revision":2} and "source_unavailable" is false
When the restricted user, who cannot read 12, sends GET /v1/article/14
Then "source" is null and "source_unavailable" is true
When article 12 is deleted and the owner sends GET /v1/article/14
Then "source" is null and "source_unavailable" is true
Scenario: Translations name the source they track only when it is readable
Given article 14 tracks article 12
When the owner sends GET /v1/article/10
Then the translations entry of 14 has "source_article_id": 12 and the entry of 10 has null
When the restricted user sends GET /v1/article/14
Then the translations entry of 14 has "source_article_id": null
Scenario: Partial view follows the read scope of the caller only
When the owner sends GET /v1/article/10
Then "partial_view" is false
When the restricted user sends GET /v1/article/13
Then "partial_view" is true, whether or not the story holds a version the restricted user cannot read
Scenario Outline: Publishing respects the language configuration Scenario Outline: Publishing respects the language configuration
Given a valid jwt cookie for a user holding content.publish Given a valid jwt cookie for a user holding content.publish
When the client sends PATCH /v1/article/<id>/publish When the client sends PATCH /v1/article/<id>/publish
@@ -136,6 +180,40 @@ Feature: Capabilities, translations, publish rules, delete and story move
Then the story remains Then the story remains
And nothing in the response mentions the other version And nothing in the response mentions the other version
Scenario: Deleting an article removes every version and the article
When the owner sends DELETE /v1/story/5
Then the response status is 204 with an empty body
And articles 10, 11, 12 and 13 and story 5 are gone
And one article.delete event per version is recorded with params.lang and params.story_id 5
Scenario: Versions the caller cannot read are deleted with the article
When the deleter, who reads 10 and 13 only, sends DELETE /v1/story/5
Then the response status is 204
And articles 11 and 12 are deleted too
Scenario: Deleting an article needs delete permission on every version
When writer1, whose content.delete is at scope own, sends DELETE /v1/story/5
Then the response status is 403, the body is "missing_permission" and X-Missing-Permission is "content.delete"
And every version remains
And the answer is the same whether or not the story holds a version writer1 cannot read
Scenario: A failed delete removes nothing
Given the database fails while the versions are deleted
When the owner sends DELETE /v1/story/5
Then the response status is 500 and the body is "Failed to delete article"
And every version and story 5 remain
Scenario: An unknown or unreadable article answers the same not found
When the owner sends DELETE /v1/story/9999
Then the response status is 404 and the body is "Article not found"
When the deleter sends DELETE /v1/story/77, whose only version is a draft by another user
Then the response status is 404 and the body is "Article not found"
And that version remains
Scenario: An invalid id is refused
When the owner sends DELETE /v1/story/abc
Then the response status is 400 with the uint32 id message
Scenario: Move a story Scenario: Move a story
When the owner sends POST /v1/story/5/move with {"site":"atelier"} When the owner sends POST /v1/story/5/move with {"site":"atelier"}
Then the response status is 200 Then the response status is 200
+3 -1
View File
@@ -5,12 +5,14 @@ Backend only. Go tests in test/, black-box. No design states.
## Automated, no server (task test) ## Automated, no server (task test)
1. Use cases over fakes (article, site, language, localization, role grants) and a fake Transactor: the capabilities and translations mapping, the publish rule, deleting the last version and MoveStory. Table-driven over every Examples table; assert the sentinels with errors.Is. MoveStory runs the permission check, the language check, the slug check and all writes inside one Transactor.Run, and a failing write rolls back all fake rows. 1. Use cases over fakes (article, site, language, localization, role grants) and a fake Transactor: the capabilities and translations mapping, the publish rule, deleting the last version and MoveStory. Table-driven over every Examples table; assert the sentinels with errors.Is. MoveStory runs the permission check, the language check, the slug check and all writes inside one Transactor.Run, and a failing write rolls back all fake rows.
2. HTTP via the article NewHandler and the new story handler, a chi router mirroring api/router.go (/article and /story mounts, authtoken group), validator.InitValidator(), httptest and jwt cookies for owner, writer1, reader and none. Assert exact bodies and the headers X-Existing-Article-Id (absent where not allowed), X-Current-Revision, X-Conflicting-Articles and X-Missing-Languages. Hidden versions appear in no body, header or count. 2. HTTP via the article NewHandler and the new story handler, a chi router mirroring api/router.go (/article and /story mounts, authtoken group), validator.InitValidator(), httptest and jwt cookies for owner, writer1, reader and none. Assert exact bodies and the headers X-Existing-Article-Id (absent where not allowed), X-Current-Revision, X-Conflicting-Articles and X-Missing-Languages. Hidden versions appear in no body, header or count.
3. Anonymous reads: the key set is the slice 1 set plus revision, capabilities, translations and source. 3. Anonymous reads: the key set is the slice 1 set plus revision, capabilities, translations and source. Signed-in reads add missing_langs, excluded_from_public, source_unavailable and partial_view (TestArticleEditorialContextHTTP; TestArticleCreatedByHttp compares the two shapes).
3b. Whole-article delete: TestStoryDeleteHTTP covers every scenario of the "Deleting a whole article" rule over the story world (fake transactor with rollback, DeleteStory failure injected through the fail switch).
4. Activity: a fake recorder receives params.lang on publish, unpublish, edit, delete and per-version move events. 4. Activity: a fake recorder receives params.lang on publish, unpublish, edit, delete and per-version move events.
5. Regression: the article_publishing and article_title_rules tests pass with the updated pair text. 5. Regression: the article_publishing and article_title_rules tests pass with the updated pair text.
## Automated, SQLite file (task test) ## Automated, SQLite file (task test)
6. Deleting the last version removes the story row, deleting one keeps it. Re-creating fr after deleting fr works. The site_languages in-use count still includes a deleted fr version (slice 2 TestLanguageInUseCountsSoftDeletedVersions stays green). 6. Deleting the last version removes the story row, deleting one keeps it. Re-creating fr after deleting fr works. The site_languages in-use count still includes a deleted fr version (slice 2 TestLanguageInUseCountsSoftDeletedVersions stays green).
6b. DeleteStory: TestArticleSEOIndex* "Deleting an article removes every version and the article row" (live versions 0, story row gone, slugs free, a second call is not found), on SQLite and on the real databases.
7. Move: all site_id values change together. A unique (site_id, lang, slug) violation at the destination rolls back every row. An injected write failure on the third row leaves all rows unchanged. 7. Move: all site_id values change together. A unique (site_id, lang, slug) violation at the destination rolls back every row. An injected write failure on the third row leaves all rows unchanged.
8. PATCH site_id counts readable versions only: with one readable version in a multi-version story it runs the full story move (every version, hidden ones too, moves in one transaction; language and slug refusals roll back and name readable versions only), which needs content.update on every version (403 missing_permission otherwise); with two readable versions it is 409 story_move_required. 8. PATCH site_id counts readable versions only: with one readable version in a multi-version story it runs the full story move (every version, hidden ones too, moves in one transaction; language and slug refusals roll back and name readable versions only), which needs content.update on every version (403 missing_permission otherwise); with two readable versions it is 409 story_move_required.
+1 -1
View File
@@ -1,5 +1,5 @@
Feature: Create a translation of an article version Feature: Create a translation of an article version
A user who can create articles adds a version of an existing article in another language. The version joins the story of the version it is created from, in the same site, as an offline draft. Two entry points share one implementation: POST /v1/article/{id}/translations and POST /v1/article/ with story_id. Hidden versions are never named, counted or hinted at. A user who can create articles adds a version of an existing article in another language. The version joins the story of the version it is created from, in the same site, as an offline draft. Two entry points share one implementation: POST /v1/article/{id}/translations and POST /v1/article/ with story_id. The story_id entry point is deprecated: it keeps working with the same results, and new clients use POST /v1/article/{id}/translations. Hidden versions are never named, counted or hinted at.
Background: Background:
Given the language rules of features/article_localization_storage.feature apply Given the language rules of features/article_localization_storage.feature apply
@@ -303,6 +303,14 @@ Feature: Localized public API, SEO output and the existing surfaces that show la
Then fallbacks.site_text_skipped is true Then fallbacks.site_text_skipped is true
And this is the documented limitation of treating the site text as written in the current default language And this is the documented limitation of treating the site text as written in the current default language
Scenario: The SEO check and the list status follow the same language rule
Given journal's default_description is 108 characters long and versions 10 (en, default) and 12 (fr) have no description, excerpt or body sentence
When the owner sends POST /v1/article/10/seo/check and POST /v1/article/12/seo/check with {}
Then only the fr check reports desc_short
And the seo_suggestions of 12 in GET /v1/article/ is one more than that of 10
When the owner makes fr the default language of journal
Then the next GET /v1/article/ reports one more suggestion for 10 than for 12, without any edit of the articles
Scenario: Brand text and images stay shared Scenario: Brand text and images stay shared
Then title_template and site_name apply to every language Then title_template and site_name apply to every language
And the image chain (social, cover, site default image) is unchanged and each version's cover_image_alt stays its own And the image chain (social, cover, site default image) is unchanged and each version's cover_image_alt stays its own
@@ -326,6 +334,15 @@ Feature: Localized public API, SEO output and the existing surfaces that show la
When an anonymous caller sends GET /v1/site/journal/sitemap When an anonymous caller sends GET /v1/site/journal/sitemap
Then the url of version 20 is "https://www.journal.example/spring" as before this slice Then the url of version 20 is "https://www.journal.example/spring" as before this slice
Rule: The public routes and the sitemap are rate limited per client
Scenario: The public article routes and the sitemap share one budget per client
Given one client address has sent 120 requests within a minute to /v1/public/... and /v1/site/journal/sitemap together
When it sends one more request to either
Then the response status is 429 with Retry-After and the body "Too many requests. Please try again later."
And another client address is still answered
And X-Real-IP is used as the client address only when the connection comes from TRUSTED_PROXIES
Rule: Media usage names the language and counts a story once Rule: Media usage names the language and counts a story once
Background: Background:
@@ -400,6 +417,7 @@ Feature: Localized public API, SEO output and the existing surfaces that show la
And the tool descriptions no longer say that a title cannot be changed And the tool descriptions no longer say that a title cannot be changed
And get_article, list_articles and get_sitemap return the JSON they already return, which now carries lang and story_id And get_article, list_articles and get_sitemap return the JSON they already return, which now carries lang and story_id
And no tool is added for the public routes And no tool is added for the public routes
And the MCP server keeps calling the authenticated HTTP routes with its API key rather than the use cases, for the reasons in docs/mcp.md
Scenario: The llms documents mention languages Scenario: The llms documents mention languages
Then docs/llms.md, docs/llm/ and the llms-full output describe lang, story_id, alternates, the two public routes and the fallback rule Then docs/llms.md, docs/llm/ and the llms-full output describe lang, story_id, alternates, the two public routes and the fallback rule
@@ -15,6 +15,8 @@ Fixture: the Background of the feature (journal with en default, fr fr_FR, de, a
5. **SEO object**: key sets (additive `lang`, `alternates`, `og.locale_alternates`, `fallbacks.site_text_skipped`); canonical table (pattern, override wins, no pattern keeps legacy, unassigned keeps legacy, `{lang}` keeps `pt-BR`); `fallbacks.canonical` values; og:locale Examples table including `en` with Site.Locale `en` and `null` (empty string, alternates `[]`); `og.locale_alternates`; the description/title fallback tables and `site_text_skipped`; the default-change limitation scenario. Existing `test/seo_output_*` specs are amended only where they assert the exact key set, the `og.locale` of an assigned article, or the canonical of a site that has a pattern (unassigned and pattern-less cases must pass unchanged). 5. **SEO object**: key sets (additive `lang`, `alternates`, `og.locale_alternates`, `fallbacks.site_text_skipped`); canonical table (pattern, override wins, no pattern keeps legacy, unassigned keeps legacy, `{lang}` keeps `pt-BR`); `fallbacks.canonical` values; og:locale Examples table including `en` with Site.Locale `en` and `null` (empty string, alternates `[]`); `og.locale_alternates`; the description/title fallback tables and `site_text_skipped`; the default-change limitation scenario. Existing `test/seo_output_*` specs are amended only where they assert the exact key set, the `og.locale` of an assigned article, or the canonical of a site that has a pattern (unassigned and pattern-less cases must pass unchanged).
6. **Sitemap**: eligible versions only, `lang` key, localized urls with a pattern and legacy urls without, `X-Total-Count`, paging invariants unchanged. 6. **Sitemap**: eligible versions only, `lang` key, localized urls with a pattern and legacy urls without, `X-Total-Count`, paging invariants unchanged.
7. **Languages read**: `social_locale_state` `derived` with `en_GB` for bare `en` and Site.Locale `en-GB`, `not_set` for bare Site.Locale; amend `test/site_languages_*` accordingly. 7. **Languages read**: `social_locale_state` `derived` with `en_GB` for bare `en` and Site.Locale `en-GB`, `not_set` for bare Site.Locale; amend `test/site_languages_*` accordingly.
7b. **Rate limit**: TestPublicReadRateLimit sends 120 requests alternating the exact lookup and the sitemap from one address, then asserts 429 with Retry-After on the exact lookup, the sitemap and the story resolution, and 200 for another address.
7c. **Language-aware SEO check and status**: TestCheckArticleSEOIsLanguageAware (fakes) and TestArticleSEOIndex* "The site description counts for the default language only and follows a default change" (SQLite and real databases).
8. **MCP**: `list_articles` forwards `lang` (assert the request URL with a stub HTTP client in `internal/infrastructure/mcpserver`'s existing test style), tool descriptions contain no "cannot be changed". 8. **MCP**: `list_articles` forwards `lang` (assert the request URL with a stub HTTP client in `internal/infrastructure/mcpserver`'s existing test style), tool descriptions contain no "cannot be changed".
9. **Catalogue drift**: `test/api_catalogue_drift_test.go` green with the two new routes; `web/src/data/apiCatalogue.ts` counts updated. 9. **Catalogue drift**: `test/api_catalogue_drift_test.go` green with the two new routes; `web/src/data/apiCatalogue.ts` counts updated.
10. **Layering**: existing layering tests stay green; the public handler imports no persistence, models or gorm. 10. **Layering**: existing layering tests stay green; the public handler imports no persistence, models or gorm.
@@ -116,7 +116,7 @@ Feature: Translation flows, revision conflicts, delete scopes, article moves and
And a refusal that reaches the use case records one failed event with a plain reason that names no hidden data And a refusal that reaches the use case records one failed event with a plain reason that names no hidden data
Scenario: Persistence needs no new query Scenario: Persistence needs no new query
Then the use case calls the existing site GetByKey, LockStartingLanguage(site id), localization Rows(site id), SlugTaken(site id, lang, slug) and Articles.Create Then the use case calls the existing site GetByKey, Sites.LockSite(site id), localization Rows(site id), SlugTaken(site id, lang, slug) and Articles.Create
And no repository, model, mapper or migration changes, so the real-database runs (task test:mysql, test:postgres) are not required for this change And no repository, model, mapper or migration changes, so the real-database runs (task test:mysql, test:postgres) are not required for this change
Rule: Add translation opens from the language menu, the URL and the article list Rule: Add translation opens from the language menu, the URL and the article list
@@ -17,7 +17,7 @@ Fixture: sites journal (id 1, default; en default, fr, de, ar; es disabled), ate
7. **Visibility and activity**: the new offline article is 404 for an anonymous GET, absent from the public lists of both sites, listed by `GET /v1/article/?site=atelier` for a writer; the event params hold the site key. 7. **Visibility and activity**: the new offline article is 404 for an anonymous GET, absent from the public lists of both sites, listed by `GET /v1/article/?site=atelier` for a writer; the event params hold the site key.
8. **Dependency rule**: the handler test imports no persistence, models or gorm; a grep-style check is not added here (architect covers it). The handler change is the `Site *string` field with `validate:"omitempty,sitekey"` and the pass-through in `CreateArticleCommand`; no domain, repository, mapper or migration change. 8. **Dependency rule**: the handler test imports no persistence, models or gorm; a grep-style check is not added here (architect covers it). The handler change is the `Site *string` field with `validate:"omitempty,sitekey"` and the pass-through in `CreateArticleCommand`; no domain, repository, mapper or migration change.
Real-database runs: `task test:mysql` and `task test:postgres` are not required, because no query, model or repository changes (existing `GetByKey`, `LockStartingLanguage`, `Rows`, `SlugTaken` and `Create` are already covered per site by `test/article_localization_storage_persistence_test.go` and `test/site_persistence_test.go`). If the implementation adds any query, add a persistence case there and run both. Real-database runs: `task test:mysql` and `task test:postgres` are not required, because no query, model or repository changes (existing `GetByKey`, `LockSite`, `Rows`, `SlugTaken` and `Create` are already covered per site by `test/article_localization_storage_persistence_test.go` and `test/site_persistence_test.go`). If the implementation adds any query, add a persistence case there and run both.
Existing Go specs to amend (and nothing else): `test/article_create_language_test.go` (command carries the site key), `test/article_translation_create_http_test.go` (rows of the "unchanged when it is not a translation" table, the `site` plus `story_id` refusal), `test/article_title_rules_test.go` and `test/use_case_baseline_test.go` only where they build `CreateArticleCommand` or `PostArticle` (compile and default-site behaviour must be unchanged), `test/api_catalogue_drift_test.go` stays green because the route list does not change (check it still passes; if the catalogue documents request fields, update it with the docs). `test/seo_data_cleaner_test.go` fails `GetDefault` on purpose and must keep passing for the omitted-site path. Existing Go specs to amend (and nothing else): `test/article_create_language_test.go` (command carries the site key), `test/article_translation_create_http_test.go` (rows of the "unchanged when it is not a translation" table, the `site` plus `story_id` refusal), `test/article_title_rules_test.go` and `test/use_case_baseline_test.go` only where they build `CreateArticleCommand` or `PostArticle` (compile and default-site behaviour must be unchanged), `test/api_catalogue_drift_test.go` stays green because the route list does not change (check it still passes; if the catalogue documents request fields, update it with the docs). `test/seo_data_cleaner_test.go` fails `GetDefault` on purpose and must keep passing for the omitted-site path.
+2
View File
@@ -140,6 +140,8 @@ type Article struct {
EditRevision uint32 `json:"-"` EditRevision uint32 `json:"-"`
SourceArticleID *uint32 `json:"-"` SourceArticleID *uint32 `json:"-"`
SourceRevision uint32 `json:"-"` SourceRevision uint32 `json:"-"`
SEO SEOSummary `json:"-"`
SEOVersion uint32 `json:"-"`
} }
func (a Article) UsesImage(imageID uint32) bool { func (a Article) UsesImage(imageID uint32) bool {
@@ -83,6 +83,8 @@ type Translation struct {
Title string `json:"title"` Title string `json:"title"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
LanguageEnabled bool `json:"language_enabled"` LanguageEnabled bool `json:"language_enabled"`
ExcludedFromPublic bool `json:"excluded_from_public"`
SourceArticleID *uint32 `json:"source_article_id"`
Capabilities []string `json:"capabilities"` Capabilities []string `json:"capabilities"`
} }
@@ -1,5 +1,7 @@
package article package article
import "math"
const ( const (
StatusOnline = "online" StatusOnline = "online"
StatusDraft = "draft" StatusDraft = "draft"
@@ -37,13 +39,23 @@ type Filters struct {
Size int Size int
Q string Q string
Status string Status string
Lang *string SEOStatus string
Lang string
Langs *[]string Langs *[]string
AuthorID *uint32 AuthorID *uint32
Sort Sort Sort Sort
Unpaged bool Unpaged bool
} }
func (f Filters) Window() (int, int) {
size := max(f.Size, 0)
skipped := max(f.Page, 1) - 1
if size > 0 && skipped > math.MaxInt/size {
return math.MaxInt, size
}
return skipped * size, size
}
type Nullable[T any] struct { type Nullable[T any] struct {
Set bool Set bool
Value *T Value *T
@@ -97,10 +109,7 @@ type Repository interface {
CountByVisibility(onlineOnly bool) (Counts, error) CountByVisibility(onlineOnly bool) (Counts, error)
CountVisibleTo(userID uint32) (Counts, error) CountVisibleTo(userID uint32) (Counts, error)
CountVersions(filters Filters) (Counts, error) CountVersions(filters Filters) (Counts, error)
FindStoryPage(filters StoryFilters) (StoryPage, error) CountSuggestions(filters Filters) (int, error)
CountStories(filters StoryFilters) (StoryCounts, error)
ListByStories(storyIDs []uint32, readable StoryFilters) ([]Article, error)
FindTitleMatches(q string, readable StoryFilters) ([]uint32, error)
Get(id uint32) (Article, error) Get(id uint32) (Article, error)
GetByName(name string) (Article, error) GetByName(name string) (Article, error)
GetByLangSlug(siteID uint32, lang string, slug string) (Article, error) GetByLangSlug(siteID uint32, lang string, slug string) (Article, error)
@@ -120,4 +129,26 @@ type Repository interface {
AssignUnassignedLanguage(siteID uint32, lang string) (int, error) AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
ListByStory(storyID uint32) ([]Article, error) ListByStory(storyID uint32) ([]Article, error)
MoveToSite(ids []uint32, siteID uint32) error MoveToSite(ids []uint32, siteID uint32) error
DeleteStory(storyID uint32) error
}
type StoryReader interface {
FindStoryPage(filters StoryFilters) (StoryPage, error)
CountStories(filters StoryFilters) (StoryCounts, error)
CountSuggestedStories(readable StoryFilters) (int, error)
ListByStories(storyIDs []uint32, readable StoryFilters) ([]Article, error)
ListVersions(ids []uint32) ([]Article, error)
FindTitleMatches(q string, readable StoryFilters) ([]uint32, error)
}
type SEOIndex interface {
StaleSEO(afterID uint32, limit int) ([]Article, error)
StoreSEO(records []SEORecord) error
MarkSiteSEOStale(siteID uint32) error
}
type ListingRepository interface {
Repository
StoryReader
SEOIndex
} }
@@ -13,6 +13,7 @@ type StoryFilters struct {
Lang string Lang string
Missing bool Missing bool
Status string Status string
SEOStatus string
AuthorID *uint32 AuthorID *uint32
VersionIDs *[]uint32 VersionIDs *[]uint32
Sort Sort Sort Sort
+10
View File
@@ -37,6 +37,16 @@ type SEOSummary struct {
Suggestions int Suggestions int
} }
func (s SEOSummary) Stale() bool {
return s.Status == ""
}
type SEORecord struct {
ArticleID uint32
Version uint32
Summary SEOSummary
}
func (a SEOAssessment) Summary() SEOSummary { func (a SEOAssessment) Summary() SEOSummary {
return SEOSummary{Status: a.Status, Suggestions: len(a.Suggestions)} return SEOSummary{Status: a.Status, Suggestions: len(a.Suggestions)}
} }
@@ -10,7 +10,6 @@ import (
var ( var (
ErrInvalidLanguage = errors.New("invalid language tag") ErrInvalidLanguage = errors.New("invalid language tag")
ErrUnsupportedLanguage = errors.New("unsupported language") ErrUnsupportedLanguage = errors.New("unsupported language")
ErrNoDefaultLanguage = errors.New("the site has no default language")
) )
const ( const (
@@ -64,27 +63,3 @@ func Suggest(locale *string) Suggestion {
} }
return Suggestion{} return Suggestion{}
} }
type SiteLanguage struct {
SiteID uint32
Tag string
IsDefault bool
Enabled bool
Confirmed bool
}
func DefaultOf(languages []SiteLanguage) (SiteLanguage, bool) {
index := slices.IndexFunc(languages, func(candidate SiteLanguage) bool { return candidate.IsDefault })
if index < 0 {
return SiteLanguage{}, false
}
return languages[index], true
}
func StartingTag(languages []SiteLanguage) string {
defaultLanguage, found := DefaultOf(languages)
if !found || !defaultLanguage.Confirmed {
return Unassigned
}
return defaultLanguage.Tag
}
@@ -1,7 +0,0 @@
package language
type Repository interface {
ListBySite(siteID uint32) ([]SiteLanguage, error)
LockSiteLanguages(siteID uint32) ([]SiteLanguage, error)
MarkConfirmed(siteID uint32, tag string) error
}
@@ -13,6 +13,7 @@ var (
ErrSetupInProgress = errors.New("another language setup is running") ErrSetupInProgress = errors.New("another language setup is running")
ErrSetupIncomplete = errors.New("every pending site needs a language") ErrSetupIncomplete = errors.New("every pending site needs a language")
ErrDuplicateSite = errors.New("the same site is listed twice") ErrDuplicateSite = errors.New("the same site is listed twice")
ErrSingleDefault = errors.New("a site has exactly one default language")
) )
const ( const (
@@ -128,6 +128,34 @@ func MissingTags(rows []Row, tags []string) []string {
return missing return missing
} }
func ConfirmedDefaultTag(rows []Row) string {
if NeedsSetup(rows) {
return language.Unassigned
}
return DefaultTag(rows)
}
func SiteTextAllowed(defaultTag string, tag string) bool {
return tag == language.Unassigned || tag == defaultTag
}
func PublicTag(rows []Row, tag string) bool {
return tag != language.Unassigned && EnabledTag(rows, tag)
}
func RequireSingleDefault(rows []Row) error {
defaults := 0
for _, row := range rows {
if row.IsDefault {
defaults++
}
}
if defaults != 1 {
return ErrSingleDefault
}
return nil
}
func DefaultTag(rows []Row) string { func DefaultTag(rows []Row) string {
for _, row := range rows { for _, row := range rows {
if row.IsDefault { if row.IsDefault {
+2 -1
View File
@@ -46,7 +46,8 @@ const (
MaxKeyRunes = 32 MaxKeyRunes = 32
TitlePlaceholder = "%title%" TitlePlaceholder = "%title%"
DefaultTitleTemplate = "%title% | %site%" SitePlaceholder = "%site%"
DefaultTitleTemplate = TitlePlaceholder + " | " + SitePlaceholder
) )
var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
@@ -5,7 +5,6 @@ import (
"RenewCMS/internal/domain/author" "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization" "RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"context" "context"
@@ -20,8 +19,8 @@ type Repositories struct {
Authors author.Repository Authors author.Repository
Entries entry.Repository Entries entry.Repository
Images image.Repository Images image.Repository
Languages language.Repository
Localization localization.Repository Localization localization.Repository
ArticleSEO article.SEOIndex
} }
type Transactor interface { type Transactor interface {
@@ -58,6 +58,8 @@ func ArticleToDomain(m models.Article) article.Article {
EditRevision: m.EditRevision, EditRevision: m.EditRevision,
SourceArticleID: m.SourceArticleID, SourceArticleID: m.SourceArticleID,
SourceRevision: m.SourceRevision, SourceRevision: m.SourceRevision,
SEO: article.SEOSummary{Status: m.SEOStatus, Suggestions: m.SEOSuggestions},
SEOVersion: m.SEOVersion,
} }
} }
@@ -1,16 +0,0 @@
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,
}
}
@@ -35,4 +35,7 @@ type Article struct {
SourceArticleID *uint32 SourceArticleID *uint32
SourceRevision uint32 `gorm:"not null;default:0"` SourceRevision uint32 `gorm:"not null;default:0"`
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"` RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
SEOStatus string `gorm:"column:seo_status;size:16;not null;default:'';index"`
SEOSuggestions int `gorm:"column:seo_suggestions;not null;default:0"`
SEOVersion uint32 `gorm:"column:seo_version;not null;default:0"`
} }
@@ -61,8 +61,11 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
case domain.StatusDraft: case domain.StatusDraft:
query = query.Where("is_online = ?", false) query = query.Where("is_online = ?", false)
} }
if filters.Lang != nil { if filters.Lang != "" {
query = query.Where("lang = ?", *filters.Lang) query = query.Where("lang = ?", filters.Lang)
}
if filters.SEOStatus != "" {
query = query.Where("seo_status = ?", filters.SEOStatus)
} }
if filters.Langs != nil { if filters.Langs != nil {
query = restrictToLangs(query, *filters.Langs) query = restrictToLangs(query, *filters.Langs)
@@ -119,7 +122,8 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
var entityArticles []entity.Article var entityArticles []entity.Article
ordered := a.scoped(filters).Order(orderClause(filters.Sort)) ordered := a.scoped(filters).Order(orderClause(filters.Sort))
if !filters.Unpaged { if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size) offset, limit := filters.Window()
ordered = ordered.Limit(limit).Offset(offset)
} }
err := ordered.Find(&entityArticles).Error err := ordered.Find(&entityArticles).Error
if err != nil { if err != nil {
@@ -146,9 +150,9 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
} }
pageIds := matchingIds pageIds := matchingIds
if !filters.Unpaged { if !filters.Unpaged {
start := min((filters.Page-1)*filters.Size, len(matchingIds)) offset, limit := filters.Window()
end := min(start+filters.Size, len(matchingIds)) start := min(offset, len(matchingIds))
pageIds = matchingIds[start:end] pageIds = matchingIds[start : start+min(limit, len(matchingIds)-start)]
} }
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)} page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 { if len(pageIds) == 0 {
@@ -220,6 +224,13 @@ func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
} }
func (a *ArticleRepository) CountSuggestions(filters domain.Filters) (int, error) {
visible := domain.Filters{IsOnline: filters.IsOnline, VisibleTo: filters.VisibleTo, SEOStatus: domain.SEOStatusSuggestions}
var total int64
err := a.scoped(visible).Count(&total).Error
return int(total), err
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article { func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles)) domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles { for _, entityArticle := range entityArticles {
@@ -463,7 +474,7 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
} }
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) { func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
return a.updateColumns(id, map[string]any{"body": body}) return a.updateColumns(id, withStaleSEO(map[string]any{"body": body}))
} }
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) { func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
@@ -603,6 +614,8 @@ func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision u
"edit_revision": written.EditRevision, "edit_revision": written.EditRevision,
"content_revision": written.ContentRevision, "content_revision": written.ContentRevision,
"updated_at": written.UpdatedAt, "updated_at": written.UpdatedAt,
seoStatusColumn: "",
seoVersionColumn: gorm.Expr(seoVersionColumn + " + 1"),
}) })
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
@@ -668,6 +681,32 @@ func (a *ArticleRepository) Delete(id uint32) error {
return err return err
} }
} }
if err := retireVersion(tx, id); err != nil {
return err
}
return closeEmptyStory(tx, found.StoryID)
})
}
func (a *ArticleRepository) DeleteStory(storyID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var ids []uint32
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
if len(ids) == 0 {
return domain.ErrArticleNotFound
}
for _, id := range ids {
if err := retireVersion(tx, id); err != nil {
return err
}
}
return deleteOrNotFound(tx.Delete(&entity.Story{}, storyID), domain.ErrArticleNotFound)
})
}
func retireVersion(tx *gorm.DB, id uint32) error {
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id} retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil { if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
return err return err
@@ -675,11 +714,7 @@ func (a *ArticleRepository) Delete(id uint32) error {
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil { if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err return err
} }
if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil { return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
return err
}
return closeEmptyStory(tx, found.StoryID)
})
} }
func closeEmptyStory(tx *gorm.DB, storyID uint32) error { func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
@@ -709,7 +744,7 @@ func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
if err := tx.First(&moving, id).Error; err != nil { if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound) return mapNotFoundErr(err, domain.ErrArticleNotFound)
} }
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")} moved := withStaleSEO(map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")})
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil { if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
return err return err
} }
@@ -763,7 +798,7 @@ func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32)
if len(*cleared) == 0 { if len(*cleared) == 0 {
return nil return nil
} }
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumns(withStaleSEO(map[string]any{column: nil})).Error
} }
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error { func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
@@ -779,7 +814,7 @@ func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint3
if len(articleIDs) == 0 { if len(articleIDs) == 0 {
return nil return nil
} }
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumns(withStaleSEO(map[string]any{column: imageID})).Error
} }
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) { func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
@@ -791,10 +826,10 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil { if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
return err return err
} }
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil { if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"cover_image_id": toImageID})).Error; err != nil {
return err return err
} }
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"social_image_id": toImageID})).Error
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -805,7 +840,7 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) { func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumn("lang", lang) result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumns(withStaleSEO(map[string]any{"lang": lang}))
return int(result.RowsAffected), result.Error return int(result.RowsAffected), result.Error
} }
@@ -0,0 +1,47 @@
package persistence
import (
domain "RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
seoStatusColumn = "seo_status"
seoSuggestionsColumn = "seo_suggestions"
seoVersionColumn = "seo_version"
)
func withStaleSEO(columns map[string]any) map[string]any {
columns[seoStatusColumn] = ""
columns[seoVersionColumn] = gorm.Expr(seoVersionColumn + " + 1")
return columns
}
func (a *ArticleRepository) StaleSEO(afterID uint32, limit int) ([]domain.Article, error) {
var found []entity.Article
err := a.db.Model(&entity.Article{}).Where(seoStatusColumn+" = ? AND id > ?", "", afterID).Order("id ASC").Limit(limit).Find(&found).Error
if err != nil {
return nil, err
}
return a.withAuthors(a.db, toDomainArticles(found))
}
func (a *ArticleRepository) StoreSEO(records []domain.SEORecord) error {
return a.db.Transaction(func(tx *gorm.DB) error {
for _, record := range records {
stored := map[string]any{seoStatusColumn: record.Summary.Status, seoSuggestionsColumn: record.Summary.Suggestions}
if err := tx.Model(&entity.Article{}).Where("id = ? AND "+seoVersionColumn+" = ?", record.ArticleID, record.Version).UpdateColumns(stored).Error; err != nil {
return err
}
}
return nil
})
}
func (a *ArticleRepository) MarkSiteSEOStale(siteID uint32) error {
return a.db.Model(&entity.Article{}).Where("site_id = ?", siteID).UpdateColumns(withStaleSEO(map[string]any{})).Error
}
var _ domain.SEOIndex = &ArticleRepository{}
@@ -2,6 +2,7 @@ package persistence
import ( import (
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"strings" "strings"
) )
@@ -127,16 +128,26 @@ func qualifyingVersions(filters domain.StoryFilters) storySQL {
case domain.StatusDraft: case domain.StatusDraft:
condition.add("q.is_online = ?", false) condition.add("q.is_online = ?", false)
} }
if filters.SEOStatus != "" {
condition.add("q.seo_status = ?", filters.SEOStatus)
}
if filters.AuthorID != nil { if filters.AuthorID != nil {
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID) condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
} }
if filters.Missing { if filters.Missing {
condition.merge(lacksReadableVersionIn("?", filters, "")) condition.merge(holdsReadableLanguage(filters))
condition.merge(missingCondition(filters)) condition.merge(missingCondition(filters))
} }
return condition return condition
} }
func holdsReadableLanguage(filters domain.StoryFilters) storySQL {
readable := readableCondition("l", filters)
condition := storySQL{}
condition.add("EXISTS (SELECT 1 FROM articles l WHERE l.story_id = q.story_id AND "+readable.where()+" AND l.lang <> ?)", append(readable.args, language.Unassigned)...)
return condition
}
func missingCondition(filters domain.StoryFilters) storySQL { func missingCondition(filters domain.StoryFilters) storySQL {
condition := storySQL{} condition := storySQL{}
if filters.Lang != "" { if filters.Lang != "" {
@@ -310,6 +321,31 @@ func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.St
return counts, err return counts, err
} }
func (a *ArticleRepository) CountSuggestedStories(readable domain.StoryFilters) (int, error) {
condition := readableCondition("q", readable)
condition.add("q.seo_status = ?", domain.SEOStatusSuggestions)
var stories, standalone int64
if err := a.db.Raw("SELECT COUNT(DISTINCT q.story_id) FROM articles q WHERE "+condition.where()+" AND q.story_id <> ?", append(condition.args, 0)...).Scan(&stories).Error; err != nil {
return 0, err
}
err := a.db.Raw("SELECT COUNT(*) FROM articles q WHERE "+condition.where()+" AND q.story_id = ?", append(condition.args, 0)...).Scan(&standalone).Error
return int(stories + standalone), err
}
func (a *ArticleRepository) ListVersions(ids []uint32) ([]domain.Article, error) {
var found []entity.Article
for _, chunk := range chunkIDs(ids) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
return a.withAuthors(a.db, toDomainArticles(found))
}
var _ domain.StoryReader = &ArticleRepository{}
func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) { func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) {
var found []entity.Article var found []entity.Article
for _, chunk := range chunkIDs(storyIDs) { for _, chunk := range chunkIDs(storyIDs) {
@@ -282,6 +282,10 @@ func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
} }
func removeAuthor(tx *gorm.DB, id uint32) error { func removeAuthor(tx *gorm.DB, id uint32) error {
credited := tx.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", id)
if err := tx.Model(&entity.Article{}).Where("id IN (?)", credited).UpdateColumns(withStaleSEO(map[string]any{})).Error; err != nil {
return err
}
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil { if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err return err
} }
@@ -105,9 +105,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
} }
} }
} }
if err := l.clearOtherDefaults(siteID, rows); err != nil {
return err
}
for _, row := range rows { for _, row := range rows {
if err := l.save(siteID, row, known); err != nil { if err := l.save(siteID, row, known); err != nil {
return err return err
@@ -116,15 +113,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
return nil return nil
} }
func (l *LocalizationRepository) clearOtherDefaults(siteID uint32, rows []domain.Row) error {
for _, row := range rows {
if row.IsDefault {
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND is_default = ? AND lang <> ?", siteID, true, row.Tag).UpdateColumn("is_default", false).Error
}
}
return nil
}
func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error { func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error {
before, exists := known[row.Tag] before, exists := known[row.Tag]
if !exists { if !exists {
@@ -1,42 +0,0 @@
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) LockSiteLanguages(siteID uint32) ([]domain.SiteLanguage, error) {
if _, err := lockSite(s.db, siteID); err != nil {
return nil, err
}
return s.ListBySite(siteID)
}
func (s *SiteLanguageRepository) MarkConfirmed(siteID uint32, tag string) error {
return s.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, tag).UpdateColumn("confirmed", true).Error
}
var _ domain.Repository = &SiteLanguageRepository{}
@@ -258,7 +258,7 @@ func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
if _, err := lockSite(tx, to); err != nil { if _, err := lockSite(tx, to); err != nil {
return err return err
} }
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumns(withStaleSEO(map[string]any{"site_id": to})).Error
} }
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) { func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
@@ -21,14 +21,15 @@ func NewTransactor(db *gorm.DB) *Transactor {
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error { func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
err := t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
articles := NewArticleRepository(tx)
return work(domain.Repositories{ return work(domain.Repositories{
Articles: NewArticleRepository(tx), Articles: articles,
Sites: NewSiteRepository(tx), Sites: NewSiteRepository(tx),
Authors: NewAuthorRepository(tx), Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx), Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx), Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx),
Localization: NewLocalizationRepository(tx), Localization: NewLocalizationRepository(tx),
ArticleSEO: articles,
}) })
}) })
if database.IsLockContention(t.db, err) { if database.IsLockContention(t.db, err) {
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role" "RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
) )
@@ -12,6 +13,7 @@ type CheckArticleSEOUseCase struct {
articleRepository article.Repository articleRepository article.Repository
sites site.Repository sites site.Repository
images image.Repository images image.Repository
localization localization.Repository
} }
type CheckArticleSEOCommand struct { type CheckArticleSEOCommand struct {
@@ -30,8 +32,8 @@ type CheckArticleSEOCommand struct {
SEODismissed *[]string SEODismissed *[]string
} }
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase { func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository, localizationRepository localization.Repository) *CheckArticleSEOUseCase {
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images} return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images, localization: localizationRepository}
} }
func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) { func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) {
@@ -57,7 +59,11 @@ func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command
if err != nil { if err != nil {
return article.SEOCheck{}, err return article.SEOCheck{}, err
} }
assessment := assessSEO(candidate, owner) rows, err := u.localization.Rows(saved.SiteID)
if err != nil {
return article.SEOCheck{}, err
}
assessment := assessSEO(candidate, owner, localization.SiteTextAllowed(localization.ConfirmedDefaultTag(rows), candidate.Lang))
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
} }
@@ -1,36 +0,0 @@
package useCases
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"context"
)
type ConfirmDefaultLanguageUseCase struct {
transactor transaction.Transactor
}
func NewConfirmDefaultLanguageUseCase(transactor transaction.Transactor) *ConfirmDefaultLanguageUseCase {
return &ConfirmDefaultLanguageUseCase{transactor}
}
func (u *ConfirmDefaultLanguageUseCase) Confirm(ctx context.Context, siteID uint32) error {
return u.transactor.Run(ctx, func(tx transaction.Repositories) error {
languages, err := tx.Languages.LockSiteLanguages(siteID)
if err != nil {
return err
}
defaultLanguage, found := language.DefaultOf(languages)
if !found {
return language.ErrNoDefaultLanguage
}
if defaultLanguage.Confirmed {
return nil
}
if err := tx.Languages.MarkConfirmed(siteID, defaultLanguage.Tag); err != nil {
return err
}
_, err = tx.Articles.AssignUnassignedLanguage(siteID, defaultLanguage.Tag)
return err
})
}
@@ -124,21 +124,20 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) { func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
var created article.Article var created article.Article
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error { err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
languages, err := tx.Languages.LockSiteLanguages(draft.SiteID) err := tx.Sites.LockSite(draft.SiteID)
if errors.Is(err, site.ErrSiteNotFound) { if errors.Is(err, site.ErrSiteNotFound) {
return article.ErrTargetSiteDeleted return article.ErrTargetSiteDeleted
} }
if err != nil { if err != nil {
return err return err
} }
lang := language.StartingTag(languages) rows, err := tx.Localization.Rows(draft.SiteID)
if requested != language.Unassigned {
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
if err != nil { if err != nil {
return err return err
} }
if draft.Lang, err = chooseLanguage(rows, requested); err != nil {
return err
} }
draft.Lang = lang
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt) draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
if err != nil { if err != nil {
return err return err
@@ -149,10 +148,9 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requeste
return created, err return created, err
} }
func chooseLanguage(repository localization.Repository, siteID uint32, requested string) (string, error) { func chooseLanguage(rows []localization.Row, requested string) (string, error) {
rows, err := repository.Rows(siteID) if requested == language.Unassigned {
if err != nil { return localization.ConfirmedDefaultTag(rows), nil
return "", err
} }
if localization.NeedsSetup(rows) { if localization.NeedsSetup(rows) {
return "", localization.ErrSetupRequired return "", localization.ErrSetupRequired
@@ -0,0 +1,92 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
)
type DeleteStoryUseCase struct {
transactor transaction.Transactor
articles article.Repository
recorder audit.Recorder
}
type DeleteStoryCommand struct {
StoryID uint32
Caller ContentCaller
}
func NewDeleteStoryUseCase(transactor transaction.Transactor, articles article.Repository, recorder audit.Recorder) *DeleteStoryUseCase {
return &DeleteStoryUseCase{transactor: transactor, articles: articles, recorder: recorder}
}
func (u *DeleteStoryUseCase) DeleteStory(ctx context.Context, command DeleteStoryCommand) error {
if err := requireDeleteOnEveryVersion(command.Caller); err != nil {
return err
}
var deleted []article.Article
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
versions, err := lockStoryVersions(tx, command.StoryID, command.Caller)
if err != nil {
return err
}
deleted = sortByCreation(versions)
return tx.Articles.DeleteStory(command.StoryID)
})
if err != nil {
u.recordRefusal(ctx, command, err)
return err
}
for _, version := range deleted {
event := articleEvent(audit.ActionArticleDelete, version)
event.Params[audit.ParamStoryID] = command.StoryID
u.recorder.Record(ctx, event)
}
return nil
}
func requireDeleteOnEveryVersion(caller ContentCaller) error {
if caller.Grants.ScopeFor(role.KeyContentDelete, builtintype.KeyArticle) != role.ScopeAll {
return role.MissingPermissionError{Key: role.KeyContentDelete}
}
return nil
}
func lockStoryVersions(tx transaction.Repositories, storyID uint32, caller ContentCaller) ([]article.Article, error) {
locked := []uint32{}
for attempt := 0; attempt < maxSiteLockRounds; attempt++ {
versions, err := tx.Articles.ListByStory(storyID)
if err != nil {
return nil, err
}
if len(caller.readableVersions(versions)) == 0 {
return nil, article.ErrArticleNotFound
}
needed := siteIDsOf(versions)
if allLocked(locked, needed) {
return versions, nil
}
if err := lockSites(tx, needed); err != nil {
return nil, err
}
locked = needed
}
return nil, transaction.ErrLockContention
}
func (u *DeleteStoryUseCase) recordRefusal(ctx context.Context, command DeleteStoryCommand, cause error) {
if isNotFound(cause) || errors.Is(cause, role.ErrMissingPermission) {
return
}
versions, err := u.articles.ListByStory(command.StoryID)
readable := sortByCreation(command.Caller.readableVersions(versions))
if err != nil || len(readable) == 0 {
return
}
recordFailure(ctx, u.recorder, articleEvent(audit.ActionArticleDelete, readable[0]), cause)
}
@@ -2,9 +2,12 @@ package useCases
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization" "RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"errors" "errors"
"slices"
) )
type DescribeArticleUseCase struct { type DescribeArticleUseCase struct {
@@ -16,6 +19,10 @@ type ArticleContext struct {
Capabilities []string Capabilities []string
Translations []article.Translation Translations []article.Translation
Source *article.Source Source *article.Source
SourceUnavailable bool
MissingLangs []string
ExcludedFromPublic bool
PartialView bool
} }
func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase { func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase {
@@ -23,73 +30,75 @@ func NewDescribeArticleUseCase(articles article.Repository, localizationReposito
} }
func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) { func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) {
described := ArticleContext{Capabilities: caller.capabilitiesFor(art), Translations: []article.Translation{}} rows, err := d.localization.Rows(art.SiteID)
if err != nil {
return ArticleContext{}, err
}
described := ArticleContext{
Capabilities: caller.capabilitiesFor(art),
Translations: []article.Translation{},
MissingLangs: []string{},
ExcludedFromPublic: !localization.PublicTag(rows, art.Lang),
PartialView: caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) != role.ScopeAll,
}
if caller.isAnonymous() { if caller.isAnonymous() {
return described, nil return described, nil
} }
translations, err := d.translations(art, caller) readable, err := d.readableVersions(art, caller)
if err != nil { if err != nil {
return ArticleContext{}, err return ArticleContext{}, err
} }
described.Translations = translations described.Translations = translationsOf(caller, sortByLanguage(readable, rows), rows)
described.Source, err = d.source(art, caller) described.MissingLangs = missingLanguages(readable, rows, caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle))
if err != nil { described.Source, described.SourceUnavailable, err = d.source(art, caller)
return ArticleContext{}, err return described, err
}
return described, nil
} }
func (d *DescribeArticleUseCase) translations(art article.Article, caller ContentCaller) ([]article.Translation, error) { func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, bool, error) {
versions := []article.Article{art} if art.SourceArticleID == nil {
if art.StoryID != 0 { return nil, false, nil
found, err := d.articles.ListByStory(art.StoryID) }
origin, err := d.articles.Get(*art.SourceArticleID)
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !caller.seesArticle(origin) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, false, nil
}
func (d *DescribeArticleUseCase) readableVersions(art article.Article, caller ContentCaller) ([]article.Article, error) {
if art.StoryID == 0 {
return []article.Article{art}, nil
}
versions, err := d.articles.ListByStory(art.StoryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
versions = found return caller.readableVersions(versions), nil
} }
rows, err := d.rows(art.SiteID)
if err != nil { func translationsOf(caller ContentCaller, ordered []article.Article, rows []localization.Row) []article.Translation {
return nil, err translations := make([]article.Translation, 0, len(ordered))
} for _, version := range ordered {
translations := []article.Translation{}
for _, version := range sortByLanguage(caller.readableVersions(versions), rows) {
translations = append(translations, article.Translation{ translations = append(translations, article.Translation{
ID: version.ID, ID: version.ID,
Lang: version.Lang, Lang: version.Lang,
Title: version.Title, Title: version.Title,
IsOnline: version.IsOnline, IsOnline: version.IsOnline,
LanguageEnabled: d.languageEnabled(rows, version.Lang), LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(rows, version.Lang),
ExcludedFromPublic: !localization.PublicTag(rows, version.Lang),
SourceArticleID: trackedSourceID(version, ordered),
Capabilities: caller.capabilitiesFor(version), Capabilities: caller.capabilitiesFor(version),
}) })
} }
return translations, nil return translations
} }
func (d *DescribeArticleUseCase) rows(siteID uint32) ([]localization.Row, error) { func trackedSourceID(version article.Article, readable []article.Article) *uint32 {
return d.localization.Rows(siteID) if version.SourceArticleID == nil || !slices.ContainsFunc(readable, func(candidate article.Article) bool { return candidate.ID == *version.SourceArticleID }) {
return nil
} }
return version.SourceArticleID
func (d *DescribeArticleUseCase) languageEnabled(rows []localization.Row, tag string) bool {
if tag == language.Unassigned {
return true
}
return localization.EnabledTag(rows, tag)
}
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, error) {
if art.SourceArticleID == nil {
return nil, nil
}
origin, err := d.articles.Get(*art.SourceArticleID)
if errors.Is(err, article.ErrArticleNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !caller.seesArticle(origin) {
return nil, nil
}
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, nil
} }
@@ -34,6 +34,7 @@ type StoryRow struct {
Authors []author.Byline Authors []author.Byline
Coverage []article.Translation Coverage []article.Translation
MissingLangs []string MissingLangs []string
ExcludedFromPublic bool
Capabilities []string Capabilities []string
} }
@@ -46,11 +47,13 @@ type StoryRowsPage struct {
} }
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) { func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
if err := u.seo.RefreshStale(); err != nil {
return StoryRowsPage{}, err
}
visibility := visibilityFor(command.Caller) visibility := visibilityFor(command.Caller)
creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle) creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle)
empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size} empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size}
filters := storyFilters(command, visibility, command.Lang == "" && !creatable) filters := storyFilters(command, visibility, command.Lang == "" && !creatable)
owners := &siteOwners{repository: u.sites}
if command.SiteKey != nil { if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey) siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil { if err != nil {
@@ -61,18 +64,17 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
} }
filters.SiteID = &siteID filters.SiteID = &siteID
} }
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners) var err error
if err != nil { if filters.VersionIDs, err = u.titleRestriction(command, filters); err != nil {
return StoryRowsPage{}, err
}
if filters.VersionIDs, err = u.versionRestriction(command, filters, summaries); err != nil {
return StoryRowsPage{}, err return StoryRowsPage{}, err
} }
counts, err := u.articleRepository.CountStories(filters) counts, err := u.articleRepository.CountStories(filters)
if err != nil { if err != nil {
return StoryRowsPage{}, err return StoryRowsPage{}, err
} }
counts.Suggestions = countSuggestedStories(visibleVersions, summaries) if counts.Suggestions, err = u.articleRepository.CountSuggestedStories(readableOnly(filters)); err != nil {
return StoryRowsPage{}, err
}
empty.Counts = counts empty.Counts = counts
if command.Tab == article.TabMissing && command.Lang == "" && !creatable { if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
return empty, nil return empty, nil
@@ -85,7 +87,7 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
if len(found.Refs) == 0 { if len(found.Refs) == 0 {
return empty, nil return empty, nil
} }
rows, err := u.storyRows(command, filters, found.Refs, creatable, storyVersionsByID(visibleVersions), summaries) rows, err := u.storyRows(command, filters, found.Refs, creatable)
if err != nil { if err != nil {
return StoryRowsPage{}, err return StoryRowsPage{}, err
} }
@@ -93,6 +95,10 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
return empty, nil return empty, nil
} }
func readableOnly(filters article.StoryFilters) article.StoryFilters {
return article.StoryFilters{OnlyOnline: filters.OnlyOnline, VisibleTo: filters.VisibleTo}
}
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters { func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
return article.StoryFilters{ return article.StoryFilters{
SkipMissing: skipMissing, SkipMissing: skipMissing,
@@ -101,6 +107,7 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
Lang: command.Lang, Lang: command.Lang,
Missing: command.Tab == article.TabMissing, Missing: command.Tab == article.TabMissing,
Status: command.Status, Status: command.Status,
SEOStatus: command.SEO,
AuthorID: command.Author, AuthorID: command.Author,
Sort: command.Sort, Sort: command.Sort,
Page: command.Page, Page: command.Page,
@@ -108,75 +115,34 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
} }
} }
func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibility, owners *siteOwners) (map[uint32]article.SEOSummary, []article.Article, error) { func (u *ListArticlesUseCase) titleRestriction(command ListStoriesCommand, filters article.StoryFilters) (*[]uint32, error) {
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true})) query := strings.TrimSpace(command.Q)
if err != nil { if query == "" {
return nil, nil, err return nil, nil
} }
summaries, err := u.summarize(found.Items, owners)
return summaries, found.Items, err
}
func (u *ListArticlesUseCase) versionRestriction(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) (*[]uint32, error) {
var allowed *[]uint32
if query := strings.TrimSpace(command.Q); query != "" {
matching, err := u.articleRepository.FindTitleMatches(query, filters) matching, err := u.articleRepository.FindTitleMatches(query, filters)
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowed = &matching return &matching, nil
}
if command.SEO == "" {
return allowed, nil
}
matchingTitle := map[uint32]bool{}
if allowed != nil {
for _, id := range *allowed {
matchingTitle[id] = true
}
}
withStatus := []uint32{}
for id, summary := range summaries {
if summary.Status == command.SEO && (allowed == nil || matchingTitle[id]) {
withStatus = append(withStatus, id)
}
}
return &withStatus, nil
} }
func storyVersionsByID(versions []article.Article) map[uint32]article.Article { func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool) ([]StoryRow, error) {
byID := make(map[uint32]article.Article, len(versions))
for _, version := range versions {
byID[version.ID] = version
}
return byID
}
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
stories := map[uint32]bool{}
standalone := 0
for _, version := range versions {
if summaries[version.ID].Status != article.SEOStatusSuggestions {
continue
}
if version.StoryID == 0 {
standalone++
continue
}
stories[version.StoryID] = true
}
return len(stories) + standalone
}
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool, visible map[uint32]article.Article, summaries map[uint32]article.SEOSummary) ([]StoryRow, error) {
storyIDs := make([]uint32, 0, len(refs)) storyIDs := make([]uint32, 0, len(refs))
representativeIDs := make([]uint32, 0, len(refs))
for _, ref := range refs { for _, ref := range refs {
storyIDs = append(storyIDs, ref.StoryID) storyIDs = append(storyIDs, ref.StoryID)
representativeIDs = append(representativeIDs, ref.ArticleID)
} }
loaded, err := u.articleRepository.ListByStories(storyIDs, filters) loaded, err := u.articleRepository.ListByStories(storyIDs, filters)
if err != nil { if err != nil {
return nil, err return nil, err
} }
withBodies, err := u.articleRepository.ListVersions(representativeIDs)
if err != nil {
return nil, err
}
visible := storyVersionsByID(withBodies)
byStory := map[uint32][]article.Article{} byStory := map[uint32][]article.Article{}
for _, version := range command.Caller.readableVersions(loaded) { for _, version := range command.Caller.readableVersions(loaded) {
byStory[version.StoryID] = append(byStory[version.StoryID], version) byStory[version.StoryID] = append(byStory[version.StoryID], version)
@@ -188,7 +154,7 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
versions := byStory[ref.StoryID] versions := byStory[ref.StoryID]
_, listed := versionByID(versions, ref.ArticleID) _, listed := versionByID(versions, ref.ArticleID)
representative, found := visible[ref.ArticleID] representative, found := visible[ref.ArticleID]
if !listed || !found { if !listed || !found || !command.Caller.seesArticle(representative) {
continue continue
} }
languages, cached := siteRows[representative.SiteID] languages, cached := siteRows[representative.SiteID]
@@ -207,11 +173,19 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
} }
for index := range rows { for index := range rows {
rows[index].Article = rendered[index] rows[index].Article = rendered[index]
rows[index].SEO = summaries[rendered[index].ID] rows[index].SEO = representatives[index].SEO
} }
return rows, nil return rows, nil
} }
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
byID := make(map[uint32]article.Article, len(versions))
for _, version := range versions {
byID[version.ID] = version
}
return byID
}
func versionByID(versions []article.Article, id uint32) (article.Article, bool) { func versionByID(versions []article.Article, id uint32) (article.Article, bool) {
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id }) index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id })
if index < 0 { if index < 0 {
@@ -222,19 +196,10 @@ func versionByID(versions []article.Article, id uint32) (article.Article, bool)
func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow { func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow {
ordered := sortByLanguage(versions, languages) ordered := sortByLanguage(versions, languages)
coverage := make([]article.Translation, 0, len(ordered))
updatedAt := time.Time{} updatedAt := time.Time{}
authors := []author.Byline{} authors := []author.Byline{}
seen := map[uint32]bool{} seen := map[uint32]bool{}
for _, version := range ordered { for _, version := range ordered {
coverage = append(coverage, article.Translation{
ID: version.ID,
Lang: version.Lang,
Title: version.Title,
IsOnline: version.IsOnline,
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(languages, version.Lang),
Capabilities: caller.capabilitiesFor(version),
})
if version.UpdatedAt.After(updatedAt) { if version.UpdatedAt.After(updatedAt) {
updatedAt = version.UpdatedAt updatedAt = version.UpdatedAt
} }
@@ -248,15 +213,16 @@ func buildStoryRow(caller ContentCaller, representative article.Article, version
return StoryRow{ return StoryRow{
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
Authors: authors, Authors: authors,
Coverage: coverage, Coverage: translationsOf(caller, ordered, languages),
MissingLangs: missingLanguages(ordered, languages, creatable), MissingLangs: missingLanguages(ordered, languages, creatable),
ExcludedFromPublic: !localization.PublicTag(languages, representative.Lang),
Capabilities: caller.capabilitiesFor(representative), Capabilities: caller.capabilitiesFor(representative),
} }
} }
func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string { func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string {
missing := []string{} missing := []string{}
if !creatable || slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang == language.Unassigned }) { if !creatable || !slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang != language.Unassigned }) {
return missing return missing
} }
present := languagesOf(versions) present := languagesOf(versions)
@@ -10,14 +10,21 @@ import (
) )
type ListArticlesUseCase struct { type ListArticlesUseCase struct {
articleRepository article.Repository articleRepository article.ListingRepository
bodyRenderer article.BodyRenderer bodyRenderer article.BodyRenderer
sites site.Repository sites site.Repository
localization localization.Repository localization localization.Repository
seo *RefreshArticleSEOUseCase
} }
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase { func NewListArticlesUseCase(articleRepository article.ListingRepository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites, localization: localizationRepository} return &ListArticlesUseCase{
articleRepository: articleRepository,
bodyRenderer: bodyRenderer,
sites: sites,
localization: localizationRepository,
seo: NewRefreshArticleSEOUseCase(articleRepository, sites, localizationRepository),
}
} }
type ListArticlesCommand struct { type ListArticlesCommand struct {
@@ -30,7 +37,7 @@ type ListArticlesCommand struct {
Sort article.Sort Sort article.Sort
SiteKey *string SiteKey *string
SEO string SEO string
Lang *string Lang string
Author *uint32 Author *uint32
} }
@@ -73,7 +80,7 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
} }
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) { func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
if command.Lang != nil { if command.Lang != "" {
return u.countVisibleVersions(command, visibility) return u.countVisibleVersions(command, visibility)
} }
return u.countVisible(visibility) return u.countVisible(visibility)
@@ -87,18 +94,18 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
} }
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) { func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
onlineOnly := visibility.onlineOnly if err := u.seo.RefreshStale(); err != nil {
return ArticlePage{}, err
}
counts, err := u.countFlat(command, visibility) counts, err := u.countFlat(command, visibility)
if err != nil { if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
summaries, err := u.summarizeVisible(visibility) if counts.Suggestions, err = u.articleRepository.CountSuggestions(visibility.apply(article.Filters{})); err != nil {
if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
counts.Suggestions = countSuggestions(summaries)
page := emptyArticlePage(command, counts) page := emptyArticlePage(command, counts)
if onlineOnly && command.Status == article.StatusDraft { if visibility.onlineOnly && command.Status == article.StatusDraft {
return page, nil return page, nil
} }
filters := visibility.apply(pageFilters(command)) filters := visibility.apply(pageFilters(command))
@@ -112,39 +119,32 @@ func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesComman
} }
filters.SiteID = &siteID filters.SiteID = &siteID
} }
filters.Unpaged = command.SEO != ""
found, err := u.articleRepository.FindPage(filters) found, err := u.articleRepository.FindPage(filters)
if err != nil { if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
items, total := found.Items, found.Total rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
if command.SEO != "" {
items, total = pageBySEOStatus(found.Items, summaries, command)
}
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
if err != nil { if err != nil {
return ArticlePage{}, err return ArticlePage{}, err
} }
page.Items = rendered page.Items = rendered
page.Total = total page.Total = found.Total
page.SEO = summaries page.SEO = storedSummaries(found.Items)
return page, nil return page, nil
} }
func storedSummaries(items []article.Article) map[uint32]article.SEOSummary {
summaries := make(map[uint32]article.SEOSummary, len(items))
for _, item := range items {
summaries[item.ID] = item.SEO
}
return summaries
}
func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage { func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage {
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}} return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
} }
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == status {
matching = append(matching, item)
}
}
return matching
}
func pageSlice[T any](items []T, page int, size int) []T { func pageSlice[T any](items []T, page int, size int) []T {
size = min(max(size, 0), len(items)) size = min(max(size, 0), len(items))
if size == 0 { if size == 0 {
@@ -159,69 +159,11 @@ func pageSlice[T any](items []T, page int, size int) []T {
return items[start:min(start+size, len(items))] return items[start:min(start+size, len(items))]
} }
func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (map[uint32]article.SEOSummary, error) {
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
if err != nil {
return nil, err
}
return u.summarize(found.Items, &siteOwners{repository: u.sites})
}
func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) { func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
filters := visibility.apply(article.Filters{Lang: command.Lang}) filters := visibility.apply(article.Filters{Lang: command.Lang})
return u.articleRepository.CountVersions(filters) return u.articleRepository.CountVersions(filters)
} }
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
suggestions := 0
for _, summary := range summaries {
if summary.Status == article.SEOStatusSuggestions {
suggestions++
}
}
return suggestions
}
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
matching := withSEOStatus(items, summaries, command.SEO)
return pageSlice(matching, command.Page, command.Size), len(matching)
}
type siteOwners struct {
repository site.Repository
byID map[uint32]site.Site
}
func (o *siteOwners) get() (map[uint32]site.Site, error) {
if o.byID != nil {
return o.byID, nil
}
known, err := o.repository.List()
if err != nil {
return nil, err
}
o.byID = make(map[uint32]site.Site, len(known))
for _, owner := range known {
o.byID[owner.ID] = owner
}
return o.byID, nil
}
func (u *ListArticlesUseCase) summarize(items []article.Article, lookup *siteOwners) (map[uint32]article.SEOSummary, error) {
summaries := make(map[uint32]article.SEOSummary, len(items))
if len(items) == 0 {
return summaries, nil
}
owners, err := lookup.get()
if err != nil {
return nil, err
}
for _, item := range items {
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
}
return summaries, nil
}
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) { func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
found, err := u.sites.GetByKey(key) found, err := u.sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) { if errors.Is(err, site.ErrSiteNotFound) {
@@ -234,5 +176,5 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
} }
func pageFilters(command ListArticlesCommand) article.Filters { func pageFilters(command ListArticlesCommand) article.Filters {
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author} return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, SEOStatus: command.SEO, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
} }
@@ -0,0 +1,90 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"errors"
)
const seoRefreshBatchSize = 500
type RefreshArticleSEOUseCase struct {
index article.SEOIndex
sites site.Repository
localization localization.Repository
}
func NewRefreshArticleSEOUseCase(index article.SEOIndex, sites site.Repository, localizationRepository localization.Repository) *RefreshArticleSEOUseCase {
return &RefreshArticleSEOUseCase{index: index, sites: sites, localization: localizationRepository}
}
func (u *RefreshArticleSEOUseCase) RefreshStale() error {
afterID := uint32(0)
for {
stale, err := u.index.StaleSEO(afterID, seoRefreshBatchSize)
if err != nil || len(stale) == 0 {
return err
}
context := u.siteContextReadAfter()
records := make([]article.SEORecord, 0, len(stale))
for _, version := range stale {
summary, err := context.assess(version)
if err != nil {
return err
}
records = append(records, article.SEORecord{ArticleID: version.ID, Version: version.SEOVersion, Summary: summary})
}
if err := u.index.StoreSEO(records); err != nil {
return err
}
afterID = stale[len(stale)-1].ID
}
}
func (u *RefreshArticleSEOUseCase) siteContextReadAfter() *seoSiteContext {
return &seoSiteContext{sites: u.sites, localization: u.localization, owners: map[uint32]site.Site{}, defaults: map[uint32]string{}}
}
type seoSiteContext struct {
sites site.Repository
localization localization.Repository
owners map[uint32]site.Site
defaults map[uint32]string
}
func (c *seoSiteContext) assess(version article.Article) (article.SEOSummary, error) {
owner, err := c.owner(version.SiteID)
if err != nil {
return article.SEOSummary{}, err
}
defaultTag, err := c.defaultTag(version.SiteID)
if err != nil {
return article.SEOSummary{}, err
}
return assessSEO(version, owner, localization.SiteTextAllowed(defaultTag, version.Lang)).Summary(), nil
}
func (c *seoSiteContext) owner(siteID uint32) (site.Site, error) {
if owner, cached := c.owners[siteID]; cached {
return owner, nil
}
owner, err := c.sites.Get(siteID)
if err != nil && !errors.Is(err, site.ErrSiteNotFound) {
return site.Site{}, err
}
c.owners[siteID] = owner
return owner, nil
}
func (c *seoSiteContext) defaultTag(siteID uint32) (string, error) {
if tag, cached := c.defaults[siteID]; cached {
return tag, nil
}
rows, err := c.localization.Rows(siteID)
if err != nil {
return "", err
}
c.defaults[siteID] = localization.ConfirmedDefaultTag(rows)
return c.defaults[siteID], nil
}
@@ -20,7 +20,6 @@ const (
jsonLDArticle = "Article" jsonLDArticle = "Article"
jsonLDPerson = "Person" jsonLDPerson = "Person"
jsonLDWebPage = "WebPage" jsonLDWebPage = "WebPage"
sitePlaceholder = "%site%"
) )
type ResolveArticleSEOUseCase struct { type ResolveArticleSEOUseCase struct {
@@ -69,7 +68,7 @@ func (u *ResolveArticleSEOUseCase) languageOf(art article.Article, owner site.Si
localeAlternates = []string{} localeAlternates = []string{}
} }
return seoLanguage{ return seoLanguage{
defaultTag: confirmedDefault(rows), defaultTag: localization.ConfirmedDefaultTag(rows),
locale: locale, locale: locale,
alternates: alternatesOf(owner, siblings), alternates: alternatesOf(owner, siblings),
localeAlternates: localeAlternates, localeAlternates: localeAlternates,
@@ -129,7 +128,7 @@ func applyTitleTemplate(template string, title string, siteName string) string {
if template == "" { if template == "" {
template = site.DefaultTitleTemplate template = site.DefaultTitleTemplate
} }
replacer := strings.NewReplacer(site.TitlePlaceholder, title, sitePlaceholder, siteName) replacer := strings.NewReplacer(site.TitlePlaceholder, title, site.SitePlaceholder, siteName)
return strings.TrimSpace(replacer.Replace(template)) return strings.TrimSpace(replacer.Replace(template))
} }
@@ -195,7 +194,7 @@ func buildJSONLD(headline string, description string, picked article.SEOImage, a
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage, spoken seoLanguage) article.ResolvedSEO { func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage, spoken seoLanguage) article.ResolvedSEO {
meta, titleSource := metaTitle(art) meta, titleSource := metaTitle(art)
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName) title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
siteTextAllowed := art.Lang == "" || art.Lang == spoken.defaultTag siteTextAllowed := localization.SiteTextAllowed(spoken.defaultTag, art.Lang)
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed) description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed)
siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != "" siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != ""
canonical, canonicalSource := resolveCanonical(art, owner) canonical, canonicalSource := resolveCanonical(art, owner)
@@ -96,7 +96,7 @@ func (u *ResolvePublicArticleUseCase) ByStory(query PublicStoryQuery) (PublicArt
if unsupported { if unsupported {
return PublicArticle{}, article.ErrLanguageNotAvailable return PublicArticle{}, article.ErrLanguageNotAvailable
} }
defaultTag := confirmedDefault(rows) defaultTag := localization.ConfirmedDefaultTag(rows)
if query.Lang == nil { if query.Lang == nil {
requested = defaultTag requested = defaultTag
} }
@@ -1,6 +1,7 @@
package useCases package useCases
import ( import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
@@ -11,10 +12,11 @@ type UpdateSiteUseCase struct {
siteRepository site.Repository siteRepository site.Repository
imageRepository image.Repository imageRepository image.Repository
recorder audit.Recorder recorder audit.Recorder
articleSEO article.SEOIndex
} }
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *UpdateSiteUseCase { func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder, articleSEO article.SEOIndex) *UpdateSiteUseCase {
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder} return &UpdateSiteUseCase{siteRepository, imageRepository, recorder, articleSEO}
} }
func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) { func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) {
@@ -43,6 +45,9 @@ func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Pa
event.Target = siteTarget(saved) event.Target = siteTarget(saved)
event.Changes = changes event.Changes = changes
u.recorder.Record(ctx, event) u.recorder.Record(ctx, event)
if before.DefaultDescription != saved.DefaultDescription {
return saved, u.articleSEO.MarkSiteSEOStale(saved.ID)
}
return saved, nil return saved, nil
} }
@@ -65,11 +65,9 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
if err != nil { if err != nil {
return err return err
} }
if !localization.SameRows(before, after) { if err := replaceLanguages(tx, found.ID, before, after); err != nil {
if err := tx.Localization.Replace(found.ID, after); err != nil {
return err return err
} }
}
view, err = readSiteLanguages(tx.Localization, found) view, err = readSiteLanguages(tx.Localization, found)
return err return err
}) })
@@ -87,19 +85,26 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
return view, nil return view, nil
} }
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change { func replaceLanguages(tx transaction.Repositories, siteID uint32, before []localization.Row, after []localization.Row) error {
changes := []audit.Change{} if localization.SameRows(before, after) {
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, defaultTag(before), defaultTag(after)) return nil
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after)) }
if err := localization.RequireSingleDefault(after); err != nil {
return err
}
if err := tx.Localization.Replace(siteID, after); err != nil {
return err
}
if localization.DefaultTag(before) == localization.DefaultTag(after) {
return nil
}
return tx.ArticleSEO.MarkSiteSEOStale(siteID)
} }
func defaultTag(rows []localization.Row) string { func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
for _, row := range rows { changes := []audit.Change{}
if row.IsDefault { changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, localization.DefaultTag(before), localization.DefaultTag(after))
return row.Tag return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
}
}
return ""
} }
func languagesLabel(rows []localization.Row) string { func languagesLabel(rows []localization.Row) string {
@@ -7,21 +7,14 @@ import (
"slices" "slices"
) )
func confirmedDefault(rows []localization.Row) string {
if localization.NeedsSetup(rows) {
return ""
}
return localization.DefaultTag(rows)
}
func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article { func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article {
defaultTag := confirmedDefault(rows) defaultTag := localization.ConfirmedDefaultTag(rows)
siblings := []article.Article{} siblings := []article.Article{}
for _, version := range versions { for _, version := range versions {
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline || version.Lang == "" { if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline {
continue continue
} }
if localization.EnabledTag(rows, version.Lang) { if localization.PublicTag(rows, version.Lang) {
siblings = append(siblings, version) siblings = append(siblings, version)
} }
} }
+4 -6
View File
@@ -15,8 +15,6 @@ func metaTitle(art article.Article) (string, string) {
return art.Title, article.SEOSourceTitle return art.Title, article.SEOSourceTitle
} }
const withSiteText = true
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) { func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) {
if art.SEODescription != "" { if art.SEODescription != "" {
return art.SEODescription, article.SEOSourceSEODescription return art.SEODescription, article.SEOSourceSEODescription
@@ -37,7 +35,7 @@ func suggestion(code string, field string, params map[string]int) article.SEOSug
return article.SEOSuggestion{Code: code, Field: field, Params: params} return article.SEOSuggestion{Code: code, Field: field, Params: params}
} }
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion { func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) []article.SEOSuggestion {
suggestions := []article.SEOSuggestion{} suggestions := []article.SEOSuggestion{}
title, _ := metaTitle(art) title, _ := metaTitle(art)
titleRunes := utf8.RuneCountInString(title) titleRunes := utf8.RuneCountInString(title)
@@ -47,7 +45,7 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
if strings.TrimSpace(title) == "" { if strings.TrimSpace(title) == "" {
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{})) suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
} }
description, _ := resolveDescription(art, owner, facts, withSiteText) description, _ := resolveDescription(art, owner, facts, siteTextAllowed)
descriptionRunes := utf8.RuneCountInString(description) descriptionRunes := utf8.RuneCountInString(description)
if descriptionRunes < article.SEODescriptionMinRunes { if descriptionRunes < article.SEODescriptionMinRunes {
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes})) suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
@@ -80,10 +78,10 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
return suggestions return suggestions
} }
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment { func assessSEO(art article.Article, owner site.Site, siteTextAllowed bool) article.SEOAssessment {
facts := analyzeBody(art.Body) facts := analyzeBody(art.Body)
kept := []article.SEOSuggestion{} kept := []article.SEOSuggestion{}
for _, found := range rawSuggestions(art, owner, facts) { for _, found := range rawSuggestions(art, owner, facts, siteTextAllowed) {
if !slices.Contains(art.SEODismissed, found.Code) { if !slices.Contains(art.SEODismissed, found.Code) {
kept = append(kept, found) kept = append(kept, found)
} }
+1 -2
View File
@@ -24,10 +24,9 @@ func newCreateSiteWorld(t *testing.T) *storyWorld {
w.sites.seed(createSiteNotes, "Notes", "notes", false) w.sites.seed(createSiteNotes, "Notes", "notes", false)
w.sites.seed(createSiteArchive, "Archive", "archive", false) w.sites.seed(createSiteArchive, "Archive", "archive", false)
w.sites.seed(createSiteFresh, "Fresh", "fresh", false) w.sites.seed(createSiteFresh, "Fresh", "fresh", false)
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil) w.rows.rows[storyAtelier] = storyRows("fr", []string{"en", "fr", "de"}, nil)
w.rows.rows[createSiteNotes] = storyRows("en", []string{"en"}, nil) w.rows.rows[createSiteNotes] = storyRows("en", []string{"en"}, nil)
w.rows.rows[createSiteArchive] = storyRows("en", []string{"en"}, nil) w.rows.rows[createSiteArchive] = storyRows("en", []string{"en"}, nil)
w.transactor.starting = map[uint32]string{storyJournal: "en", storyAtelier: "fr", createSiteNotes: "en", createSiteArchive: "en"}
return w return w
} }
+41 -15
View File
@@ -3,6 +3,7 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site" "RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction" "RenewCMS/internal/domain/transaction"
"context" "context"
@@ -10,35 +11,60 @@ import (
) )
type mockStartingLanguages struct { type mockStartingLanguages struct {
site.Repository
confirmed map[uint32]string confirmed map[uint32]string
locked []uint32 locked []uint32
failWith error failWith error
removed map[uint32]bool removed map[uint32]bool
} }
var _ language.Repository = &mockStartingLanguages{} var _ localization.Repository = &mockStartingLanguages{}
func (m *mockStartingLanguages) ListBySite(siteID uint32) ([]language.SiteLanguage, error) { func (m *mockStartingLanguages) LockSite(siteID uint32) error {
return nil, nil m.locked = append(m.locked, siteID)
if m.failWith != nil {
return m.failWith
}
if m.removed[siteID] {
return site.ErrSiteNotFound
} }
func (m *mockStartingLanguages) MarkConfirmed(siteID uint32, tag string) error {
return nil return nil
} }
func (m *mockStartingLanguages) LockSiteLanguages(siteID uint32) ([]language.SiteLanguage, error) { func (m *mockStartingLanguages) Rows(siteID uint32) ([]localization.Row, error) {
m.locked = append(m.locked, siteID)
if m.failWith != nil {
return nil, m.failWith
}
if m.removed[siteID] {
return nil, site.ErrSiteNotFound
}
tag, confirmed := m.confirmed[siteID] tag, confirmed := m.confirmed[siteID]
if !confirmed || tag == language.Unassigned { if !confirmed || tag == language.Unassigned {
return nil, nil return nil, nil
} }
return []language.SiteLanguage{{SiteID: siteID, Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}, nil return []localization.Row{{Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}}, nil
}
func (m *mockStartingLanguages) ConfirmedSites() (map[uint32]bool, error) {
return map[uint32]bool{}, nil
}
func (m *mockStartingLanguages) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) {
return map[string]localization.VersionCount{}, nil
}
func (m *mockStartingLanguages) Replace(siteID uint32, rows []localization.Row) error {
return nil
}
func (m *mockStartingLanguages) ConfirmSetup(siteID uint32, tag string) error {
return nil
}
type removableSites struct {
*mockSiteRepo
removed map[uint32]bool
}
func (r removableSites) LockSite(siteID uint32) error {
if r.removed[siteID] {
return site.ErrSiteNotFound
}
return r.mockSiteRepo.LockSite(siteID)
} }
type mockCreateTransactor struct { type mockCreateTransactor struct {
@@ -51,7 +77,7 @@ var _ transaction.Transactor = &mockCreateTransactor{}
func (m *mockCreateTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error { func (m *mockCreateTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
m.runs++ m.runs++
return work(transaction.Repositories{Articles: m.articles, Languages: m.languages}) return work(transaction.Repositories{Articles: m.articles, Sites: m.languages, Localization: m.languages})
} }
func passthroughCreateTransactor(articles article.Repository) *mockCreateTransactor { func passthroughCreateTransactor(articles article.Repository) *mockCreateTransactor {
+3
View File
@@ -224,6 +224,9 @@ func TestArticleCreatedByHttp(t *testing.T) {
delete(signedIn, "created_by") delete(signedIn, "created_by")
delete(signedIn, "capabilities") delete(signedIn, "capabilities")
delete(signedIn, "translations") delete(signedIn, "translations")
for _, editorial := range []string{"source_unavailable", "missing_langs", "excluded_from_public", "partial_view"} {
delete(signedIn, editorial)
}
delete(anonymous, "translations") delete(anonymous, "translations")
delete(anonymous, "capabilities") delete(anonymous, "capabilities")
if len(anonymous) != len(signedIn) { if len(anonymous) != len(signedIn) {
+128
View File
@@ -0,0 +1,128 @@
package test
import (
"RenewCMS/internal/domain/article"
"encoding/json"
"net/http"
"testing"
)
func editorialField[T any](t *testing.T, response storyResponse, key string) T {
t.Helper()
var value T
if err := json.Unmarshal(response.fields(t)[key], &value); err != nil {
t.Fatalf("%s: %v in %s", key, err, response.text())
}
return value
}
func translationEntry(t *testing.T, response storyResponse, id string) map[string]json.RawMessage {
t.Helper()
for _, entry := range translationsOf(t, response) {
if string(entry["id"]) == id {
return entry
}
}
t.Fatalf("no translation %s in %s", id, response.text())
return nil
}
func putTrackingDraft(w *storyWorld) {
source := uint32(12)
w.put(article.Article{ID: 14, Title: "Costes", CreatedBy: storyRestrict, Slug: "costes", Lang: "es", StoryID: storyStory, SourceArticleID: &source, SourceRevision: 2})
}
func TestArticleEditorialContextHTTP(t *testing.T) {
t.Run("Missing languages are the enabled languages without a readable version", func(t *testing.T) {
w := newStoryWorld(t)
cases := []struct {
userID uint32
missing []string
}{
{storyOwner, []string{}},
{storyRestrict, []string{"de", "fr"}},
{storyReader, []string{}},
}
for _, c := range cases {
got := editorialField[[]string](t, w.send(c.userID, http.MethodGet, "/v1/article/10", ""), "missing_langs")
if len(got) != len(c.missing) || len(got) > 0 && (got[0] != c.missing[0] || got[1] != c.missing[1]) {
t.Errorf("user %d: missing_langs %v, want %v", c.userID, got, c.missing)
}
}
})
t.Run("A version in a disabled language or without language is left out of public surfaces", func(t *testing.T) {
w := newStoryWorld(t)
putTrackingDraft(w)
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: storyWriter1, Slug: "unassigned", StoryID: 30, IsOnline: true})
owner := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
if !editorialField[bool](t, owner, "excluded_from_public") || string(translationEntry(t, owner, "14")["excluded_from_public"]) != "true" || string(translationEntry(t, owner, "10")["excluded_from_public"]) != "false" {
t.Errorf("es must be left out, en kept: %s", owner.text())
}
if !editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/30", ""), "excluded_from_public") {
t.Error("a version without language must be left out of the public language surfaces")
}
if editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/10", ""), "excluded_from_public") {
t.Error("an enabled language must not be left out")
}
})
t.Run("A deleted source and an unreadable source read the same", func(t *testing.T) {
w := newStoryWorld(t)
putTrackingDraft(w)
tracked := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
unreadable := w.send(storyRestrict, http.MethodGet, "/v1/article/14", "")
delete(w.articles.articles, 12)
deleted := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
if string(tracked.fields(t)["source"]) != `{"article_id":12,"revision":2}` || editorialField[bool](t, tracked, "source_unavailable") {
t.Fatalf("a readable source is shown: %s", tracked.text())
}
for name, response := range map[string]storyResponse{"unreadable": unreadable, "deleted": deleted} {
if string(response.fields(t)["source"]) != "null" || !editorialField[bool](t, response, "source_unavailable") {
t.Errorf("%s source: %s", name, response.text())
}
}
})
t.Run("Translations name the source they track only when it is readable", func(t *testing.T) {
w := newStoryWorld(t)
putTrackingDraft(w)
owner := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
restricted := w.send(storyRestrict, http.MethodGet, "/v1/article/14", "")
if string(translationEntry(t, owner, "14")["source_article_id"]) != "12" || string(translationEntry(t, owner, "10")["source_article_id"]) != "null" {
t.Errorf("owner translations: %s", owner.text())
}
if string(translationEntry(t, restricted, "14")["source_article_id"]) != "null" {
t.Errorf("an unreadable source is named: %s", restricted.text())
}
})
t.Run("Partial view follows the read scope of the caller only", func(t *testing.T) {
w := newStoryWorld(t)
if editorialField[bool](t, w.send(storyOwner, http.MethodGet, "/v1/article/10", ""), "partial_view") {
t.Error("a caller who reads every version has a complete view")
}
if !editorialField[bool](t, w.send(storyRestrict, http.MethodGet, "/v1/article/13", ""), "partial_view") {
t.Error("a caller limited to own versions has a partial view")
}
})
t.Run("Anonymous reads carry no editorial keys", func(t *testing.T) {
w := newStoryWorld(t)
fields := w.send(storyAnon, http.MethodGet, "/v1/article/10", "").fields(t)
for _, key := range []string{"missing_langs", "excluded_from_public", "source_unavailable", "partial_view"} {
if _, present := fields[key]; present {
t.Errorf("anonymous read carries %s", key)
}
}
})
}
+9 -6
View File
@@ -628,11 +628,14 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
if missing.Total != len(wanted) || missing.Counts["missing"] != len(wanted) || all.Counts["missing"] != len(wanted) { if missing.Total != len(wanted) || missing.Counts["missing"] != len(wanted) || all.Counts["missing"] != len(wanted) {
t.Fatalf("total=%d counts=%v, want %d", missing.Total, missing.Counts, len(wanted)) t.Fatalf("total=%d counts=%v, want %d", missing.Total, missing.Counts, len(wanted))
} }
for _, mixed := range []int{31, 33, 35} { for _, mixed := range []int{31, 33} {
if missing.item(mixed) != nil { if missing.item(mixed) == nil {
t.Fatalf("story %d holding an unassigned version listed as needing translation", mixed) t.Fatalf("mixed story %d must be listed as needing translation", mixed)
} }
} }
if missing.item(35) != nil {
t.Fatal("a story with only an unassigned version must never be listed as needing translation")
}
for _, tag := range []string{"fr", "de", "ar", "en"} { for _, tag := range []string{"fr", "de", "ar", "en"} {
_, notIn := env.get(localizationOwnerID, "?group=story&lang="+tag+"&tab=missing&size=100") _, notIn := env.get(localizationOwnerID, "?group=story&lang="+tag+"&tab=missing&size=100")
expected := []int{} expected := []int{}
@@ -655,8 +658,8 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
env := base(t) env := base(t)
env.siteRepo.lists = 0 env.siteRepo.lists = 0
_, body := env.get(localizationOwnerID, "?group=story&size=100") _, body := env.get(localizationOwnerID, "?group=story&size=100")
if len(body.Items) == 0 || env.siteRepo.lists != 1 { if len(body.Items) == 0 || env.siteRepo.lists > 1 {
t.Fatalf("items=%d site lists=%d, want rows and 1", len(body.Items), env.siteRepo.lists) t.Fatalf("items=%d site lists=%d, want rows and at most 1", len(body.Items), env.siteRepo.lists)
} }
}) })
@@ -697,7 +700,7 @@ func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
}) })
t.Run("Repository failure answers 500", func(t *testing.T) { t.Run("Repository failure answers 500", func(t *testing.T) {
for _, method := range []string{"FindStoryPage", "CountStories", "FindPage"} { for _, method := range []string{"FindStoryPage", "CountStories", "ListVersions"} {
env := base(t) env := base(t)
env.repo.failMethod = method env.repo.failMethod = method
rec, _ := env.get(localizationOwnerID, "?group=story") rec, _ := env.get(localizationOwnerID, "?group=story")
@@ -86,6 +86,13 @@ func (r *storyListingRepository) CountStories(filters domainArticle.StoryFilters
return r.ArticleRepository.CountStories(filters) return r.ArticleRepository.CountStories(filters)
} }
func (r *storyListingRepository) ListVersions(ids []uint32) ([]domainArticle.Article, error) {
if err := r.failure("ListVersions"); err != nil {
return nil, err
}
return r.ArticleRepository.ListVersions(ids)
}
type listCountingSites struct { type listCountingSites struct {
*persistence.SiteRepository *persistence.SiteRepository
lists int lists int
+18 -4
View File
@@ -25,6 +25,13 @@ type countingStoryRepository struct {
*persistence.ArticleRepository *persistence.ArticleRepository
titleSearches int titleSearches int
pageLoads int pageLoads int
staleReads int
}
func (r *countingStoryRepository) StaleSEO(afterID uint32, limit int) ([]domainArticle.Article, error) {
stale, err := r.ArticleRepository.StaleSEO(afterID, limit)
r.staleReads += len(stale)
return stale, err
} }
func (r *countingStoryRepository) FindTitleMatches(q string, readable domainArticle.StoryFilters) ([]uint32, error) { func (r *countingStoryRepository) FindTitleMatches(q string, readable domainArticle.StoryFilters) ([]uint32, error) {
@@ -78,7 +85,14 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
site := storageDefaultSite(t, db) site := storageDefaultSite(t, db)
seedSEOScale(t, db, site.ID) seedSEOScale(t, db, site.ID)
repository := &countingStoryRepository{ArticleRepository: persistence.NewArticleRepository(db)} repository := &countingStoryRepository{ArticleRepository: persistence.NewArticleRepository(db)}
list := useCases.NewListArticlesUseCase(repository, &fakeBodyRenderer{}, persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db)) sites, rows := persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db)
if err := useCases.NewRefreshArticleSEOUseCase(repository, sites, rows).RefreshStale(); err != nil {
t.Fatalf("backfill: %v", err)
}
if repository.staleReads != seoScaleVersions {
t.Fatalf("the backfill assessed %d versions, want %d", repository.staleReads, seoScaleVersions)
}
list := useCases.NewListArticlesUseCase(repository, &fakeBodyRenderer{}, sites, rows)
cases := []struct { cases := []struct {
name string name string
@@ -93,7 +107,7 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { t.Run(c.name, func(t *testing.T) {
repository.titleSearches, repository.pageLoads = 0, 0 repository.titleSearches, repository.pageLoads, repository.staleReads = 0, 0, 0
want := expectedSEOScale(c.hidden, c.titled) want := expectedSEOScale(c.hidden, c.titled)
page, err := list.ListStoriesPage(useCases.ListStoriesCommand{Caller: ownerCaller(1), Page: 2, Size: seoScalePageSize, SEO: c.seo, Q: c.q}) page, err := list.ListStoriesPage(useCases.ListStoriesCommand{Caller: ownerCaller(1), Page: 2, Size: seoScalePageSize, SEO: c.seo, Q: c.q})
@@ -116,8 +130,8 @@ func TestArticleStoryListSEOFilterAtScaleSQLite(t *testing.T) {
if c.q != "" { if c.q != "" {
wantSearches = 1 wantSearches = 1
} }
if repository.titleSearches != wantSearches || repository.pageLoads != 1 { if repository.titleSearches != wantSearches || repository.pageLoads != 0 || repository.staleReads != 0 {
t.Errorf("title searches %d, page loads %d per request", repository.titleSearches, repository.pageLoads) t.Errorf("title searches %d, page loads %d, stale versions read %d per request", repository.titleSearches, repository.pageLoads, repository.staleReads)
} }
}) })
} }
@@ -3,7 +3,7 @@ package test
import ( import (
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author" authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language" "RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
@@ -177,7 +177,17 @@ func createTitled(db *gorm.DB, title string) (domainArticle.Article, error) {
} }
func confirmDefaultLanguage(db *gorm.DB, siteID uint32) error { func confirmDefaultLanguage(db *gorm.DB, siteID uint32) error {
return useCases.NewConfirmDefaultLanguageUseCase(persistence.NewTransactor(db)).Confirm(context.Background(), siteID) var found entity.Site
if err := db.First(&found, siteID).Error; err != nil {
return err
}
var seeded entity.SiteLanguage
if err := db.Where("site_id = ? AND is_default = ?", siteID, true).First(&seeded).Error; err != nil {
return err
}
setup := useCases.NewApplyLanguageSetupUseCase(persistence.NewTransactor(db), discardRecorder)
_, err := setup.Apply(context.Background(), []localization.SetupChoice{{Key: found.Key, Lang: seeded.Lang}})
return err
} }
func newStorageArticle(siteID uint32, lang string, slug string, storyID uint32) domainArticle.Article { func newStorageArticle(siteID uint32, lang string, slug string, storyID uint32) domainArticle.Article {
@@ -219,7 +229,7 @@ func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB)
migrateStorage(t, db) migrateStorage(t, db)
languages, err := persistence.NewSiteLanguageRepository(db).ListBySite(site.ID) languages, err := persistence.NewLocalizationRepository(db).Rows(site.ID)
if err != nil || len(languages) != 1 || languages[0].Tag != "fr" || languages[0].Confirmed { 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.Errorf("expected one unconfirmed fr row, got %+v %v", languages, err)
} }
@@ -560,17 +570,6 @@ func runArticleLocalizationStorage(t *testing.T, open func(*testing.T) *gorm.DB)
} }
}) })
t.Run("Confirming a site without a default language is refused", func(t *testing.T) {
db := open(t)
site := storageDefaultSite(t, db)
err := confirmDefaultLanguage(db, 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) { t.Run("Unique indexes replace the per-site slug index", func(t *testing.T) {
db := open(t) db := open(t)
for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} { for _, name := range []string{storyLanguageIndexName, siteSlugIndexName} {
+200
View File
@@ -0,0 +1,200 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"strings"
"testing"
"gorm.io/gorm"
)
type seoIndexEnv struct {
t *testing.T
db *gorm.DB
articles *persistence.ArticleRepository
sites *persistence.SiteRepository
rows *persistence.LocalizationRepository
refresh *useCases.RefreshArticleSEOUseCase
siteID uint32
}
func newSEOIndexEnv(t *testing.T, open func(*testing.T) *gorm.DB) *seoIndexEnv {
t.Helper()
db := open(t)
e := &seoIndexEnv{t: t, db: db, articles: persistence.NewArticleRepository(db), sites: persistence.NewSiteRepository(db), rows: persistence.NewLocalizationRepository(db)}
e.refresh = useCases.NewRefreshArticleSEOUseCase(e.articles, e.sites, e.rows)
e.siteID = storageDefaultSite(t, db).ID
e.must(db.Where("site_id = ?", e.siteID).Delete(&entity.SiteLanguage{}).Error)
e.must(e.rows.Replace(e.siteID, []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}))
return e
}
func (e *seoIndexEnv) must(err error) {
e.t.Helper()
if err != nil {
e.t.Fatal(err)
}
}
func (e *seoIndexEnv) create(lang string, storyID uint32) domainArticle.Article {
e.t.Helper()
created, err := e.articles.Create(domainArticle.Article{Title: "Harbour " + lang, SiteID: e.siteID, Lang: lang, Slug: uniqueTestSlug(), StoryID: storyID, SEODismissed: []string{}})
e.must(err)
return created
}
func (e *seoIndexEnv) status(id uint32) string {
e.t.Helper()
var statuses []string
e.must(e.db.Model(&entity.Article{}).Where("id = ?", id).Pluck("seo_status", &statuses).Error)
return statuses[0]
}
func (e *seoIndexEnv) expectRecomputed(t *testing.T, name string, id uint32) {
t.Helper()
if e.status(id) != "" {
t.Fatalf("%s left the stored status in place", name)
}
e.must(e.refresh.RefreshStale())
if e.status(id) == "" {
t.Fatalf("%s: the refresh did not store a status", name)
}
}
func (e *seoIndexEnv) suggestions(id uint32) int {
e.t.Helper()
page, err := useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, e.sites, e.rows).ListArticlesPage(useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 100})
e.must(err)
return page.SEO[id].Suggestions
}
func runArticleSEOIndex(t *testing.T, open func(*testing.T) *gorm.DB) {
t.Run("Every write to an SEO input clears the stored status until it is recomputed", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
created := e.create("en", 0)
unassigned := e.create("", 0)
image := entity.Image{Path: "/static/uploadedImages/seo-index.png", Filename: "seo-index.png"}
e.must(e.db.Create(&image).Error)
other, err := e.sites.Create(site.Site{Name: "Atelier", Key: "atelier", BaseURL: "https://atelier.example"})
e.must(err)
credited, err := persistence.NewAuthorRepository(e.db).Create(author.Author{Name: "Noor", Slug: "noor"})
e.must(err)
e.expectRecomputed(t, "create", created.ID)
title, body := "Harbour light", "<p>Body.</p>"
cover := domainArticle.Nullable[uint32]{Set: true, Value: &image.ID}
writes := []struct {
name string
id uint32
write func() error
}{
{"a title edit", created.ID, func() error { _, err := e.articles.Update(created.ID, domainArticle.Update{Title: &title}); return err }},
{"a body edit", created.ID, func() error { _, err := e.articles.UpdateBody(created.ID, body); return err }},
{"a new cover", created.ID, func() error {
_, err := e.articles.Update(created.ID, domainArticle.Update{CoverImageID: cover})
return err
}},
{"a deleted cover", created.ID, func() error { _, err := e.articles.ClearImage(image.ID); return err }},
{"a restored cover", created.ID, func() error {
return e.articles.RestoreImage(domainArticle.ImageReferences{Cover: []uint32{created.ID}}, image.ID)
}},
{"a merged cover", created.ID, func() error { _, err := e.articles.RepointImage([]uint32{image.ID}, image.ID); return err }},
{"a credited author", created.ID, func() error {
ids := []uint32{credited.ID}
_, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &ids})
return err
}},
{"a deleted author", created.ID, func() error { return persistence.NewAuthorRepository(e.db).DeleteUncrediting(credited.ID) }},
{"a site description or default language change", created.ID, func() error { return e.articles.MarkSiteSEOStale(e.siteID) }},
{"a language assignment", unassigned.ID, func() error { _, err := e.articles.AssignUnassignedLanguage(e.siteID, "fr"); return err }},
{"a move to another site", created.ID, func() error { return e.articles.MoveToSite([]uint32{created.ID}, other.ID) }},
}
for _, w := range writes {
e.must(e.refresh.RefreshStale())
e.must(w.write())
e.expectRecomputed(t, w.name, w.id)
}
})
t.Run("A status computed before a concurrent write is not stored", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
created := e.create("en", 0)
stale, err := e.articles.StaleSEO(0, 10)
e.must(err)
_, err = e.articles.UpdateBody(created.ID, "<p>Changed.</p>")
e.must(err)
e.must(e.articles.StoreSEO([]domainArticle.SEORecord{{ArticleID: created.ID, Version: stale[0].SEOVersion, Summary: domainArticle.SEOSummary{Status: domainArticle.SEOStatusReady}}}))
if e.status(created.ID) != "" {
t.Fatal("a status read before the write was stored over it")
}
})
t.Run("The site description counts for the default language only and follows a default change", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
english := e.create("en", 0)
french := e.create("fr", english.StoryID)
description := strings.Repeat("Renovation notes. ", 6)
update := useCases.NewUpdateSiteUseCase(e.sites, persistence.NewImageRepository(e.db), discardRecorder, e.articles)
_, err := update.Update(context.Background(), e.siteID, site.Patch{DefaultDescription: &description})
e.must(err)
if e.suggestions(french.ID) != e.suggestions(english.ID)+1 {
t.Fatalf("site text: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
}
current, err := e.rows.Rows(e.siteID)
e.must(err)
languages := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(e.db), discardRecorder)
_, err = languages.Update(context.Background(), useCases.UpdateSiteLanguagesCommand{Key: storageDefaultSite(t, e.db).Key, Revision: localization.Revision(current), Default: "fr", Languages: []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true}}})
e.must(err)
if e.suggestions(english.ID) != e.suggestions(french.ID)+1 {
t.Fatalf("after the default change: en %d, fr %d suggestions", e.suggestions(english.ID), e.suggestions(french.ID))
}
})
t.Run("Deleting an article removes every version and the article row", func(t *testing.T) {
e := newSEOIndexEnv(t, open)
english := e.create("en", 0)
french := e.create("fr", english.StoryID)
e.must(e.articles.DeleteStory(english.StoryID))
var live, stories int64
e.must(e.db.Model(&entity.Article{}).Where("story_id = ?", english.StoryID).Count(&live).Error)
e.must(e.db.Model(&entity.Story{}).Where("id = ?", english.StoryID).Count(&stories).Error)
if live != 0 || stories != 0 {
t.Fatalf("live versions %d, story rows %d", live, stories)
}
for _, version := range []domainArticle.Article{english, french} {
taken, err := e.articles.SlugTaken(e.siteID, version.Lang, version.Slug, 0)
e.must(err)
if taken {
t.Errorf("the slug of %s is still taken", version.Lang)
}
}
if err := e.articles.DeleteStory(english.StoryID); err == nil {
t.Error("deleting a deleted article must answer not found")
}
})
}
func TestArticleSEOIndexSQLite(t *testing.T) {
runArticleSEOIndex(t, openContractSQLite)
}
func TestArticleSEOIndexMySQL(t *testing.T) {
runArticleSEOIndex(t, openContractMySQL)
}
func TestArticleSEOIndexPostgres(t *testing.T) {
runArticleSEOIndex(t, openContractPostgres)
}
+148
View File
@@ -0,0 +1,148 @@
package test
import (
"RenewCMS/internal/domain/article"
"reflect"
"slices"
)
var _ article.ListingRepository = &mockArticleRepo{}
type assessedVersion struct {
inputs article.Article
credits []uint32
}
func seoInputsOf(version article.Article) article.Article {
version.SEO = article.SEOSummary{}
version.SEOVersion = 0
version.UpdatedAt = version.UpdatedAt.UTC()
return version
}
func (m *mockArticleRepo) seoFresh(version article.Article) bool {
assessed, found := m.assessed[version.ID]
return found && !version.SEO.Stale() && reflect.DeepEqual(assessed.inputs, seoInputsOf(version)) && slices.Equal(assessed.credits, m.credits[version.ID])
}
func (m *mockArticleRepo) StaleSEO(afterID uint32, limit int) ([]article.Article, error) {
if err := m.fail.trigger("StaleSEO"); err != nil {
return nil, err
}
stale := []article.Article{}
for _, id := range m.sortedIds() {
version := m.articles[id]
if id <= afterID || m.seoFresh(version) {
continue
}
version.SEO = article.SEOSummary{}
stale = append(stale, m.withAuthors(version))
if len(stale) == limit {
break
}
}
return stale, nil
}
func (m *mockArticleRepo) StoreSEO(records []article.SEORecord) error {
if err := m.fail.trigger("StoreSEO"); err != nil {
return err
}
if m.assessed == nil {
m.assessed = map[uint32]assessedVersion{}
}
for _, record := range records {
version, found := m.articles[record.ArticleID]
if !found {
continue
}
version.SEO = record.Summary
m.articles[record.ArticleID] = version
m.assessed[record.ArticleID] = assessedVersion{inputs: seoInputsOf(version), credits: slices.Clone(m.credits[record.ArticleID])}
}
return nil
}
func (m *mockArticleRepo) MarkSiteSEOStale(siteID uint32) error {
for id, version := range m.articles {
if version.SiteID == siteID {
delete(m.assessed, id)
}
}
return nil
}
func (m *mockArticleRepo) CountSuggestions(filters article.Filters) (int, error) {
if err := m.fail.trigger("CountSuggestions"); err != nil {
return 0, err
}
total := 0
for _, version := range m.articles {
if mockVisible(version, filters.IsOnline != nil && *filters.IsOnline, filters.VisibleTo) && version.SEO.Status == article.SEOStatusSuggestions {
total++
}
}
return total, nil
}
func (m *mockArticleRepo) CountSuggestedStories(readable article.StoryFilters) (int, error) {
m.listCalls++
if err := m.fail.trigger("CountSuggestedStories"); err != nil {
return 0, err
}
stories := map[uint32]bool{}
standalone := 0
for _, version := range m.articles {
if !mockVisible(version, readable.OnlyOnline, readable.VisibleTo) || version.SEO.Status != article.SEOStatusSuggestions {
continue
}
if version.StoryID == 0 {
standalone++
continue
}
stories[version.StoryID] = true
}
return len(stories) + standalone, nil
}
func mockVisible(version article.Article, onlineOnly bool, visibleTo *uint32) bool {
if version.IsOnline {
return true
}
if onlineOnly {
return false
}
return visibleTo == nil || version.CreatedBy == *visibleTo
}
func (m *mockArticleRepo) ListVersions(ids []uint32) ([]article.Article, error) {
m.listCalls++
if err := m.fail.trigger("ListVersions"); err != nil {
return nil, err
}
found := []article.Article{}
for _, id := range ids {
if version, ok := m.articles[id]; ok {
found = append(found, m.withAuthors(version))
}
}
return found, nil
}
func (m *mockArticleRepo) DeleteStory(storyID uint32) error {
if err := m.fail.trigger("DeleteStory"); err != nil {
return err
}
deleted := 0
for id, version := range m.articles {
if version.StoryID == storyID {
delete(m.articles, id)
delete(m.credits, id)
deleted++
}
}
if deleted == 0 {
return article.ErrArticleNotFound
}
return nil
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -107,7 +108,7 @@ func TestArticleStoryLifecycleHTTP(t *testing.T) {
} }
} }
} }
if strings.Contains(response.text(), "Kosten") || strings.Contains(response.text(), `"de"`) { if strings.Contains(response.text(), "Kosten") || slices.Contains(languagesIn(translations), "de") {
t.Errorf("the hidden version leaks: %s", response.text()) t.Errorf("the hidden version leaks: %s", response.text())
} }
}) })
+9 -4
View File
@@ -38,6 +38,7 @@ type mockArticleRepo struct {
onGet func(id uint32) error onGet func(id uint32) error
reverseStory bool reverseStory bool
listCalls int listCalls int
assessed map[uint32]assessedVersion
} }
func newMockArticleRepo() *mockArticleRepo { func newMockArticleRepo() *mockArticleRepo {
@@ -126,7 +127,10 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
if filters.Status == article.StatusDraft && a.IsOnline { if filters.Status == article.StatusDraft && a.IsOnline {
continue continue
} }
if filters.Lang != nil && a.Lang != *filters.Lang { if filters.Lang != "" && a.Lang != filters.Lang {
continue
}
if filters.SEOStatus != "" && a.SEO.Status != filters.SEOStatus {
continue continue
} }
if filters.Langs != nil && !slices.Contains(*filters.Langs, a.Lang) { if filters.Langs != nil && !slices.Contains(*filters.Langs, a.Lang) {
@@ -141,8 +145,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
matching = append(matching, a) matching = append(matching, a)
} }
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) }) sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
start := min((filters.Page-1)*filters.Size, len(matching)) offset, limit := filters.Window()
end := min(start+filters.Size, len(matching)) start := min(offset, len(matching))
end := start + min(limit, len(matching)-start)
if filters.Unpaged { if filters.Unpaged {
start, end = 0, len(matching) start, end = 0, len(matching)
} }
@@ -176,7 +181,7 @@ func (m *mockArticleRepo) CountVersions(filters article.Filters) (article.Counts
} }
counts := article.Counts{} counts := article.Counts{}
for _, a := range m.articles { for _, a := range m.articles {
if filters.Lang != nil && a.Lang != *filters.Lang { if filters.Lang != "" && a.Lang != filters.Lang {
continue continue
} }
if a.IsOnline { if a.IsOnline {
@@ -96,7 +96,7 @@ func holdSiteLock(t *testing.T, db *gorm.DB, siteID uint32, then func(transactio
finished := make(chan error, 1) finished := make(chan error, 1)
go func() { go func() {
finished <- persistence.NewTransactor(db).Run(context.Background(), func(tx transaction.Repositories) error { finished <- persistence.NewTransactor(db).Run(context.Background(), func(tx transaction.Repositories) error {
if _, err := tx.Languages.LockSiteLanguages(siteID); err != nil { if err := tx.Sites.LockSite(siteID); err != nil {
return err return err
} }
close(held) close(held)
+105
View File
@@ -0,0 +1,105 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"math"
"slices"
"strings"
"testing"
)
func TestLocalizationDomainRules(t *testing.T) {
t.Run("A site has exactly one default language", func(t *testing.T) {
cases := []struct {
name string
rows []localization.Row
err error
}{
{"none", []localization.Row{{Tag: "en"}}, localization.ErrSingleDefault},
{"one", []localization.Row{{Tag: "en", IsDefault: true}, {Tag: "fr"}}, nil},
{"two", []localization.Row{{Tag: "en", IsDefault: true}, {Tag: "fr", IsDefault: true}}, localization.ErrSingleDefault},
}
for _, c := range cases {
if err := localization.RequireSingleDefault(c.rows); !errors.Is(err, c.err) {
t.Errorf("%s: %v, want %v", c.name, err, c.err)
}
}
})
t.Run("Only an enabled assigned language takes part in the public surfaces", func(t *testing.T) {
rows := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "es"}}
if !localization.PublicTag(rows, "en") || localization.PublicTag(rows, "es") || localization.PublicTag(rows, "") || localization.PublicTag(rows, "de") {
t.Error("public languages are the enabled rows only")
}
})
t.Run("The site text belongs to the default language and to unassigned versions", func(t *testing.T) {
if !localization.SiteTextAllowed("en", "en") || !localization.SiteTextAllowed("en", "") || localization.SiteTextAllowed("en", "fr") {
t.Error("site text rule")
}
if localization.ConfirmedDefaultTag([]localization.Row{{Tag: "en", IsDefault: true}}) != "" {
t.Error("an unconfirmed default is no default")
}
})
t.Run("The title template placeholders have one source", func(t *testing.T) {
if site.DefaultTitleTemplate != site.TitlePlaceholder+" | "+site.SitePlaceholder {
t.Errorf("default template %q", site.DefaultTitleTemplate)
}
})
t.Run("A page window never overflows", func(t *testing.T) {
cases := []struct {
page, size, offset, limit int
}{
{1, 25, 0, 25},
{3, 5, 10, 5},
{0, 5, 0, 5},
{math.MinInt, 5, 0, 5},
{math.MaxInt, 25, math.MaxInt, 25},
{2, -1, 0, 0},
}
for _, c := range cases {
offset, limit := article.Filters{Page: c.page, Size: c.size}.Window()
if offset != c.offset || limit != c.limit {
t.Errorf("page %d size %d: %d %d", c.page, c.size, offset, limit)
}
}
})
}
func TestCheckArticleSEOIsLanguageAware(t *testing.T) {
repo := newMockArticleRepo()
sites := newMockSiteRepo(repo)
journal := sites.seed(1, "Journal", "journal", true)
journal.DefaultDescription = strings.Repeat("Renovation notes. ", 6)
sites.sites[1] = journal
rows := newFakeLocalizationStore(repo)
rows.rows[1] = []localization.Row{{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "fr", Enabled: true}}
repo.articles[1] = article.Article{ID: 1, Title: "Harbour", SiteID: 1, Lang: "en", SEODismissed: []string{}}
repo.articles[2] = article.Article{ID: 2, Title: "Port", SiteID: 1, Lang: "fr", SEODismissed: []string{}}
check := useCases.NewCheckArticleSEOUseCase(repo, sites, newMockImageRepo(), rows)
codes := func(id uint32) []string {
result, err := check.Check(id, ownerCaller(1), useCases.CheckArticleSEOCommand{})
if err != nil {
t.Fatal(err)
}
found := []string{}
for _, suggestion := range result.Suggestions {
found = append(found, suggestion.Code)
}
return found
}
if slices.Contains(codes(1), article.DismissDescShort) {
t.Error("the default language uses the site description")
}
if !slices.Contains(codes(2), article.DismissDescShort) {
t.Error("another language never uses the site description")
}
}
+2 -2
View File
@@ -118,7 +118,7 @@ var _ transaction.Transactor = &rollbackLocalizationTransactor{}
func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error { func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
r.runs++ r.runs++
rows, articles := r.store.snapshot() rows, articles := r.store.snapshot()
err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store}) err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store, ArticleSEO: r.store.articles})
if err != nil { if err != nil {
r.store.rows = rows r.store.rows = rows
r.store.articles.articles = articles r.store.articles.articles = articles
@@ -156,7 +156,7 @@ func newLocalizationEnv(t *testing.T) *localizationEnv {
transactor := &rollbackLocalizationTransactor{store: store, sites: sites} transactor := &rollbackLocalizationTransactor{store: store, sites: sites}
c := e.container c := e.container
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites) c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder) c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder, e.articles)
c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store) c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store)
c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder) c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder)
c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store) c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store)
+44
View File
@@ -0,0 +1,44 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"net/http/httptest"
"testing"
)
const publicReadBudget = 120
func (e *locPublicEnv) getFrom(remoteAddr string, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func TestPublicReadRateLimit(t *testing.T) {
t.Run("The public article routes and the sitemap share one budget per client", func(t *testing.T) {
e := newLocPublicEnv(t)
client := "198.51.100.7:4000"
for index := 0; index < publicReadBudget; index++ {
path := locFrBase
if index%2 == 0 {
path = "/v1/site/journal/sitemap"
}
if rec := e.getFrom(client, path); rec.Code != http.StatusOK {
t.Fatalf("request %d: %d %s", index, rec.Code, rec.Body.String())
}
}
for _, path := range []string{locFrBase, "/v1/site/journal/sitemap", "/v1/public/site/journal/story/5"} {
rec := e.getFrom(client, path)
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
}
}
if rec := e.getFrom("198.51.100.8:4000", locFrBase); rec.Code != http.StatusOK {
t.Errorf("another client is limited: %d", rec.Code)
}
})
}
+1 -1
View File
@@ -323,7 +323,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) { t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) {
repo, sites, _, images := newRepos() repo, sites, _, images := newRepos()
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images) check := useCases.NewCheckArticleSEOUseCase(repo, sites, images, plainLocalization())
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images, plainLocalization()) resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images, plainLocalization())
reader := callerWith(22, "content.read=own") reader := callerWith(22, "content.read=own")
if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil { if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil {
+1 -1
View File
@@ -218,7 +218,7 @@ func (w *contentScopeWorld) wire() {
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo) c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock) c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization()) c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images) c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock) c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock) c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images) c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
+1 -1
View File
@@ -68,7 +68,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo()) c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites) c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder) c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder) c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder) c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder) c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors) c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
+7 -1
View File
@@ -38,7 +38,13 @@ func TestSeoFilterPerformance(t *testing.T) {
if err := db.CreateInBatches(&rows, 100).Error; err != nil { if err := db.CreateInBatches(&rows, 100).Error; err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
} }
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db), plainLocalization()) articles, sites := persistence.NewArticleRepository(db), persistence.NewSiteRepository(db)
backfillStarted := time.Now()
if err := useCases.NewRefreshArticleSEOUseCase(articles, sites, plainLocalization()).RefreshStale(); err != nil {
t.Fatalf("backfill: %v", err)
}
t.Logf("startup backfill of %d articles: %d ms", seoPerformanceArticles, time.Since(backfillStarted).Milliseconds())
list := useCases.NewListArticlesUseCase(articles, &fakeBodyRenderer{}, sites, plainLocalization())
filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()} filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()} unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()}
+1 -1
View File
@@ -130,7 +130,7 @@ func newSeoOutEnv(t *testing.T) *seoOutEnv {
e.seedBanner(seoOutSiteImage, "site.png", "hash-site") e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
c := e.container c := e.container
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, plainLocalization()) c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images) c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites, plainLocalization()) c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites, plainLocalization())
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization()) c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
router := chi.NewRouter() router := chi.NewRouter()
+1 -1
View File
@@ -153,7 +153,7 @@ func TestSiteUseCaseRules(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
sites := newMockSiteRepo(repo) sites := newMockSiteRepo(repo)
sites.seed(2, "Shop", "shop", false) sites.seed(2, "Shop", "shop", false)
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder) uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder, repo)
flag := true flag := true
_, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag}) _, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag})
+1 -1
View File
@@ -338,7 +338,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors()) c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites) c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder) c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder) c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder, p.articles)
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder) c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder) c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
router := chi.NewRouter() router := chi.NewRouter()
+2 -2
View File
@@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed { if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema) t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
} }
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} { for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "idx_articles_seo_status", "users", "idx_users_role_id", "invites", "idx_invites_role_id"} {
delete(before.schema, name) delete(before.schema, name)
delete(after.schema, name) delete(after.schema, name)
} }
@@ -218,7 +218,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"} var addedUserEmailChangeColumns = []string{"pending_email", "email_change_code", "email_change_expiration"}
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed", "lang", "story_id", "content_revision", "edit_revision", "source_article_id", "source_revision", "retired_id"} 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", "edit_revision", "source_article_id", "source_revision", "retired_id", "seo_status", "seo_suggestions", "seo_version"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) { func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper() t.Helper()
+98
View File
@@ -0,0 +1,98 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"net/http"
"slices"
"testing"
)
const storyDeletePath = "/v1/story/5"
func (w *storyWorld) storyVersionIDs() []uint32 {
ids := []uint32{}
for id, version := range w.articles.articles {
if version.StoryID == storyStory {
ids = append(ids, id)
}
}
slices.Sort(ids)
return ids
}
func TestStoryDeleteHTTP(t *testing.T) {
t.Run("Deleting an article removes every version and the article", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusNoContent, "")
if remaining := w.storyVersionIDs(); len(remaining) != 0 {
t.Fatalf("versions left: %v", remaining)
}
w.send(storyOwner, http.MethodGet, "/v1/article/10", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
deletes := 0
for _, event := range w.recorder.events {
if event.Action == audit.ActionArticleDelete && event.Params[audit.ParamStoryID] == storyStory {
deletes++
}
}
if deletes != 4 {
t.Errorf("expected one article.delete per version, got %d in %v", deletes, w.recorder.actions())
}
})
t.Run("Versions the caller cannot read are deleted with the article", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyDeleter, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusNoContent, "")
if remaining := w.storyVersionIDs(); len(remaining) != 0 {
t.Fatalf("hidden versions left: %v", remaining)
}
})
t.Run("Deleting an article needs delete permission on every version", func(t *testing.T) {
w := newStoryWorld(t)
before := w.storyVersionIDs()
response := w.send(storyWriter1, http.MethodDelete, storyDeletePath, "")
response.expect(t, http.StatusForbidden, apperr.MissingPermissionErrorMessage)
if response.header(apperr.MissingPermissionHeader) != "content.delete" || !slices.Equal(w.storyVersionIDs(), before) {
t.Fatalf("header %q, versions %v", response.header(apperr.MissingPermissionHeader), w.storyVersionIDs())
}
})
t.Run("A failed delete removes nothing", func(t *testing.T) {
w := newStoryWorld(t)
before := w.storyVersionIDs()
w.articles.fail.failNext("DeleteStory")
w.send(storyOwner, http.MethodDelete, storyDeletePath, "").expect(t, http.StatusInternalServerError, apperr.DeleteArticleFailedErrorMessage)
if !slices.Equal(w.storyVersionIDs(), before) {
t.Fatalf("versions changed: %v", w.storyVersionIDs())
}
})
t.Run("An unknown or unreadable article answers the same not found", func(t *testing.T) {
w := newStoryWorld(t)
w.put(w.stored(12))
hidden := w.stored(11)
hidden.StoryID = 77
w.put(hidden)
w.send(storyOwner, http.MethodDelete, "/v1/story/9999", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
w.send(storyDeleter, http.MethodDelete, "/v1/story/77", "").expect(t, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
if _, kept := w.articles.articles[11]; !kept {
t.Fatal("an unreadable article must not be deleted")
}
})
t.Run("An invalid id is refused", func(t *testing.T) {
w := newStoryWorld(t)
w.send(storyOwner, http.MethodDelete, "/v1/story/abc", "").expect(t, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
}
+4 -6
View File
@@ -39,6 +39,7 @@ const (
storyPublish uint32 = 55 storyPublish uint32 = 55
storyNobody uint32 = 56 storyNobody uint32 = 56
storyPartial uint32 = 57 storyPartial uint32 = 57
storyDeleter uint32 = 58
storyAnon uint32 = 0 storyAnon uint32 = 0
storyStory uint32 = 5 storyStory uint32 = 5
) )
@@ -62,7 +63,6 @@ type storyTransactor struct {
world *storyWorld world *storyWorld
runs int runs int
external map[uint32]article.Article external map[uint32]article.Article
starting map[uint32]string
removed map[uint32]bool removed map[uint32]bool
} }
@@ -74,11 +74,7 @@ func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Reposit
articles := maps.Clone(w.articles.articles) articles := maps.Clone(w.articles.articles)
credits := maps.Clone(w.articles.credits) credits := maps.Clone(w.articles.credits)
rows, _ := w.rows.snapshot() rows, _ := w.rows.snapshot()
starting := map[uint32]string{storyJournal: "en", storyAtelier: "en"} err := work(transaction.Repositories{Articles: w.articles, Sites: removableSites{mockSiteRepo: w.sites, removed: s.removed}, Authors: w.authors, Localization: w.rows, ArticleSEO: w.articles})
if s.starting != nil {
starting = s.starting
}
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: starting, removed: s.removed}})
if err != nil { if err != nil {
w.articles.articles = articles w.articles.articles = articles
w.articles.credits = credits w.articles.credits = credits
@@ -136,6 +132,7 @@ func (w *storyWorld) seedUsers() {
storyPublish: {"content.publish=all", "content.read=all"}, storyPublish: {"content.publish=all", "content.read=all"},
storyNobody: {"schema.view=all"}, storyNobody: {"schema.view=all"},
storyPartial: {"content.update=all", "content.read=own"}, storyPartial: {"content.update=all", "content.read=own"},
storyDeleter: {"content.delete=all", "content.read=own"},
} }
for id, grants := range custom { for id, grants := range custom {
created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)}) created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)})
@@ -204,6 +201,7 @@ func (w *storyWorld) wire() {
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder), CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows), DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows),
MoveStoryUseCase: moveStory, MoveStoryUseCase: moveStory,
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(w.transactor, w.articles, w.recorder),
} }
router := chi.NewRouter() router := chi.NewRouter()
router.Use(authtoken.Verifier(w.token)) router.Use(authtoken.Verifier(w.token))