feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5360
-209
No files matched your search
+23
-10
@@ -8,6 +8,7 @@ import (
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/auditing"
|
||||
@@ -47,6 +48,11 @@ type UseCases struct {
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
UpdateArticleUseCase *useCases.UpdateArticleUseCase
|
||||
DeleteArticleUseCase *useCases.DeleteArticleUseCase
|
||||
ListSitesUseCase *useCases.ListSitesUseCase
|
||||
CreateSiteUseCase *useCases.CreateSiteUseCase
|
||||
UpdateSiteUseCase *useCases.UpdateSiteUseCase
|
||||
SetDefaultSiteUseCase *useCases.SetDefaultSiteUseCase
|
||||
DeleteSiteUseCase *useCases.DeleteSiteUseCase
|
||||
GetUserUseCase *useCases.GetUserUseCase
|
||||
CreateUserUseCase *useCases.CreateUserUseCase
|
||||
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
|
||||
@@ -123,6 +129,7 @@ func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
|
||||
type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
SiteRepo domainSite.Repository
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
VariantRepo domainImage.VariantRepository
|
||||
@@ -241,7 +248,7 @@ func openDatabase(settings config.Settings) *database.Connection {
|
||||
if err != nil {
|
||||
panic("Unable to open the database: " + err.Error())
|
||||
}
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, settings.PublicURL); err != nil {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
return connection
|
||||
@@ -261,6 +268,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
variantFiles := persistence.NewVariantFileRepository(db)
|
||||
return &Repositories{
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
SiteRepo: persistence.NewSiteRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
VariantRepo: persistence.NewVariantRepository(db),
|
||||
@@ -293,9 +301,9 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo)
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo)
|
||||
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
|
||||
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
|
||||
|
||||
@@ -306,11 +314,16 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
||||
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
|
||||
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
|
||||
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
|
||||
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
|
||||
@@ -329,14 +342,14 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo),
|
||||
RunPurgeUseCase: runPurge,
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
|
||||
@@ -348,7 +361,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now, repos.SiteRepo),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
|
||||
|
||||
@@ -29,7 +29,7 @@ type Handler struct {
|
||||
type filterQuery struct {
|
||||
Q string `validate:"max=200"`
|
||||
Actor string `validate:"max=40"`
|
||||
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
|
||||
Category string `validate:"omitempty,oneof=articles media users api_keys auth system sites"`
|
||||
Outcome string `validate:"omitempty,oneof=all success failed"`
|
||||
Security string `validate:"omitempty,oneof=true"`
|
||||
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -50,6 +52,7 @@ type articleResponse struct {
|
||||
Body json.RawMessage `json:"body"`
|
||||
BodyFormat domain.BodyFormat `json:"body_format"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -69,6 +72,7 @@ func toArticleResponse(article domain.Article, format domain.BodyFormat) (articl
|
||||
Body: body,
|
||||
BodyFormat: format,
|
||||
IsOnline: article.IsOnline,
|
||||
SiteID: article.SiteID,
|
||||
CreatedAt: article.CreatedAt,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
}, nil
|
||||
@@ -173,6 +177,10 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) {
|
||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -213,7 +221,40 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
|
||||
}
|
||||
|
||||
type UpdateArticleBody struct {
|
||||
Body string `json:"body"`
|
||||
Body *string `json:"body"`
|
||||
SiteID *uint32 `json:"site_id" validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
var updatableArticleFields = []string{"body", "site_id"}
|
||||
|
||||
func readUpdateArticleBody(r *http.Request) (UpdateArticleBody, bool) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known := 0
|
||||
for _, name := range updatableArticleFields {
|
||||
value, present := fields[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if string(value) == "null" {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
known++
|
||||
}
|
||||
if known == 0 {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
var request UpdateArticleBody
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
return UpdateArticleBody{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -228,18 +269,22 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var localArticle UpdateArticleBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
|
||||
request, ok := readUpdateArticleBody(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.Validate.Struct(localArticle); err != nil {
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
|
||||
updatedArticle, err := h.UpdateUseCase.Update(r.Context(), id, useCases.UpdateArticleCommand{
|
||||
Body: request.Body,
|
||||
SiteID: request.SiteID,
|
||||
Format: format,
|
||||
})
|
||||
if err != nil {
|
||||
writeArticleErrorResponse(w, err)
|
||||
return
|
||||
@@ -280,6 +325,7 @@ type listQuery struct {
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
Q string `validate:"max=200"`
|
||||
Status string `validate:"omitempty,oneof=online draft"`
|
||||
Site *string `validate:"omitempty,min=1"`
|
||||
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
|
||||
}
|
||||
|
||||
@@ -322,7 +368,15 @@ func readListQuery(values url.Values) (listQuery, error) {
|
||||
if values.Has("sort") {
|
||||
sortValue = values.Get("sort")
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue}, nil
|
||||
var siteKey *string
|
||||
if values.Has("site") {
|
||||
key := values.Get("site")
|
||||
if key == "" {
|
||||
return listQuery{}, errors.New("empty site")
|
||||
}
|
||||
siteKey = &key
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -345,6 +399,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
Q: query.Q,
|
||||
Status: query.Status,
|
||||
Sort: parseSort(query.Sort),
|
||||
SiteKey: query.Site,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||
|
||||
@@ -97,6 +97,7 @@ type usageResponse struct {
|
||||
|
||||
type mergeResponse struct {
|
||||
ArticlesUpdated int `json:"articles_updated"`
|
||||
SitesUpdated int `json:"sites_updated"`
|
||||
Merged int `json:"merged"`
|
||||
}
|
||||
|
||||
@@ -352,7 +353,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
|
||||
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, SitesUpdated: result.SitesUpdated, Merged: result.Merged})
|
||||
}
|
||||
|
||||
func writeMergeError(w http.ResponseWriter, err error) {
|
||||
|
||||
@@ -53,6 +53,7 @@ type purgeRunResponse struct {
|
||||
GroupsPurged int `json:"groups_purged"`
|
||||
Merged int `json:"merged"`
|
||||
ArticlesUpdated int `json:"articles_updated"`
|
||||
SitesUpdated int `json:"sites_updated"`
|
||||
FailedGroups int `json:"failed_groups"`
|
||||
}
|
||||
|
||||
@@ -164,6 +165,7 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
|
||||
GroupsPurged: result.GroupsPurged,
|
||||
Merged: result.Merged,
|
||||
ArticlesUpdated: result.ArticlesUpdated,
|
||||
SitesUpdated: result.SitesUpdated,
|
||||
FailedGroups: result.FailedGroups,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/image"
|
||||
domain "RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ListUseCase *useCases.ListSitesUseCase
|
||||
CreateUseCase *useCases.CreateSiteUseCase
|
||||
UpdateUseCase *useCases.UpdateSiteUseCase
|
||||
SetDefaultUseCase *useCases.SetDefaultSiteUseCase
|
||||
DeleteUseCase *useCases.DeleteSiteUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListSitesUseCase, create *useCases.CreateSiteUseCase, update *useCases.UpdateSiteUseCase, setDefault *useCases.SetDefaultSiteUseCase, deleteUseCase *useCases.DeleteSiteUseCase) *Handler {
|
||||
return &Handler{ListUseCase: list, CreateUseCase: create, UpdateUseCase: update, SetDefaultUseCase: setDefault, DeleteUseCase: deleteUseCase}
|
||||
}
|
||||
|
||||
type PostSite struct {
|
||||
Name string `json:"name" validate:"required,sitename"`
|
||||
Key string `json:"key" validate:"required,sitekey"`
|
||||
BaseURL string `json:"base_url" validate:"required"`
|
||||
DefaultRobots string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
|
||||
Locale *string `json:"locale"`
|
||||
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type optional[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
func (o *optional[T]) UnmarshalJSON(data []byte) error {
|
||||
o.Set = true
|
||||
if string(data) == "null" {
|
||||
o.Value = nil
|
||||
return nil
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Value = &value
|
||||
return nil
|
||||
}
|
||||
|
||||
type PatchSite struct {
|
||||
Name *string `json:"name" validate:"omitempty,sitename"`
|
||||
Key *string `json:"key" validate:"omitempty,sitekey"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
DefaultRobots *string `json:"default_robots" validate:"omitempty,oneof=index noindex"`
|
||||
Locale optional[string] `json:"locale"`
|
||||
DefaultSocialImageID optional[uint32] `json:"default_social_image_id"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
}
|
||||
|
||||
func (p PatchSite) toPatch() domain.Patch {
|
||||
return domain.Patch{
|
||||
Name: p.Name,
|
||||
Key: p.Key,
|
||||
BaseURL: p.BaseURL,
|
||||
DefaultRobots: p.DefaultRobots,
|
||||
Locale: domain.Nullable[string]{Set: p.Locale.Set, Value: p.Locale.Value},
|
||||
DefaultSocialImageID: domain.Nullable[uint32]{Set: p.DefaultSocialImageID.Set, Value: p.DefaultSocialImageID.Value},
|
||||
IsDefault: p.IsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSiteId(r *http.Request) (uint32, error) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
return uint32(id), err
|
||||
}
|
||||
|
||||
func writeSite(w http.ResponseWriter, status int, s domain.Site) {
|
||||
body, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSiteError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrSiteNotFound):
|
||||
http.Error(w, apperr.SiteNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, domain.ErrSiteKeyTaken):
|
||||
http.Error(w, apperr.SiteKeyTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrSiteHasArticles):
|
||||
http.Error(w, apperr.SiteHasArticlesErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrDefaultSite):
|
||||
http.Error(w, apperr.DefaultSiteErrorMessage, http.StatusForbidden)
|
||||
case errors.Is(err, domain.ErrKeyImmutable):
|
||||
http.Error(w, apperr.SiteKeyImmutableErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidSiteURL):
|
||||
http.Error(w, apperr.InvalidSiteURLErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidLocale):
|
||||
http.Error(w, apperr.InvalidLocaleErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, image.ErrImageNotFound):
|
||||
http.Error(w, apperr.SiteImageNotFoundErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrUseSetDefault):
|
||||
http.Error(w, apperr.UseSetDefaultErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, domain.ErrInvalidReassign):
|
||||
http.Error(w, apperr.InvalidReassignErrorMessage, http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
sites, err := h.ListUseCase.List()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(sites)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request PostSite
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateSiteCommand{
|
||||
Name: request.Name,
|
||||
Key: request.Key,
|
||||
BaseURL: request.BaseURL,
|
||||
DefaultRobots: request.DefaultRobots,
|
||||
Locale: request.Locale,
|
||||
DefaultSocialImageID: request.DefaultSocialImageID,
|
||||
IsDefault: request.IsDefault,
|
||||
})
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request PatchSite
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.UpdateUseCase.Update(r.Context(), id, request.toPatch())
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (h *Handler) SetDefault(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.SetDefaultUseCase.SetDefault(r.Context(), id)
|
||||
if err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
writeSite(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
const reassignQueryParam = "reassign_to"
|
||||
|
||||
func readReassign(r *http.Request) (*uint32, bool) {
|
||||
query := r.URL.Query()
|
||||
if !query.Has(reassignQueryParam) {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(query.Get(reassignQueryParam), 10, 32)
|
||||
if err != nil {
|
||||
return nil, true
|
||||
}
|
||||
destination := uint32(parsed)
|
||||
return &destination, false
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseSiteId(r)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reassignTo, invalid := readReassign(r)
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteSiteCommand{ID: id, ReassignTo: reassignTo, ReassignInvalid: invalid}); err != nil {
|
||||
writeSiteError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -57,3 +57,13 @@ const CachePurgeScopeErrorMessage = "Scope must be all, unused, variant or image
|
||||
const CachePurgeTargetErrorMessage = "A variant name or an image id is required for this scope."
|
||||
const VariantUnknownMessage = "Unknown variant"
|
||||
const VariantUnsupportedParameterMessage = "Unsupported parameter"
|
||||
const SiteNotFoundErrorMessage = "site_not_found"
|
||||
const SiteKeyTakenErrorMessage = "site_key_taken"
|
||||
const SiteHasArticlesErrorMessage = "site_has_articles"
|
||||
const DefaultSiteErrorMessage = "default_site"
|
||||
const SiteKeyImmutableErrorMessage = "key_immutable"
|
||||
const InvalidSiteURLErrorMessage = "invalid_site_url"
|
||||
const InvalidLocaleErrorMessage = "invalid_locale"
|
||||
const SiteImageNotFoundErrorMessage = "image_not_found"
|
||||
const UseSetDefaultErrorMessage = "use_set_default"
|
||||
const InvalidReassignErrorMessage = "invalid_reassign"
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
|
||||
const inviteEmailTag = "inviteemail"
|
||||
const variantNameTag = "variantname"
|
||||
const siteKeyTag = "sitekey"
|
||||
const siteNameTag = "sitename"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
@@ -18,6 +21,16 @@ func InitValidator() {
|
||||
Validate = validator.New()
|
||||
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
|
||||
_ = Validate.RegisterValidation(siteKeyTag, isSiteKey)
|
||||
_ = Validate.RegisterValidation(siteNameTag, isSiteName)
|
||||
}
|
||||
|
||||
func isSiteKey(field validator.FieldLevel) bool {
|
||||
return site.IsValidKey(field.Field().String())
|
||||
}
|
||||
|
||||
func isSiteName(field validator.FieldLevel) bool {
|
||||
return site.IsValidName(field.Field().String())
|
||||
}
|
||||
|
||||
func isVariantName(field validator.FieldLevel) bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"RenewCMS/api/handlers/imagevariant"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/site"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
"RenewCMS/api/handlers/update"
|
||||
"RenewCMS/api/handlers/user"
|
||||
@@ -173,6 +174,36 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
|
||||
return r
|
||||
}
|
||||
|
||||
func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
siteHandler := site.NewHandler(container.ListSitesUseCase, container.CreateSiteUseCase, container.UpdateSiteUseCase, container.SetDefaultSiteUseCase, container.DeleteSiteUseCase)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Get("/", siteHandler.List)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwner(container.GetUserUseCase))
|
||||
r.Post("/", siteHandler.Create)
|
||||
r.Patch("/{id}", siteHandler.Update)
|
||||
r.Patch("/{id}/default", siteHandler.SetDefault)
|
||||
r.Delete("/{id}", siteHandler.Delete)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -320,6 +351,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||
|
||||
+50
-5
@@ -17,16 +17,18 @@ for example `https://cms.example/v1/article/`.
|
||||
creator, with that user's role, and shows in full once.
|
||||
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
|
||||
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
|
||||
or revoked key answers 401, even on public routes.
|
||||
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
|
||||
under `/v1/site`) are session only too.
|
||||
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
|
||||
|
||||
## Routes a key can call (24)
|
||||
## Routes a key can call (25)
|
||||
| Group | Routes |
|
||||
| --- | --- |
|
||||
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
||||
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
|
||||
| Settings | GET /v1/settings |
|
||||
| Sites | GET /v1/site/ |
|
||||
|
||||
## Activity log
|
||||
The activity routes are session only: a key answers 403 "This action needs a real session." and a
|
||||
@@ -40,7 +42,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
|
||||
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
|
||||
|
||||
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
|
||||
`api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
||||
`api_keys`, `auth`, `system`, `sites`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
|
||||
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
|
||||
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
|
||||
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
|
||||
@@ -65,11 +67,12 @@ never changes the response or the change itself.
|
||||
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
|
||||
Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
||||
Target types: `article`, `site`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
||||
`api_key` and `release` (id and name are the tag).
|
||||
Only the fields `title`, `online`, `body`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes`, with the
|
||||
Only the fields `title`, `online`, `body`, `site`, `name`, `base_url`, `default_robots`, `locale`, `default_social_image`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes` (a site key is never a change; moving an article records `site` with the site names), with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
|
||||
change that leaves a value as it was (same body, same publish state, same file name, same display
|
||||
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
||||
@@ -112,6 +115,48 @@ with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `so
|
||||
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
|
||||
are no longer parameters; they are ignored, never an error, and have no effect on paging.
|
||||
|
||||
## Sites
|
||||
Every article belongs to exactly one site, and exactly one site is the default. A site is `{ id,
|
||||
name, key, base_url, default_social_image_id, default_robots, locale, is_default, article_count }`:
|
||||
`name` is 1 to 100 characters, `key` is 2 to 32 characters of lowercase letters, digits and single
|
||||
dashes (`^[a-z0-9]+(-[a-z0-9]+)*$`) and never changes, `base_url` is `https` (or `http` for
|
||||
`localhost`, `127.0.0.1` and `[::1]`) with no path, query or user info (a trailing slash is
|
||||
stripped, uniqueness is not enforced), `default_robots` is `index` (default) or `noindex`, `locale`
|
||||
is `null` or a tag such as `en` or `fr-CA` (an empty string means none) and `default_social_image_id`
|
||||
is `null` or the id of an image. Read access needs a verified session or an API key; every write is
|
||||
session only and owner only (403 "Only the owner can do this.", or "This action needs a real
|
||||
session." with a key). The role is checked before the body.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. |
|
||||
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. |
|
||||
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
||||
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
|
||||
| `DELETE /v1/site/{id}?reassign_to={id}` | 204. A site with articles needs `reassign_to`; the articles move and the site is deleted in one transaction. `reassign_to` is ignored when the site has no article. |
|
||||
|
||||
Errors are plain text whose body is exactly the token: 409 `site_key_taken`, 409 `site_has_articles`,
|
||||
403 `default_site` (checked before the articles), 400 `key_immutable`, 400 `invalid_site_url`, 400
|
||||
`invalid_locale`, 400 `image_not_found`, 400 `use_set_default`, 400 `invalid_reassign` (missing
|
||||
number, the site itself or not a whole number) and 404 `site_not_found`. Refused writes except `site_not_found` and `image_not_found` are recorded as failed events. A body that cannot be
|
||||
decoded is 400 with the body-mismatch message, a malformed id is 400 with the uint32 message, and a
|
||||
field that fails validation (`name`, `key` format, `default_robots`) is 400 with the validator text.
|
||||
|
||||
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
|
||||
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
|
||||
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
|
||||
body-mismatch message. `PATCH /v1/article/{id}` takes `body` and `site_id` (an
|
||||
API key may send it too) and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
|
||||
those fields, a `null` value or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
|
||||
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
|
||||
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
|
||||
|
||||
A site's `default_social_image_id` counts as a use of that image: it raises `used_in` and keeps the
|
||||
image out of the `unused` filter, while `GET /v1/media/{id}/usage` keeps listing articles only.
|
||||
Deleting the image sets the field to `null` and succeeds. `POST /v1/media/merge` and `POST
|
||||
/v1/media/purge` repoint the sites of the merged copies to the kept image and report it as
|
||||
`sites_updated` next to `articles_updated`.
|
||||
|
||||
## Image variants
|
||||
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
||||
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
|
||||
|
||||
`PATCH /v1/article/{id}` takes `body` and `site_id`, and nothing else. The request must contain at least one of them, and `null` for either answers `400`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
|
||||
|
||||
## Routes that honour `format`
|
||||
|
||||
- `GET /v1/article/`
|
||||
@@ -33,6 +35,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
|
||||
| `size` | `5`, `25`, `50` or `100` | `25` |
|
||||
| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none |
|
||||
| `status` | `online` or `draft` | none |
|
||||
| `site` | the `key` of a site; an unknown key gives an empty page, an empty value is `400` | none |
|
||||
| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` |
|
||||
|
||||
- `items` is `[]` when empty, never `null`. Each item is the article object below.
|
||||
@@ -66,6 +69,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
|
||||
"body": "## Plan\n\nReplace the **old** deck.",
|
||||
"body_format": "markdown",
|
||||
"is_online": true,
|
||||
"site_id": 1,
|
||||
"created_at": "2026-03-12T09:14:00Z",
|
||||
"updated_at": "2026-09-10T07:00:00Z"
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
|
||||
|
||||
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
|
||||
|
||||
### Sites
|
||||
|
||||
`internal/domain/site` holds the `Site` entity (name, immutable key, normalised base URL, optional default social image, `index` or `noindex` robots, optional locale, the default flag and the computed `article_count`), `NormalizeBaseURL`, `SeedBaseURL`, `NormalizeLocale`, `IsValidKey`, the `Patch` type (with `Nullable` for the two nullable fields), the `Err...` sentinels (`ErrSiteNotFound`, `ErrSiteKeyTaken`, `ErrSiteHasArticles`, `ErrDefaultSite`, `ErrKeyImmutable`, `ErrInvalidSiteURL`, `ErrInvalidLocale`, `ErrUseSetDefault`, `ErrInvalidReassign`) and the `Repository` interface. `persistence.SiteRepository` implements it over `models.Site` (`sites`, unique `site_key`); the single default is kept by the transactional operations `Create` (when flagged) and `SetDefault`, not by an index, and `DeleteMovingArticles` moves the articles and deletes the site in one transaction. Articles carry a `site_id` (indexed, no foreign key, so the SQL stays portable).
|
||||
|
||||
The use cases `ListSites`, `CreateSite`, `UpdateSite`, `SetDefaultSite` and `DeleteSite` live in `internal/infrastructure/useCases`; the article, media and purge use cases take the site repository as a required constructor argument, wired by the composition root (new articles take the default site, `UpdateArticle` moves an article and records `site` with site names, the media use cases count, clear and repoint default social images). `api/handlers/site` serves `/v1/site`; reads accept a session or an API key, writes are session only behind `RequireOwner`. `database.Migrate(db, publicURL)` registers the model, seeds one default site from `PUBLIC_URL` when the table is empty and backfills `articles.site_id`.
|
||||
|
||||
### Image variants
|
||||
|
||||
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
|
||||
| Name | Description | Example |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
|
||||
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
|
||||
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` |
|
||||
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
|
||||
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
|
||||
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
|
||||
|
||||
@@ -38,8 +38,8 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
| 400 invalid_site_url | "Use https. For example https://blog.example.com." / "Remove the path. Use only the host, like https://blog.example.com." / "Enter a full URL, like https://blog.example.com." (inline under base url) |
|
||||
| 409 site_key_taken | "A site with the key "journal" already exists. Choose another key." (inline under key, with a suggestion) |
|
||||
| 409 site_has_articles | "Choose where the articles go. This site has 38 articles." (inside the delete dialog) |
|
||||
| 403 owner_only (page) | "You don't have access to Sites" / "Only the owner can manage sites. Ask the owner of this site if you need a change." / "Back to Settings" (403 page on /settings/sites) |
|
||||
| 403 owner_only (action) | "Only the owner can manage sites" / "Ask the owner of this site." (error toast, role changed while the page was open) |
|
||||
| 403 "Only the owner can do this." (page) | "You don't have access to Sites" / "Only the owner can manage sites. Ask the owner of this site if you need a change." / "Back to Settings" (403 page on /settings/sites) |
|
||||
| 403 "Only the owner can do this." (action) | "Only the owner can manage sites" / "Ask the owner of this site." (error toast, role changed while the page was open) |
|
||||
| 403 author_rights | "Only an owner or admin can change authors" / "You can still edit your own author profile." (error toast, role changed while the page was open) |
|
||||
| 400 key_immutable | "Used in API paths, so it can't change." (not shown: the edit form has no editable key) |
|
||||
| 400 invalid_locale | "Use a language tag like en or fr-CH." (inline under locale) |
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
Feature: Sites backend, the owner manages sites and every article belongs to exactly one
|
||||
|
||||
As the owner of a RenewCMS installation
|
||||
I want to create, edit, delete and choose a default among several sites, and assign each article to one
|
||||
So that a single back-office can hold the content of more than one website
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/sites.md, docs/design/states.md (site rows), docs/design/routes.md and docs/design/sites-authors-slices.md
|
||||
And this slice is backend only: no SPA, no change to docs/design
|
||||
And a Site has id (uint32), name, key, base_url, default_social_image_id (nullable), default_robots ("index" or "noindex"), locale (nullable), is_default and article_count, in snake_case JSON
|
||||
And exactly one site is the default at any time, enforced by the use case inside a transaction and not by a partial index
|
||||
And every article has a site_id referencing an existing site
|
||||
And the routes live under /v1/site, mounted inside the JWT group: GET /, POST /, PATCH /{id}, PATCH /{id}/default, DELETE /{id}?reassign_to={id}
|
||||
And GET /v1/site/ accepts a session or an API key; every write is session-only and goes through RequireOwner, like PATCH /v1/settings
|
||||
And PATCH /v1/article/{id} with site_id stays callable with an API key
|
||||
And error responses are plain text from http.Error whose body is exactly the code token: site_key_taken, site_has_articles, default_site, key_immutable, invalid_site_url, invalid_locale, site_not_found, image_not_found, use_set_default, invalid_reassign
|
||||
And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.OwnerRequiredErrorMessage), with no owner_only token
|
||||
And a request body that cannot be decoded answers 400 with apperr.BodyErrorMessage ("The request cannot be processed due to a mismatch in the format of the body."), a malformed id answers 400 with apperr.IdUint32ErrorMessage, and a validate-tag failure answers 400 with the validator error text, like POST /v1/article
|
||||
And a missing article answers 404 "Article not found" (apperr.ArticleNotFoundErrorMessage)
|
||||
And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true
|
||||
And success responses are JSON with snake_case fields
|
||||
And request validation goes through validate tags and validator.Validate.Struct
|
||||
And the key matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 2 to 32 characters, the name has 1 to 100 characters, the locale matches ^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$ and an empty or null locale means none
|
||||
And base_url must be https, except http for localhost, 127.0.0.1 and [::1], with no path, query or userinfo; a trailing slash is stripped; uniqueness is not enforced
|
||||
And handlers reach sites only through use cases and domain interfaces, and no comment is added anywhere
|
||||
|
||||
Rule: Reading sites
|
||||
|
||||
Scenario: The list puts the default site first and then orders by name
|
||||
Given the sites "Zeta" (key "zeta"), "Alpha" (key "alpha") and "Main" (key "main", default)
|
||||
When a verified account sends GET /v1/site/
|
||||
Then the status is 200
|
||||
And the body lists "Main", "Alpha", "Zeta" in that order
|
||||
And every site carries article_count
|
||||
|
||||
Scenario Outline: Who can read the list
|
||||
Given at least one site exists
|
||||
When GET /v1/site/ is sent with <credentials>
|
||||
Then the status is <status>
|
||||
|
||||
Examples:
|
||||
| credentials | status |
|
||||
| the owner's jwt cookie | 200 |
|
||||
| a verified admin's jwt cookie | 200 |
|
||||
| a valid API key | 200 |
|
||||
| an unverified account's jwt cookie | 403 |
|
||||
| no credentials | 401 |
|
||||
|
||||
Scenario: A site without image or locale returns nulls
|
||||
Given the site "Blog" has no default social image and no locale
|
||||
When a verified account sends GET /v1/site/
|
||||
Then "Blog" has default_social_image_id null and locale null
|
||||
|
||||
Rule: Creating a site is owner only
|
||||
|
||||
Scenario: The owner creates a site
|
||||
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}
|
||||
Then the status is 201
|
||||
And the body is the site with a new id, is_default false, article_count 0 and the given values
|
||||
And the site is stored
|
||||
|
||||
Scenario: Optional fields default
|
||||
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
|
||||
Then the site has default_robots "index", locale null, default_social_image_id null and is_default false
|
||||
|
||||
Scenario: A new site can become the default at creation
|
||||
Given the site "Main" is the default
|
||||
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}
|
||||
Then "Shop" is the default and "Main" is not
|
||||
And exactly one site is the default
|
||||
|
||||
Scenario Outline: The base URL is normalised
|
||||
When the owner creates a site with base_url "<sent>"
|
||||
Then the stored base_url is "<stored>"
|
||||
|
||||
Examples:
|
||||
| sent | stored |
|
||||
| https://shop.example.com/ | https://shop.example.com |
|
||||
| https://Shop.Example.com | https://shop.example.com |
|
||||
| http://localhost:5173/ | http://localhost:5173 |
|
||||
|
||||
Scenario Outline: The base URL must be https except loopback and carry no path
|
||||
When the owner creates a site with base_url "<sent>"
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
|
||||
Examples:
|
||||
| sent | status | body |
|
||||
| https://shop.example.com | 201 | |
|
||||
| http://localhost:8080 | 201 | |
|
||||
| http://127.0.0.1:8080 | 201 | |
|
||||
| http://[::1]:8080 | 201 | |
|
||||
| http://shop.example.com | 400 | invalid_site_url |
|
||||
| https://shop.example.com/blog | 400 | invalid_site_url |
|
||||
| https://shop.example.com/?a=1 | 400 | invalid_site_url |
|
||||
| https://user@shop.example.com | 400 | invalid_site_url |
|
||||
| ftp://shop.example.com | 400 | invalid_site_url |
|
||||
| shop.example.com | 400 | invalid_site_url |
|
||||
|
||||
Scenario Outline: The key format
|
||||
When the owner creates a site with key "<key>"
|
||||
Then the status is <status>
|
||||
|
||||
Examples:
|
||||
| key | status |
|
||||
| shop | 201 |
|
||||
| my-shop-2 | 201 |
|
||||
| ab | 201 |
|
||||
| a | 400 |
|
||||
| Shop | 400 |
|
||||
| -shop | 400 |
|
||||
| shop- | 400 |
|
||||
| my--shop | 400 |
|
||||
| my_shop | 400 |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 400 |
|
||||
| (empty) | 400 |
|
||||
|
||||
Scenario: A taken key is refused
|
||||
Given the site with key "shop" exists
|
||||
When the owner sends POST /v1/site/ with key "shop"
|
||||
Then the status is 409
|
||||
And the body is "site_key_taken"
|
||||
And no site is added
|
||||
|
||||
Scenario Outline: Other fields are validated
|
||||
When the owner sends POST /v1/site/ with <field> <value>
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And no site is added
|
||||
|
||||
Examples:
|
||||
| field | value | status | body |
|
||||
| name | (empty) | 400 | the validator error text |
|
||||
| name | 101 characters | 400 | the validator error text |
|
||||
| default_robots | "follow" | 400 | the validator error text |
|
||||
| locale | "english" | 400 | invalid_locale |
|
||||
| locale | "fr_FR" | 400 | invalid_locale |
|
||||
| default_social_image_id | 999 (no such image) | 400 | image_not_found |
|
||||
|
||||
Scenario: A malformed body is refused
|
||||
When the owner sends POST /v1/site/ with the body "{not json"
|
||||
Then the status is 400
|
||||
And the body is apperr.BodyErrorMessage
|
||||
And no site is added
|
||||
|
||||
Scenario Outline: A valid locale is accepted and an empty one clears it
|
||||
When the owner creates a site with locale <locale>
|
||||
Then the status is 201
|
||||
And the stored locale is <stored>
|
||||
|
||||
Examples:
|
||||
| locale | stored |
|
||||
| "en" | "en" |
|
||||
| "fr-CA" | "fr-CA" |
|
||||
| "zh-Hant-TW" | "zh-Hant-TW" |
|
||||
| "" | null |
|
||||
| null | null |
|
||||
|
||||
Rule: Editing a site is owner only
|
||||
|
||||
Scenario: The owner edits a site
|
||||
Given the site 2 "Shop" with key "shop"
|
||||
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}
|
||||
Then the status is 200
|
||||
And the body is the site with the new values and the key "shop"
|
||||
|
||||
Scenario: Absent fields are untouched
|
||||
Given the site 2 has locale "fr" and default_robots "noindex"
|
||||
When the owner sends PATCH /v1/site/2 with {"name": "Boutique"}
|
||||
Then only the name changed
|
||||
|
||||
Scenario: The default social image is set and cleared
|
||||
Given the image 5 exists
|
||||
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5}
|
||||
Then the site 2 has default_social_image_id 5
|
||||
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": null}
|
||||
Then the site 2 has default_social_image_id null
|
||||
|
||||
Scenario Outline: The key never changes
|
||||
Given the site 2 has key "shop"
|
||||
When the owner sends PATCH /v1/site/2 with {"key": "<key>"}
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And the key of site 2 is still "shop"
|
||||
|
||||
Examples:
|
||||
| key | status | body |
|
||||
| shop | 200 | |
|
||||
| store | 400 | key_immutable |
|
||||
|
||||
Scenario: A rejected key change leaves every other field unchanged
|
||||
Given the site 2 "Shop"
|
||||
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "key": "store"}
|
||||
Then the status is 400 with body "key_immutable"
|
||||
And the name of site 2 is still "Shop"
|
||||
|
||||
Scenario: The default flag is not editable here
|
||||
When the owner sends PATCH /v1/site/2 with {"is_default": true}
|
||||
Then the status is 400
|
||||
And the body is "use_set_default"
|
||||
And the default site did not change
|
||||
|
||||
Scenario Outline: Editing is validated like creation
|
||||
When the owner sends PATCH /v1/site/2 with <field> <value>
|
||||
Then the status is 400
|
||||
And the body is "<body>"
|
||||
And the site 2 is unchanged
|
||||
|
||||
Examples:
|
||||
| field | value | body |
|
||||
| base_url | "http://shop.example.com" | invalid_site_url |
|
||||
| base_url | "https://shop.example.com/x" | invalid_site_url |
|
||||
| locale | "fr_FR" | invalid_locale |
|
||||
| default_social_image_id | 999 | image_not_found |
|
||||
| name | (empty) | the validator error text |
|
||||
| default_robots | "follow" | the validator error text |
|
||||
|
||||
Scenario: A missing site is not found
|
||||
When the owner sends PATCH /v1/site/999 with {"name": "X"}
|
||||
Then the status is 404
|
||||
And the body is "site_not_found"
|
||||
|
||||
Scenario: A no-op edit records nothing
|
||||
When the owner sends PATCH /v1/site/2 with the values it already has
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Scenario: A malformed id is refused
|
||||
When the owner sends PATCH /v1/site/abc with {"name": "X"}
|
||||
Then the status is 400
|
||||
And the body is the uint32 id format error
|
||||
|
||||
Rule: The default site
|
||||
|
||||
Scenario: The owner makes another site the default
|
||||
Given "Main" is the default and "Shop" is not
|
||||
When the owner sends PATCH /v1/site/2/default
|
||||
Then the status is 200
|
||||
And the body is "Shop" with is_default true
|
||||
And "Main" is no longer the default
|
||||
|
||||
Scenario: Setting the current default is a no-op
|
||||
When the owner sends PATCH /v1/site/1/default on the current default
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Scenario: A missing site cannot become the default
|
||||
When the owner sends PATCH /v1/site/999/default
|
||||
Then the status is 404
|
||||
And the body is "site_not_found"
|
||||
|
||||
Scenario: The swap is atomic
|
||||
Given the repository fails while flagging the new default
|
||||
When the owner sends PATCH /v1/site/2/default
|
||||
Then the previous default is still the only default
|
||||
|
||||
Scenario: New articles start on the new default
|
||||
Given "Shop" became the default
|
||||
When an article is created without a site
|
||||
Then its site_id is the id of "Shop"
|
||||
|
||||
Rule: Deleting a site is owner only
|
||||
|
||||
Scenario: A site without articles is deleted
|
||||
Given the site 2 has no articles and is not the default
|
||||
When the owner sends DELETE /v1/site/2
|
||||
Then the status is 204
|
||||
And the site 2 no longer exists
|
||||
|
||||
Scenario: A site with articles needs a destination
|
||||
Given the site 2 has 3 articles
|
||||
When the owner sends DELETE /v1/site/2
|
||||
Then the status is 409
|
||||
And the body is "site_has_articles"
|
||||
And the site and its articles are unchanged
|
||||
|
||||
Scenario: The articles move in one transaction
|
||||
Given the site 2 has 3 articles and the site 3 exists
|
||||
When the owner sends DELETE /v1/site/2?reassign_to=3
|
||||
Then the status is 204
|
||||
And the 3 articles now have site_id 3
|
||||
And the site 2 no longer exists
|
||||
|
||||
Scenario: Reassign and delete are all-or-nothing
|
||||
Given the repository fails while deleting the site 2
|
||||
When the owner sends DELETE /v1/site/2?reassign_to=3
|
||||
Then the articles of the site 2 still have site_id 2
|
||||
And the site 2 still exists
|
||||
|
||||
Scenario: The articles are moved by one repository operation
|
||||
When the owner deletes a site with articles and a valid reassign_to
|
||||
Then the use case calls a single repository operation that moves the articles and deletes the site
|
||||
|
||||
Scenario Outline: An unusable destination
|
||||
Given the site 2 has 3 articles and is not the default
|
||||
When the owner sends DELETE /v1/site/2?reassign_to=<reassign_to>
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And nothing changed
|
||||
|
||||
Examples:
|
||||
| reassign_to | status | body |
|
||||
| 999 | 404 | site_not_found |
|
||||
| 2 | 400 | invalid_reassign |
|
||||
| abc | 400 | invalid_reassign |
|
||||
| -1 | 400 | invalid_reassign |
|
||||
|
||||
Scenario: The default site cannot be deleted
|
||||
When the owner sends DELETE /v1/site/1 on the default site
|
||||
Then the status is 403
|
||||
And the body is "default_site"
|
||||
|
||||
Scenario: The default check comes before the articles check
|
||||
Given the default site has 5 articles
|
||||
When the owner sends DELETE /v1/site/1 without reassign_to
|
||||
Then the status is 403 with body "default_site"
|
||||
|
||||
Scenario: A missing site is not found
|
||||
When the owner sends DELETE /v1/site/999
|
||||
Then the status is 404
|
||||
And the body is "site_not_found"
|
||||
|
||||
Scenario: An unused reassign_to is ignored
|
||||
Given the site 2 has no articles
|
||||
When the owner sends DELETE /v1/site/2?reassign_to=3
|
||||
Then the status is 204
|
||||
And the site 3 is unchanged
|
||||
|
||||
Rule: Only the owner writes
|
||||
|
||||
Scenario Outline: Other roles and keys are refused
|
||||
When <request> is sent with <credentials>
|
||||
Then the status is 403
|
||||
And the body is "<body>"
|
||||
And no site changed
|
||||
|
||||
Examples:
|
||||
| request | credentials | body |
|
||||
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| PATCH /v1/site/2/default | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| DELETE /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
|
||||
| POST /v1/site/ | a valid API key | This action needs a real session. |
|
||||
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
|
||||
| PATCH /v1/site/2/default | a valid API key | This action needs a real session. |
|
||||
| DELETE /v1/site/2 | a valid API key | This action needs a real session. |
|
||||
|
||||
Scenario: The role is checked before the body
|
||||
When an admin sends POST /v1/site/ with the body "{not json"
|
||||
Then the status is 403 with body "Only the owner can do this."
|
||||
|
||||
Rule: Seed and migration
|
||||
|
||||
Scenario: A fresh database gets one default site from PUBLIC_URL
|
||||
Given an empty database and PUBLIC_URL "https://www.example.com/blog"
|
||||
When database.Migrate runs with that PUBLIC_URL
|
||||
Then one site exists with name "Default", key "default", base_url "https://www.example.com", default_robots "index", locale null and is_default true
|
||||
|
||||
Scenario Outline: The seed follows the base URL rule
|
||||
Given an empty database
|
||||
When database.Migrate runs with PUBLIC_URL "<public_url>"
|
||||
Then the seeded base_url is "<base_url>"
|
||||
|
||||
Examples:
|
||||
| public_url | base_url |
|
||||
| https://www.example.com/ | https://www.example.com |
|
||||
| https://www.example.com/a/b | https://www.example.com |
|
||||
| http://www.example.com | https://www.example.com |
|
||||
| http://localhost:8080 | http://localhost:8080 |
|
||||
| http://127.0.0.1:8080/x | http://127.0.0.1:8080 |
|
||||
|
||||
Scenario: An unparseable PUBLIC_URL stops the migration
|
||||
Given an empty database
|
||||
When database.Migrate runs with PUBLIC_URL "://not a url"
|
||||
Then it returns an error naming PUBLIC_URL
|
||||
And no site is created
|
||||
|
||||
Scenario: Existing articles are backfilled
|
||||
Given a database from before this slice holding 4 articles
|
||||
When database.Migrate runs
|
||||
Then all 4 articles have the seeded default site as site_id
|
||||
|
||||
Scenario: The seed runs once
|
||||
Given the sites table already holds a site
|
||||
When database.Migrate runs again with another PUBLIC_URL
|
||||
Then no site is added and no site is changed
|
||||
|
||||
Scenario: PUBLIC_URL is not used afterwards
|
||||
Given the owner edited the default site base_url
|
||||
When the process restarts with the same PUBLIC_URL
|
||||
Then the default site keeps the owner's base_url
|
||||
|
||||
Scenario: It works on every database driver
|
||||
When the repository contract suite runs
|
||||
Then the site repository behaves the same on SQLite, MySQL and PostgreSQL
|
||||
And the SQLite schema test accounts for the new table and column
|
||||
|
||||
Rule: Articles belong to a site
|
||||
|
||||
Scenario: A new article starts on the default site
|
||||
Given "Main" is the default site with id 1
|
||||
When the owner sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
|
||||
Then the stored article has site_id 1
|
||||
And the article is offline
|
||||
|
||||
Scenario Outline: Article reads carry site_id
|
||||
Given the article 8 belongs to the site 2
|
||||
When <request> is sent
|
||||
Then the article in the response has "site_id": 2
|
||||
|
||||
Examples:
|
||||
| request |
|
||||
| GET /v1/article/8 |
|
||||
| GET /v1/article/ (each item of items) |
|
||||
| POST /v1/article |
|
||||
| PATCH /v1/article/8/publish |
|
||||
| PATCH /v1/article/8 |
|
||||
|
||||
Scenario: The list filters by site key
|
||||
Given the site "shop" has 2 articles and "main" has 3
|
||||
When a verified account sends GET /v1/article/?site=shop
|
||||
Then only the 2 articles of "shop" are listed
|
||||
|
||||
Scenario Outline: The filter composes with the other parameters
|
||||
Given the site "shop" has articles of mixed status and titles
|
||||
When GET /v1/article/?site=shop&<params> is sent
|
||||
Then every returned article belongs to "shop" and satisfies <params>
|
||||
|
||||
Examples:
|
||||
| params |
|
||||
| status=online |
|
||||
| status=draft |
|
||||
| q=spring |
|
||||
| sort=title:asc |
|
||||
| size=5&page=2 |
|
||||
| status=draft&q=launch |
|
||||
|
||||
Scenario Outline: Unknown or empty site keys
|
||||
When GET /v1/article/?site=<key> is sent
|
||||
Then the status is <status>
|
||||
And <result>
|
||||
And the counts stay global
|
||||
|
||||
Examples:
|
||||
| key | status | result |
|
||||
| nope | 200 | the list is empty |
|
||||
| (empty) | 400 | the body is apperr.BodyErrorMessage |
|
||||
|
||||
Scenario: An anonymous or unverified list is filtered by site and shows online articles only
|
||||
Given "shop" has one online and one offline article
|
||||
When a caller without a verified account sends GET /v1/article/?site=shop
|
||||
Then only the online article is returned
|
||||
|
||||
Scenario: An offline article is still not found publicly
|
||||
Given the article 9 is offline on any site
|
||||
When a caller without a verified account sends GET /v1/article/9
|
||||
Then the status is 404
|
||||
And the body is "Article not found"
|
||||
|
||||
Scenario: An article moves to another site
|
||||
Given the article 8 is on the site 1 and the site 2 exists
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
||||
Then the status is 200
|
||||
And the article has site_id 2
|
||||
|
||||
Scenario Outline: Any role that can edit the article may choose its site
|
||||
Given the article 8 is editable by <credentials>
|
||||
When PATCH /v1/article/8 is sent with {"site_id": 2} and <credentials>
|
||||
Then the status is 200
|
||||
|
||||
Examples:
|
||||
| credentials |
|
||||
| the owner's jwt cookie |
|
||||
| a verified admin's jwt cookie |
|
||||
| a valid API key |
|
||||
|
||||
Scenario: The body and the site change together
|
||||
When the user sends PATCH /v1/article/8 with {"title": "New", "site_id": 2}
|
||||
Then both the title and the site_id are updated
|
||||
|
||||
Scenario: An update without site_id keeps the site
|
||||
Given the article 8 is on the site 2
|
||||
When the user sends PATCH /v1/article/8 with {"title": "New"}
|
||||
Then the article is still on the site 2
|
||||
|
||||
Scenario Outline: An unusable site_id is refused
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": <value>}
|
||||
Then the status is <status>
|
||||
And the body is "<body>"
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| value | status | body |
|
||||
| 999 | 404 | site_not_found |
|
||||
| 0 | 400 | the validator error text |
|
||||
| -1 | 400 | apperr.BodyErrorMessage |
|
||||
| "a" | 400 | apperr.BodyErrorMessage |
|
||||
|
||||
Scenario: A site cannot be removed from an article
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": null}
|
||||
Then the status is 400
|
||||
And the body is apperr.BodyErrorMessage
|
||||
And the article is unchanged
|
||||
|
||||
Scenario: Moving to the same site records nothing
|
||||
Given the article 8 is on the site 2
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
||||
Then the status is 200
|
||||
And no activity event is recorded
|
||||
|
||||
Scenario Outline: An empty or unknown update is refused
|
||||
When the user sends PATCH /v1/article/8 with <body>
|
||||
Then the status is 400
|
||||
And the body is apperr.BodyErrorMessage
|
||||
And the article is unchanged
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {} |
|
||||
| {"colour": "x"} |
|
||||
|
||||
Scenario: is_online is independent of the site
|
||||
Given the article 8 is online
|
||||
When the user moves it to the site 2
|
||||
Then it is still online
|
||||
|
||||
Scenario: Articles keep working after a site rename
|
||||
Given the article 8 is on the site 2
|
||||
When the owner renames the site 2
|
||||
Then GET /v1/article/8 still returns the article with site_id 2
|
||||
|
||||
Scenario: A site change is recorded in article.edit with site names
|
||||
Given the article 8 "Spring release" is on "Main" and "Shop" exists
|
||||
When the user sends PATCH /v1/article/8 with {"site_id": 2}
|
||||
Then one event "article.edit" exists with changes [{"field": "site", "from": "Main", "to": "Shop"}]
|
||||
|
||||
Rule: Media usage by default social images
|
||||
|
||||
Scenario: An image used as a default social image is counted as used
|
||||
Given the image 5 is the default social image of the site 2 and appears in no article
|
||||
When a verified account looks up the usage of the image 5
|
||||
Then used_in counts the site and the image is not "Unused"
|
||||
|
||||
Scenario: Deleting the image clears the site's field
|
||||
When the owner deletes the image 5
|
||||
Then the status is 204
|
||||
And the site 2 has default_social_image_id null
|
||||
|
||||
Scenario: Merging copies repoints the site
|
||||
Given the image 6 is a copy of the image 5 and the site 2 uses the image 6
|
||||
When the copies are merged into the image 5
|
||||
Then the site 2 has default_social_image_id 5
|
||||
And the merge response reports sites_updated 1
|
||||
|
||||
Scenario: Purge repoints sites the same way
|
||||
Given a purge group whose kept image is 5 and whose duplicate 6 is used by the site 2
|
||||
When the purge runs
|
||||
Then the site 2 has default_social_image_id 5
|
||||
And the purge result reports sites_updated 1
|
||||
|
||||
Scenario: A failed purge group leaves the site pointing at an existing image
|
||||
Given the purge of a group fails midway
|
||||
Then every site default_social_image_id refers to an image that still exists
|
||||
|
||||
Scenario: The usage lookup array shape is unchanged
|
||||
When a verified account looks up the usage of an image
|
||||
Then the response keeps the same array shape as before this slice
|
||||
|
||||
Rule: Every site write is recorded in the activity log
|
||||
|
||||
Scenario Outline: A successful write is recorded once after the change
|
||||
When the owner performs <request>
|
||||
Then one event exists with action "<action>", category "sites", target {"type": "site", "id": "<id>", "name": "<name>"}, outcome "success", actor the owner and source "web"
|
||||
And the event was recorded after the change was applied
|
||||
|
||||
Examples:
|
||||
| request | action | id | name |
|
||||
| POST /v1/site/ for "Shop" | site.create | 2 | Shop |
|
||||
| PATCH /v1/site/2 renaming it | site.edit | 2 | Shop |
|
||||
| PATCH /v1/site/2/default | site.set_default | 2 | Shop |
|
||||
| DELETE /v1/site/2 | site.delete | 2 | Shop |
|
||||
|
||||
Scenario Outline: A refused write is recorded as failed with a plain reason
|
||||
When the owner performs <request> which is refused with <code>
|
||||
Then one event exists with action "<action>", outcome "failed" and failure_reason "<reason>"
|
||||
|
||||
Examples:
|
||||
| request | code | action | reason |
|
||||
| POST /v1/site/ with a taken key | site_key_taken | site.create | A site with that key already exists. |
|
||||
| PATCH /v1/site/2 changing the key | key_immutable | site.edit | The key of a site cannot be changed. |
|
||||
| DELETE /v1/site/1 on the default site | default_site | site.delete | The default site cannot be deleted. |
|
||||
| DELETE /v1/site/2 with articles | site_has_articles | site.delete | The site still has articles. |
|
||||
| POST /v1/site/ with an invalid base URL | invalid_site_url | site.create | The site address is not valid. |
|
||||
| PATCH /v1/site/2 with an invalid locale | invalid_locale | site.edit | The locale is not valid. |
|
||||
| PATCH /v1/site/2 with is_default | use_set_default | site.edit | Use the default site action to change the default. |
|
||||
| DELETE /v1/site/2 reassigned to itself | invalid_reassign | site.delete | The destination site is not valid. |
|
||||
| POST /v1/site/ when the repository fails with "db is locked" | 500 | site.create | Something went wrong on our side. Please try again. |
|
||||
| PATCH /v1/site/2/default when the repository fails | 500 | site.set_default | Something went wrong on our side. Please try again. |
|
||||
|
||||
Scenario Outline: Nothing is recorded when there is nothing to attribute
|
||||
When <request> is sent
|
||||
Then no activity event is recorded
|
||||
|
||||
Examples:
|
||||
| request |
|
||||
| PATCH /v1/site/999 as the owner |
|
||||
| DELETE /v1/site/999 as the owner |
|
||||
| POST /v1/site/ with an unknown image (image_not_found) |
|
||||
| POST /v1/site/ as an admin (403) |
|
||||
| POST /v1/site/ with no credentials (401) |
|
||||
| PATCH /v1/site/abc as the owner |
|
||||
| POST /v1/site/ with a malformed body |
|
||||
|
||||
Scenario: A failing audit repository never changes the response
|
||||
Given the audit repository fails on Append
|
||||
When the owner sends POST /v1/site/ with valid values
|
||||
Then the response equals that of a healthy repository
|
||||
And the site was created
|
||||
|
||||
Scenario: Reads are never recorded
|
||||
When GET /v1/site/ is sent
|
||||
Then no activity event is recorded
|
||||
|
||||
Scenario: The key is never a change field
|
||||
When the owner edits a site name, base URL, robots, locale and image
|
||||
Then the changes use only the fields name, base_url, default_robots, locale and default_social_image
|
||||
And no change has the field "key"
|
||||
|
||||
Scenario: The activity API lists site events
|
||||
Given site events were recorded
|
||||
When a verified account sends GET /v1/activity/?category=sites
|
||||
Then the site events are returned with category "sites"
|
||||
|
||||
Rule: Documentation
|
||||
|
||||
Scenario: The docs describe the new surface
|
||||
Then docs/api.md documents the /v1/site routes, their errors, article site_id, the ?site= filter and the media sites_updated field
|
||||
And docs/configuration.md states that PUBLIC_URL seeds the first site once at migration
|
||||
And docs/architecture.md mentions the site aggregate, its repository and its use cases
|
||||
@@ -0,0 +1,97 @@
|
||||
# QA procedure: Sites backend
|
||||
|
||||
Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Every error assertion compares the plain-text body to the exact code token.
|
||||
|
||||
## Setup
|
||||
|
||||
- New files `test/site_*_test.go`. Fakes have unique names: `mockSiteRepo` (sites, default flag, article counts, transactional reassign-and-delete as one operation, failure injection), `mockSiteImageRepo` (images and usage), and an extended `mockArticleRepo` (site_id, site filter, move). A fake audit repository with fail and panic switches is reused.
|
||||
- Routers mirror `api/router.go`: a new `NewSiteRouter`, plus the article and media routers. Protected routes sit behind `jwtauth.Verifier` + `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`. Requests carry real signed `jwt` cookies carrying the `user_id` claim for an owner, an admin and an unverified account (roles are only owner and admin); an API key request uses the key authentication path.
|
||||
|
||||
## A. Reading (Rule: Reading sites)
|
||||
|
||||
1. GET /v1/site/ with three sites: 200, order default first then name, `article_count` on each.
|
||||
2. GET /v1/site/ credentials table: owner, admin, API key give 200; unverified gives 403; none gives 401.
|
||||
3. A site without image and locale returns `default_social_image_id` null and `locale` null.
|
||||
|
||||
## B. Creating
|
||||
|
||||
4. POST creates a site: 201, full body, stored, `is_default` false, `article_count` 0.
|
||||
5. Optional fields default to `default_robots` "index", locale null, image null.
|
||||
6. POST with `is_default` true: the new site is the only default.
|
||||
7. Base URL normalisation table (trailing slash, host case, loopback http).
|
||||
8. Base URL rule table (all rows, `invalid_site_url`).
|
||||
9. Key format table (valid and invalid rows) and `site_key_taken` 409 with nothing stored.
|
||||
10. Other fields table: name, robots, locale (`invalid_locale`), unknown image (`image_not_found`), malformed JSON gives 400 and nothing stored.
|
||||
11. Locale table: valid values stored, empty and null stored as null.
|
||||
|
||||
## C. Editing
|
||||
|
||||
12. PATCH edits name, base_url, robots, locale and returns the site with the unchanged key.
|
||||
13. Absent fields stay untouched.
|
||||
14. Image set to 5 then cleared with null.
|
||||
15. Key table: same key 200, different key 400 `key_immutable`, key unchanged.
|
||||
16. A rejected key change leaves the name unchanged.
|
||||
17. `is_default` in the body: 400 `use_set_default`, default unchanged.
|
||||
18. Validation table (base_url, locale, image, name, robots), site unchanged.
|
||||
19. Missing site 404 `site_not_found`; malformed id gives the uint32 format error.
|
||||
20. No-op edit: 200 and no audit event.
|
||||
|
||||
## D. Default site
|
||||
|
||||
21. PATCH /{id}/default swaps the default; exactly one default remains.
|
||||
22. Current default: 200, no event. Missing site: 404.
|
||||
23. Fault injected on the second step of the swap: the previous default is still the only default (the fake mimics the transaction rollback).
|
||||
24. A subsequently created article gets the new default's id.
|
||||
|
||||
## E. Deleting
|
||||
|
||||
25. Empty site: 204, gone.
|
||||
26. Site with articles and no `reassign_to`: 409 `site_has_articles`, unchanged.
|
||||
27. With a valid `reassign_to`: 204, articles moved, site gone, via a single repository operation (assert the fake records one call).
|
||||
28. Failure injected in that operation: articles and site unchanged.
|
||||
29. `reassign_to` table: 999 gives 404 `site_not_found`; the site's own id and a non-numeric value give 400 `invalid_reassign`.
|
||||
30. Default site: 403 `default_site`, also when it has articles and no `reassign_to`. Missing site: 404. Unused `reassign_to` on an empty site: 204, destination untouched.
|
||||
|
||||
## F. Owner-only writes
|
||||
|
||||
31. Role table for POST, PATCH, PATCH default, DELETE: admin gives 403 "Only the owner can do this."; API key gives 403 "This action needs a real session."; nothing changes.
|
||||
32. Admin with a malformed body still gets 403 "Only the owner can do this.".
|
||||
|
||||
## G. Articles
|
||||
|
||||
33. New article is offline and on the default site; reads (GET one, GET list, publish, PATCH) carry `site_id`.
|
||||
34. `GET /v1/article/?site=shop` filters; composes with status (online, draft), q, sort (for example `title:asc`), size and page; `?site=nope` gives 200 empty with global `counts`; `?site=` gives 400 `apperr.BodyErrorMessage`.
|
||||
35. Without a verified account, `GET /v1/article/?site=shop` returns online articles only (there is no separate public route); `GET /v1/article/{offline id}` is 404 "Article not found".
|
||||
36. PATCH `site_id` moves the article for owner, admin and an API key; title plus `site_id` change together; omission keeps the site; `is_online` unaffected; rename of the site leaves the article readable.
|
||||
37. Unusable `site_id` table, one case each: 999 gives 404 `site_not_found`; 0 gives 400 with the validator error text; -1 and "a" give 400 `apperr.BodyErrorMessage` (decode failure); null gives 400 `apperr.BodyErrorMessage`; `{}` and an unknown-only object give 400 `apperr.BodyErrorMessage`; in every case the article is unchanged and no event is recorded. A same-site move returns 200 and records nothing.
|
||||
|
||||
## H. Media
|
||||
|
||||
38. Usage lookup counts a site's default social image, shape unchanged, image not "Unused".
|
||||
39. Deleting the image succeeds (204) and nulls the site's `default_social_image_id`.
|
||||
40. Merge repoints sites and reports `sites_updated`; purge does the same; a failed purge group leaves every site pointing at an existing image.
|
||||
|
||||
## I. Activity log
|
||||
|
||||
41. Success table: one event per write with action, category "sites", target, actor, source, recorded after the change.
|
||||
42. Failed table with the plain reasons; unknown ids, 403, 401, malformed requests record nothing; reads record nothing.
|
||||
43. Failing and panicking audit repository leave the response and the change as with a healthy one.
|
||||
44. After recording site events, a verified session GET /v1/activity/?category=sites returns the create, edit, set default and delete events with category "sites" and no events of other categories (an API key gets the route's existing session refusal). Failure reasons use the style of `auditFailures.go`: the four site sentinels map to the plain sentences of the feature and any other error gives `GenericFailureReason`. The change fields are limited to name, base_url, default_robots, locale, default_social_image, site and never "key"; article.edit with a site change holds `{"field":"site","from":"Main","to":"Shop"}`.
|
||||
|
||||
## J. Persistence and migration
|
||||
|
||||
45. Repository-contract harness (temp-dir SQLite, MySQL and Postgres opt-in through the existing env switches): site repository contract, transactional default swap, reassign-and-delete, backfill. `database.Migrate` is called with PUBLIC_URL at every caller, including `test/sqlite_schema_unchanged_test.go`, which is updated for the new table and column. Seed table (path dropped, http rewritten to https, loopback kept), unparseable value returns an error naming PUBLIC_URL and creates no site, backfill of existing articles, second run changes nothing, owner-edited base URL survives a restart.
|
||||
|
||||
## K. Quality gates
|
||||
|
||||
46. Layering guard: the use cases import no persistence, mailer, models, gorm, net/http or chi; the domain site package imports no GORM or HTTP; handlers import no persistence; no comments added.
|
||||
47. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES=<site and article use case files> MUTATE_RUN='TestSite' MUTATE_WORKERS=2 task mutate -- <use case package path>`; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
||||
48. Docs (Rule: Documentation), checked by reading the files: docs/api.md documents the five /v1/site routes with auth, error tokens, article `site_id`, the `?site=` filter, `sites_updated` on media merge and purge; docs/configuration.md says PUBLIC_URL seeds the first site once at migration; docs/architecture.md names the site aggregate, repository and use cases. No comment is added to any file.
|
||||
|
||||
## Manual
|
||||
|
||||
- MySQL and Postgres: run the contract suite with the opt-in env vars against real servers.
|
||||
- Real boot with an old database copy and PUBLIC_URL set: one "Default" site appears, articles backfilled, second boot changes nothing.
|
||||
- Docker with the `/data` volume: boot an existing database and confirm the same.
|
||||
- Read the activity log screen and the CSV export: site events show names and failure reasons, and no key change.
|
||||
- Not covered here: the SPA (a later slice).
|
||||
@@ -30,6 +30,7 @@ type Article struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func DefaultSort() Sort {
|
||||
|
||||
type Filters struct {
|
||||
IsOnline *bool
|
||||
SiteID *uint32
|
||||
Page int
|
||||
Size int
|
||||
Q string
|
||||
@@ -37,6 +38,11 @@ type Filters struct {
|
||||
Sort Sort
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
@@ -57,6 +63,7 @@ type Repository interface {
|
||||
GetAll() []Article
|
||||
Create(post Article) (Article, error)
|
||||
UpdateBody(id uint32, body string) (Article, error)
|
||||
Update(id uint32, update Update) (Article, error)
|
||||
UpdateIsOnline(id uint32, isOnline bool) (Article, error)
|
||||
Delete(id uint32) error
|
||||
FindByBodyContaining(filename string) ([]Article, error)
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
CategoryAPIKeys Category = "api_keys"
|
||||
CategoryAuth Category = "auth"
|
||||
CategorySystem Category = "system"
|
||||
CategorySites Category = "sites"
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
@@ -71,6 +72,10 @@ const (
|
||||
ActionArticlePublish = "article.publish"
|
||||
ActionArticleUnpublish = "article.unpublish"
|
||||
ActionArticleDelete = "article.delete"
|
||||
ActionSiteCreate = "site.create"
|
||||
ActionSiteEdit = "site.edit"
|
||||
ActionSiteSetDefault = "site.set_default"
|
||||
ActionSiteDelete = "site.delete"
|
||||
ActionMediaUpload = "media.upload"
|
||||
ActionMediaRename = "media.rename"
|
||||
ActionMediaDelete = "media.delete"
|
||||
@@ -107,6 +112,7 @@ const (
|
||||
TargetVariant = "image_variant"
|
||||
TargetInvite = "invite"
|
||||
TargetRelease = "release"
|
||||
TargetSite = "site"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -146,6 +152,12 @@ const (
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
FieldName = "name"
|
||||
FieldBaseURL = "base_url"
|
||||
FieldDefaultRobots = "default_robots"
|
||||
FieldLocale = "locale"
|
||||
FieldSocialImage = "default_social_image"
|
||||
FieldSite = "site"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
@@ -156,7 +168,7 @@ func ActorTypes() []ActorType {
|
||||
}
|
||||
|
||||
func Categories() []Category {
|
||||
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem}
|
||||
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites}
|
||||
}
|
||||
|
||||
func Outcomes() []Outcome {
|
||||
|
||||
@@ -52,5 +52,6 @@ type RunResult struct {
|
||||
GroupsPurged int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
SitesUpdated int
|
||||
FailedGroups int
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSiteNotFound = errors.New("site not found")
|
||||
ErrSiteKeyTaken = errors.New("a site with that key already exists")
|
||||
ErrSiteHasArticles = errors.New("the site still has articles")
|
||||
ErrDefaultSite = errors.New("the default site cannot be deleted")
|
||||
ErrKeyImmutable = errors.New("the key of a site cannot be changed")
|
||||
ErrInvalidSiteURL = errors.New("invalid site url")
|
||||
ErrInvalidLocale = errors.New("invalid locale")
|
||||
ErrUseSetDefault = errors.New("the default flag is changed through the default route")
|
||||
ErrInvalidReassign = errors.New("invalid reassign destination")
|
||||
)
|
||||
|
||||
const (
|
||||
RobotsIndex = "index"
|
||||
RobotsNoIndex = "noindex"
|
||||
|
||||
DefaultName = "Default"
|
||||
DefaultKey = "default"
|
||||
|
||||
MinNameRunes = 1
|
||||
MaxNameRunes = 100
|
||||
|
||||
MinKeyRunes = 2
|
||||
MaxKeyRunes = 32
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
var localePattern = regexp.MustCompile(`^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`)
|
||||
|
||||
var loopbackHosts = []string{"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
type Site struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
DefaultSocialImageID *uint32 `json:"default_social_image_id"`
|
||||
DefaultRobots string `json:"default_robots"`
|
||||
Locale *string `json:"locale"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Name *string
|
||||
Key *string
|
||||
BaseURL *string
|
||||
DefaultRobots *string
|
||||
Locale Nullable[string]
|
||||
DefaultSocialImageID Nullable[uint32]
|
||||
IsDefault *bool
|
||||
}
|
||||
|
||||
func IsValidKey(key string) bool {
|
||||
length := utf8.RuneCountInString(key)
|
||||
return length >= MinKeyRunes && length <= MaxKeyRunes && keyPattern.MatchString(key)
|
||||
}
|
||||
|
||||
func IsValidName(name string) bool {
|
||||
length := utf8.RuneCountInString(name)
|
||||
return length >= MinNameRunes && length <= MaxNameRunes
|
||||
}
|
||||
|
||||
func NormalizeLocale(locale *string) (*string, error) {
|
||||
if locale == nil || *locale == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !localePattern.MatchString(*locale) {
|
||||
return nil, ErrInvalidLocale
|
||||
}
|
||||
return locale, nil
|
||||
}
|
||||
|
||||
func isLoopback(hostname string) bool {
|
||||
for _, loopback := range loopbackHosts {
|
||||
if hostname == loopback {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type origin struct {
|
||||
scheme string
|
||||
host string
|
||||
hostname string
|
||||
parsed *url.URL
|
||||
}
|
||||
|
||||
func parseOrigin(raw string) (origin, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.Hostname() == "" {
|
||||
return origin{}, ErrInvalidSiteURL
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return origin{}, ErrInvalidSiteURL
|
||||
}
|
||||
return origin{scheme: scheme, host: strings.ToLower(parsed.Host), hostname: strings.ToLower(parsed.Hostname()), parsed: parsed}, nil
|
||||
}
|
||||
|
||||
func NormalizeBaseURL(raw string) (string, error) {
|
||||
found, err := parseOrigin(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed := found.parsed
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return "", ErrInvalidSiteURL
|
||||
}
|
||||
if found.scheme == "http" && !isLoopback(found.hostname) {
|
||||
return "", ErrInvalidSiteURL
|
||||
}
|
||||
return found.scheme + "://" + found.host, nil
|
||||
}
|
||||
|
||||
func SeedBaseURL(publicURL string) (string, error) {
|
||||
found, err := parseOrigin(publicURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scheme := found.scheme
|
||||
if scheme == "http" && !isLoopback(found.hostname) {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + found.host, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package site
|
||||
|
||||
type Repository interface {
|
||||
List() ([]Site, error)
|
||||
Get(id uint32) (Site, error)
|
||||
GetByKey(key string) (Site, error)
|
||||
GetDefault() (Site, error)
|
||||
Create(site Site) (Site, error)
|
||||
Update(site Site) (Site, error)
|
||||
SetDefault(id uint32) (Site, error)
|
||||
DeleteMovingArticles(id uint32, reassignTo *uint32) error
|
||||
ClearImage(imageID uint32) ([]uint32, error)
|
||||
AssignImage(siteIDs []uint32, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error)
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{})
|
||||
func Migrate(db *gorm.DB, publicURL string) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureImagePathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return BackfillUserRoles(db)
|
||||
if err := BackfillUserRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return SeedDefaultSite(db, publicURL)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
domainSite "RenewCMS/internal/domain/site"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedDefaultSite(db *gorm.DB, publicURL string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
defaultSite, err := ensureDefaultSite(tx, publicURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id IS NULL OR site_id = 0").UpdateColumn("site_id", defaultSite.ID).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
|
||||
var total int64
|
||||
if err := tx.Model(&entity.Site{}).Count(&total).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
if total > 0 {
|
||||
return firstDefaultSite(tx)
|
||||
}
|
||||
baseURL, err := domainSite.SeedBaseURL(publicURL)
|
||||
if err != nil {
|
||||
return entity.Site{}, fmt.Errorf("PUBLIC_URL %q cannot seed the first site: %w", publicURL, err)
|
||||
}
|
||||
seeded := entity.Site{
|
||||
Name: domainSite.DefaultName,
|
||||
Key: domainSite.DefaultKey,
|
||||
BaseURL: baseURL,
|
||||
DefaultRobots: domainSite.RobotsIndex,
|
||||
IsDefault: true,
|
||||
}
|
||||
if err := tx.Create(&seeded).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
return seeded, nil
|
||||
}
|
||||
|
||||
func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
|
||||
var found entity.Site
|
||||
if err := tx.Order("is_default DESC, id ASC").First(&found).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func ArticleToDomain(m models.Article) article.Article {
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
IsOnline: m.IsOnline,
|
||||
SiteID: m.SiteID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
@@ -21,6 +22,7 @@ func ArticleToModel(a article.Article) models.Article {
|
||||
Title: a.Title,
|
||||
Body: a.Body,
|
||||
IsOnline: a.IsOnline,
|
||||
SiteID: a.SiteID,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func SiteToDomain(m models.Site, articleCount int) site.Site {
|
||||
return site.Site{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Key: m.Key,
|
||||
BaseURL: m.BaseURL,
|
||||
DefaultSocialImageID: m.DefaultSocialImageID,
|
||||
DefaultRobots: m.DefaultRobots,
|
||||
Locale: m.Locale,
|
||||
IsDefault: m.IsDefault,
|
||||
ArticleCount: articleCount,
|
||||
}
|
||||
}
|
||||
|
||||
func SiteToModel(s site.Site) models.Site {
|
||||
return models.Site{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Key: s.Key,
|
||||
BaseURL: s.BaseURL,
|
||||
DefaultSocialImageID: s.DefaultSocialImageID,
|
||||
DefaultRobots: s.DefaultRobots,
|
||||
Locale: s.Locale,
|
||||
IsDefault: s.IsDefault,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Article struct {
|
||||
Title string
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
SiteID uint32 `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Site struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"`
|
||||
BaseURL string `gorm:"size:255;not null"`
|
||||
DefaultSocialImageID *uint32
|
||||
DefaultRobots string `gorm:"size:16;not null"`
|
||||
Locale *string `gorm:"size:35"`
|
||||
IsDefault bool `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -40,6 +40,9 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
||||
if filters.IsOnline != nil {
|
||||
query = query.Where("is_online = ?", *filters.IsOnline)
|
||||
}
|
||||
if filters.SiteID != nil {
|
||||
query = query.Where("site_id = ?", *filters.SiteID)
|
||||
}
|
||||
switch filters.Status {
|
||||
case domain.StatusOnline:
|
||||
query = query.Where("is_online = ?", true)
|
||||
@@ -177,19 +180,24 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
creationResult := a.db.Create(&entity.Article{
|
||||
created := entity.Article{
|
||||
Title: article.Title,
|
||||
Body: article.Body,
|
||||
IsOnline: article.IsOnline,
|
||||
})
|
||||
if creationResult.Error != nil {
|
||||
return domain.Article{}, creationResult.Error
|
||||
SiteID: article.SiteID,
|
||||
}
|
||||
|
||||
var createdArticle entity.Article
|
||||
creationResult.Scan(&createdArticle)
|
||||
|
||||
return mappers.ArticleToDomain(createdArticle), nil
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&created).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(created), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() []domain.Article {
|
||||
@@ -225,6 +233,31 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
|
||||
return newArticle, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if update.SiteID != nil {
|
||||
if _, err := lockSite(tx, *update.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
if update.Body != nil {
|
||||
localArticle.Body = *update.Body
|
||||
}
|
||||
if update.SiteID != nil {
|
||||
localArticle.SiteID = *update.SiteID
|
||||
}
|
||||
return tx.Save(&localArticle).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(localArticle), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type SiteRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteRepository(db *gorm.DB) *SiteRepository {
|
||||
return &SiteRepository{db}
|
||||
}
|
||||
|
||||
type articleTotal struct {
|
||||
SiteID uint32
|
||||
Total int
|
||||
}
|
||||
|
||||
func (s *SiteRepository) articleCounts(db *gorm.DB) (map[uint32]int, error) {
|
||||
var totals []articleTotal
|
||||
err := db.Model(&entity.Article{}).Select("site_id, COUNT(*) AS total").Group("site_id").Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint32]int, len(totals))
|
||||
for _, total := range totals {
|
||||
counts[total.SiteID] = total.Total
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) articleCount(db *gorm.DB, id uint32) (int, error) {
|
||||
var total int64
|
||||
if err := db.Model(&entity.Article{}).Where("site_id = ?", id).Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) List() ([]domain.Site, error) {
|
||||
var found []entity.Site
|
||||
if err := s.db.Order("is_default DESC, LOWER(name) ASC, id ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := s.articleCounts(s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sites := make([]domain.Site, 0, len(found))
|
||||
for _, model := range found {
|
||||
sites = append(sites, mappers.SiteToDomain(model, counts[model.ID]))
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) load(db *gorm.DB, model entity.Site) (domain.Site, error) {
|
||||
total, err := s.articleCount(db, model.ID)
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return mappers.SiteToDomain(model, total), nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Get(id uint32) (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.First(&model, id).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) GetByKey(key string) (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.Where("site_key = ?", key).First(&model).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) GetDefault() (domain.Site, error) {
|
||||
var model entity.Site
|
||||
if err := s.db.Where("is_default = ?", true).Order("id ASC").First(&model).Error; err != nil {
|
||||
return domain.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return s.load(s.db, model)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
|
||||
model := mappers.SiteToModel(site)
|
||||
model.ID = 0
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if model.IsDefault {
|
||||
if err := lockAllSites(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unflagDefaults(tx, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return mappers.SiteToDomain(model, 0), nil
|
||||
}
|
||||
|
||||
func lockAllSites(tx *gorm.DB) error {
|
||||
var locked []entity.Site
|
||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
|
||||
}
|
||||
|
||||
func lockSite(tx *gorm.DB, id uint32) (entity.Site, error) {
|
||||
var locked entity.Site
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
||||
return entity.Site{}, mapNotFoundErr(err, domain.ErrSiteNotFound)
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
func unflagDefaults(tx *gorm.DB, exceptID uint32) error {
|
||||
return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
|
||||
changes := map[string]any{
|
||||
"name": site.Name,
|
||||
"base_url": site.BaseURL,
|
||||
"default_social_image_id": site.DefaultSocialImageID,
|
||||
"default_robots": site.DefaultRobots,
|
||||
"locale": site.Locale,
|
||||
}
|
||||
if _, err := s.Get(site.ID); err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
if err := s.db.Model(&entity.Site{}).Where("id = ?", site.ID).Updates(changes).Error; err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return s.Get(site.ID)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) SetDefault(id uint32) (domain.Site, error) {
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAllSites(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := lockSite(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unflagDefaults(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&entity.Site{}).Where("id = ?", id).UpdateColumn("is_default", true).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *SiteRepository) DeleteMovingArticles(id uint32, reassignTo *uint32) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
target, err := lockSite(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.IsDefault {
|
||||
return domain.ErrDefaultSite
|
||||
}
|
||||
remaining, err := s.articleCount(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining > 0 && reassignTo == nil {
|
||||
return domain.ErrSiteHasArticles
|
||||
}
|
||||
if reassignTo != nil {
|
||||
if err := moveArticles(tx, id, *reassignTo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteOrNotFound(tx.Delete(&entity.Site{}, id), domain.ErrSiteNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
|
||||
if _, err := lockSite(tx, to); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
|
||||
ids := []uint32{}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&entity.Site{}).Where("default_social_image_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Site{}).Where("id IN ?", ids).UpdateColumn("default_social_image_id", nil).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) AssignImage(siteIDs []uint32, imageID uint32) error {
|
||||
if len(siteIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&entity.Site{}).Where("id IN ?", siteIDs).UpdateColumn("default_social_image_id", imageID).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
if len(fromImageIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result := s.db.Model(&entity.Site{}).Where("default_social_image_id IN ?", fromImageIDs).UpdateColumn("default_social_image_id", toImageID)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
var _ domain.Repository = &SiteRepository{}
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,7 @@ type CreateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
@@ -18,8 +20,16 @@ type CreateArticleCommand struct {
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository, bodyRenderer, recorder}
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
|
||||
defaultSite, err := g.sites.GetDefault()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return defaultSite.ID, nil
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
|
||||
@@ -29,10 +39,17 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
siteID, err := g.defaultSiteID()
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.articleRepository.Create(article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
SiteID: siteID,
|
||||
})
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type CreateSiteUseCase struct {
|
||||
siteRepository site.Repository
|
||||
imageRepository image.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateSiteCommand struct {
|
||||
Name string
|
||||
Key string
|
||||
BaseURL string
|
||||
DefaultRobots string
|
||||
Locale *string
|
||||
DefaultSocialImageID *uint32
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
func NewCreateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *CreateSiteUseCase {
|
||||
return &CreateSiteUseCase{siteRepository, imageRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *CreateSiteUseCase) Create(ctx context.Context, command CreateSiteCommand) (site.Site, error) {
|
||||
event := audit.NewEvent(audit.ActionSiteCreate, audit.CategorySites)
|
||||
event.Target = audit.Target{Type: audit.TargetSite, Name: command.Name}
|
||||
created, err := u.create(command)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return site.Site{}, err
|
||||
}
|
||||
event.Target = siteTarget(created)
|
||||
u.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error) {
|
||||
baseURL, err := site.NormalizeBaseURL(command.BaseURL)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
locale, err := site.NormalizeLocale(command.Locale)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
if err := ensureImageExists(u.imageRepository, command.DefaultSocialImageID); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
taken, err := isKeyTaken(u.siteRepository, command.Key)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
if taken {
|
||||
return site.Site{}, site.ErrSiteKeyTaken
|
||||
}
|
||||
robots := command.DefaultRobots
|
||||
if robots == "" {
|
||||
robots = site.RobotsIndex
|
||||
}
|
||||
return u.siteRepository.Create(site.Site{
|
||||
Name: command.Name,
|
||||
Key: command.Key,
|
||||
BaseURL: baseURL,
|
||||
DefaultSocialImageID: command.DefaultSocialImageID,
|
||||
DefaultRobots: robots,
|
||||
Locale: locale,
|
||||
IsDefault: command.IsDefault,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
@@ -10,10 +11,23 @@ type DeleteMediaUseCase struct {
|
||||
imageRepository image.Repository
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
|
||||
clearedSites, err := u.sites.ClearImage(img.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.remover.remove(img); err != nil {
|
||||
_ = u.sites.AssignImage(clearedSites, img.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
|
||||
@@ -24,7 +38,7 @@ func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
|
||||
|
||||
event := audit.NewEvent(audit.ActionMediaDelete, audit.CategoryMedia)
|
||||
event.Target = imageTarget(img)
|
||||
if err := u.remover.remove(img); err != nil {
|
||||
if err := u.removeClearingSites(img); err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteSiteUseCase struct {
|
||||
siteRepository site.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type DeleteSiteCommand struct {
|
||||
ID uint32
|
||||
ReassignTo *uint32
|
||||
ReassignInvalid bool
|
||||
}
|
||||
|
||||
func NewDeleteSiteUseCase(siteRepository site.Repository, recorder audit.Recorder) *DeleteSiteUseCase {
|
||||
return &DeleteSiteUseCase{siteRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteSiteUseCase) Delete(ctx context.Context, command DeleteSiteCommand) error {
|
||||
target, err := u.siteRepository.Get(command.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionSiteDelete, audit.CategorySites)
|
||||
event.Target = siteTarget(target)
|
||||
if err := u.delete(target, command); err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *DeleteSiteUseCase) delete(target site.Site, command DeleteSiteCommand) error {
|
||||
if target.IsDefault {
|
||||
return site.ErrDefaultSite
|
||||
}
|
||||
if target.ArticleCount == 0 {
|
||||
return u.siteRepository.DeleteMovingArticles(target.ID, nil)
|
||||
}
|
||||
destination, err := u.destination(target, command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.siteRepository.DeleteMovingArticles(target.ID, &destination)
|
||||
}
|
||||
|
||||
func (u *DeleteSiteUseCase) destination(target site.Site, command DeleteSiteCommand) (uint32, error) {
|
||||
if command.ReassignInvalid {
|
||||
return 0, site.ErrInvalidReassign
|
||||
}
|
||||
if command.ReassignTo == nil {
|
||||
return 0, site.ErrSiteHasArticles
|
||||
}
|
||||
if *command.ReassignTo == 0 || *command.ReassignTo == target.ID {
|
||||
return 0, site.ErrInvalidReassign
|
||||
}
|
||||
if _, err := u.siteRepository.Get(*command.ReassignTo); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *command.ReassignTo, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strconv"
|
||||
@@ -18,6 +19,7 @@ type ListActivityUseCase struct {
|
||||
images image.Repository
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
type ListActivityCommand struct {
|
||||
@@ -37,8 +39,8 @@ type ActivityPage struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time) *ListActivityUseCase {
|
||||
return &ListActivityUseCase{events, users, articles, images, apiKeys, now}
|
||||
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository) *ListActivityUseCase {
|
||||
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites}
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
|
||||
@@ -125,6 +127,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
|
||||
case audit.TargetImage:
|
||||
_, err = l.images.Get(uint32(id))
|
||||
return existsUnless(err, image.ErrImageNotFound)
|
||||
case audit.TargetSite:
|
||||
_, err = l.sites.Get(uint32(id))
|
||||
return existsUnless(err, site.ErrSiteNotFound)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/article"
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ListArticlesUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository, bodyRenderer}
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites}
|
||||
}
|
||||
|
||||
type ListArticlesCommand struct {
|
||||
@@ -19,6 +24,7 @@ type ListArticlesCommand struct {
|
||||
Q string
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
}
|
||||
|
||||
type ArticlePage struct {
|
||||
@@ -38,7 +44,18 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
if !command.Verified && command.Status == article.StatusDraft {
|
||||
return page, nil
|
||||
}
|
||||
found, err := u.articleRepository.FindPage(pageFilters(command))
|
||||
filters := pageFilters(command)
|
||||
if command.SiteKey != nil {
|
||||
siteID, known, err := u.siteIDOf(*command.SiteKey)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
if !known {
|
||||
return page, nil
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
found, err := u.articleRepository.FindPage(filters)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
@@ -51,6 +68,17 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
found, err := u.sites.GetByKey(key)
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return found.ID, true, nil
|
||||
}
|
||||
|
||||
func pageFilters(command ListArticlesCommand) article.Filters {
|
||||
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
|
||||
if !command.Verified {
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"strings"
|
||||
)
|
||||
@@ -36,10 +37,11 @@ type ListMediaUseCase struct {
|
||||
articleRepository article.Repository
|
||||
userRepository user.Repository
|
||||
variantFiles image.VariantFileRepository
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository, articleRepository, userRepository, variantFiles}
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites}
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
@@ -106,6 +108,10 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
siteUsage, err := siteImageUsage(l.sites)
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
uploadedBy, err := newUploaderNames(l.userRepository).nameOf(img.UploadedByID)
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
@@ -129,7 +135,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
variantSize += row.Size
|
||||
}
|
||||
|
||||
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
|
||||
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles) + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||
@@ -137,9 +143,13 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
siteUsage, err := siteImageUsage(l.sites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(img image.Image) int {
|
||||
storedName := image.StoredName(img.Path)
|
||||
used := 0
|
||||
used := siteUsage[img.ID]
|
||||
for _, a := range articles {
|
||||
if strings.Contains(a.Body, storedName) {
|
||||
used++
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/site"
|
||||
|
||||
type ListSitesUseCase struct {
|
||||
siteRepository site.Repository
|
||||
}
|
||||
|
||||
func NewListSitesUseCase(siteRepository site.Repository) *ListSitesUseCase {
|
||||
return &ListSitesUseCase{siteRepository}
|
||||
}
|
||||
|
||||
func (u *ListSitesUseCase) List() ([]site.Site, error) {
|
||||
return u.siteRepository.List()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
type MergeMediaResult struct {
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
SitesUpdated int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
@@ -22,10 +24,19 @@ type MergeMediaUseCase struct {
|
||||
articleRepository article.Repository
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
|
||||
copyIds := make([]uint32, 0, len(copies))
|
||||
for _, duplicate := range copies {
|
||||
copyIds = append(copyIds, duplicate.ID)
|
||||
}
|
||||
return u.sites.RepointImage(copyIds, keptId)
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
@@ -78,6 +89,11 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
if err := u.applyRewrites(rewrites); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
sitesUpdated, err := u.repointSites(copies, kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
for _, duplicate := range copies {
|
||||
if err := u.remover.remove(duplicate); err != nil {
|
||||
@@ -85,7 +101,7 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
}
|
||||
}
|
||||
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
}
|
||||
|
||||
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
|
||||
const purgePreviewListLimit = 5
|
||||
@@ -28,8 +29,8 @@ type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository, articleRepository}}
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
@@ -22,9 +23,9 @@ type RunPurgeUseCase struct {
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder) *RunPurgeUseCase {
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository, articleRepository},
|
||||
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
@@ -86,6 +87,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
}
|
||||
result.GroupsPurged++
|
||||
result.Merged += merged.Merged
|
||||
result.SitesUpdated += merged.SitesUpdated
|
||||
for _, id := range merged.UpdatedArticleIDs {
|
||||
updated[id] = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type SetDefaultSiteUseCase struct {
|
||||
siteRepository site.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewSetDefaultSiteUseCase(siteRepository site.Repository, recorder audit.Recorder) *SetDefaultSiteUseCase {
|
||||
return &SetDefaultSiteUseCase{siteRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *SetDefaultSiteUseCase) SetDefault(ctx context.Context, id uint32) (site.Site, error) {
|
||||
current, err := u.siteRepository.Get(id)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
if current.IsDefault {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionSiteSetDefault, audit.CategorySites)
|
||||
event.Target = siteTarget(current)
|
||||
updated, err := u.siteRepository.SetDefault(id)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return site.Site{}, err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return updated, nil
|
||||
}
|
||||
@@ -3,31 +3,81 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type UpdateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository, bodyRenderer, recorder}
|
||||
type UpdateArticleCommand struct {
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
|
||||
return g.Update(ctx, id, UpdateArticleCommand{Body: &body, Format: format})
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command UpdateArticleCommand) (article.Article, error) {
|
||||
before, err := g.articleRepository.Get(id)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.UpdateBody(id, body)
|
||||
destination, err := g.destinationSite(command)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID})
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
g.recordChange(ctx, audit.ActionArticleEdit, before, updated)
|
||||
return renderArticle(g.bodyRenderer, updated, format)
|
||||
g.recordEdit(ctx, before, updated, destination)
|
||||
return renderArticle(g.bodyRenderer, updated, command.Format)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
|
||||
if command.SiteID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
destination, err := g.sites.Get(*command.SiteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &destination, nil
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
|
||||
changes := audit.ArticleChanges(before, after)
|
||||
if destination != nil && before.SiteID != after.SiteID {
|
||||
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionArticleEdit, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event.Changes = changes
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) siteName(id uint32) string {
|
||||
found, err := g.sites.Get(id)
|
||||
if err != nil {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
return found.Name
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type UpdateSiteUseCase struct {
|
||||
siteRepository site.Repository
|
||||
imageRepository image.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *UpdateSiteUseCase {
|
||||
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) {
|
||||
before, err := u.siteRepository.Get(id)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionSiteEdit, audit.CategorySites)
|
||||
event.Target = siteTarget(before)
|
||||
after, err := u.apply(before, patch)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return site.Site{}, err
|
||||
}
|
||||
|
||||
changes := u.changes(before, after)
|
||||
if len(changes) == 0 {
|
||||
return before, nil
|
||||
}
|
||||
saved, err := u.siteRepository.Update(after)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return site.Site{}, err
|
||||
}
|
||||
event.Target = siteTarget(saved)
|
||||
event.Changes = changes
|
||||
u.recorder.Record(ctx, event)
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site, error) {
|
||||
after := before
|
||||
if patch.IsDefault != nil {
|
||||
return site.Site{}, site.ErrUseSetDefault
|
||||
}
|
||||
if patch.Key != nil && *patch.Key != before.Key {
|
||||
return site.Site{}, site.ErrKeyImmutable
|
||||
}
|
||||
if patch.Name != nil {
|
||||
after.Name = *patch.Name
|
||||
}
|
||||
if patch.BaseURL != nil {
|
||||
baseURL, err := site.NormalizeBaseURL(*patch.BaseURL)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
after.BaseURL = baseURL
|
||||
}
|
||||
if patch.DefaultRobots != nil {
|
||||
after.DefaultRobots = *patch.DefaultRobots
|
||||
}
|
||||
if patch.Locale.Set {
|
||||
locale, err := site.NormalizeLocale(patch.Locale.Value)
|
||||
if err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
after.Locale = locale
|
||||
}
|
||||
if patch.DefaultSocialImageID.Set {
|
||||
if err := ensureImageExists(u.imageRepository, patch.DefaultSocialImageID.Value); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
after.DefaultSocialImageID = patch.DefaultSocialImageID.Value
|
||||
}
|
||||
return after, nil
|
||||
}
|
||||
|
||||
func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.Change {
|
||||
changes := []audit.Change{}
|
||||
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
|
||||
changes = appendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
|
||||
changes = appendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
|
||||
changes = appendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
|
||||
if !sameImage(before.DefaultSocialImageID, after.DefaultSocialImageID) {
|
||||
changes = append(changes, audit.TextChange(audit.FieldSocialImage, imageLabel(u.imageRepository, before.DefaultSocialImageID), imageLabel(u.imageRepository, after.DefaultSocialImageID)))
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendTextChange(changes []audit.Change, field string, before string, after string) []audit.Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
return append(changes, audit.TextChange(field, before, after))
|
||||
}
|
||||
|
||||
func sameImage(a *uint32, b *uint32) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
@@ -55,6 +56,14 @@ var failureReasons = []failureReason{
|
||||
{update.ErrNothingToUpdate, "There is nothing to update."},
|
||||
{update.ErrPreflightRefused, "The update cannot be downloaded."},
|
||||
{update.ErrRestartKindMismatch, "The requested restart does not match the server state."},
|
||||
{site.ErrSiteKeyTaken, "A site with that key already exists."},
|
||||
{site.ErrKeyImmutable, "The key of a site cannot be changed."},
|
||||
{site.ErrDefaultSite, "The default site cannot be deleted."},
|
||||
{site.ErrSiteHasArticles, "The site still has articles."},
|
||||
{site.ErrInvalidSiteURL, "The site address is not valid."},
|
||||
{site.ErrInvalidLocale, "The locale is not valid."},
|
||||
{site.ErrUseSetDefault, "Use the default site action to change the default."},
|
||||
{site.ErrInvalidReassign, "The destination site is not valid."},
|
||||
}
|
||||
|
||||
var restartFailureReasons = []failureReason{
|
||||
@@ -72,6 +81,7 @@ var notFoundCauses = []error{
|
||||
image.ErrVariantNotFound,
|
||||
invite.ErrInviteNotFound,
|
||||
user.ErrUserNotFound,
|
||||
site.ErrSiteNotFound,
|
||||
}
|
||||
|
||||
func reasonFor(err error, overrides ...failureReason) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
@@ -26,6 +27,7 @@ type purgePlan struct {
|
||||
type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
@@ -38,11 +40,16 @@ func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
siteUsage, err := siteImageUsage(p.sites)
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
groups := []purgeGroup{}
|
||||
copies := 0
|
||||
touched := map[uint32]bool{}
|
||||
for _, members := range groupByHash(duplicates) {
|
||||
group, planned := planGroup(members, articles)
|
||||
group, planned := planGroup(members, articles, siteUsage)
|
||||
if !planned {
|
||||
continue
|
||||
}
|
||||
@@ -87,11 +94,11 @@ func usageOf(img image.Image, articles []article.Article) int {
|
||||
return used
|
||||
}
|
||||
|
||||
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
|
||||
func planGroup(members []image.Image, articles []article.Article, siteUsage map[uint32]int) (purgeGroup, bool) {
|
||||
usage := make(map[uint32]int, len(members))
|
||||
anyUsed := false
|
||||
for _, member := range members {
|
||||
usage[member.ID] = usageOf(member, articles)
|
||||
usage[member.ID] = usageOf(member, articles) + siteUsage[member.ID]
|
||||
anyUsed = anyUsed || usage[member.ID] > 0
|
||||
}
|
||||
if !anyUsed {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func siteTarget(s site.Site) audit.Target {
|
||||
return audit.NewTarget(audit.TargetSite, s.ID, s.Name)
|
||||
}
|
||||
|
||||
func ensureImageExists(images image.Repository, id *uint32) error {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := images.Get(*id)
|
||||
return err
|
||||
}
|
||||
|
||||
func isKeyTaken(sites site.Repository, key string) (bool, error) {
|
||||
_, err := sites.GetByKey(key)
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func imageLabel(images image.Repository, id *uint32) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
found, err := images.Get(*id)
|
||||
if err != nil || found.Filename == "" {
|
||||
return strconv.FormatUint(uint64(*id), 10)
|
||||
}
|
||||
return found.Filename
|
||||
}
|
||||
|
||||
func localeLabel(locale *string) string {
|
||||
if locale == nil {
|
||||
return ""
|
||||
}
|
||||
return *locale
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/site"
|
||||
|
||||
func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
|
||||
usage := map[uint32]int{}
|
||||
all, err := sites.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range all {
|
||||
if s.DefaultSocialImageID != nil {
|
||||
usage[*s.DefaultSocialImageID]++
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
|
||||
wanted []string
|
||||
}{
|
||||
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
|
||||
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "system", "users"}},
|
||||
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "media", "sites", "system", "users"}},
|
||||
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
|
||||
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
|
||||
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
|
||||
|
||||
@@ -83,7 +83,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock, plainSites()),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
|
||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func newProducersEnv(t *testing.T) *producersEnv {
|
||||
owner := env.users.users[apiKeyOwnerId]
|
||||
owner.DisplayName = producersOwnerName
|
||||
env.users.users[apiKeyOwnerId] = owner
|
||||
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock)
|
||||
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock, plainSites())
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
|
||||
env.router = router
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites())
|
||||
|
||||
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -147,7 +147,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites())
|
||||
|
||||
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -187,7 +187,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
|
||||
|
||||
t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
|
||||
h := newActivityHarness(t)
|
||||
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder)
|
||||
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites())
|
||||
|
||||
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Title: "ab", Body: "Hello"})
|
||||
|
||||
@@ -383,8 +383,8 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
|
||||
articles := newMockArticleRepo()
|
||||
storage := newMockImageStorage()
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder)
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites())
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites())
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
@@ -27,7 +27,7 @@ func openActivityDatabase(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open the in-memory database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -39,7 +39,7 @@ func newActivityWorld(t *testing.T) *activityWorld {
|
||||
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, Role: user.RoleOwner}
|
||||
w.users.users[2] = user.User{ID: 2, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
|
||||
w.users.users[3] = user.User{ID: 3, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: "editor"}
|
||||
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock)
|
||||
w.list = useCases.NewListActivityUseCase(h.probe, w.users, w.articles, w.images, w.keys, h.clock, plainSites())
|
||||
w.summary = useCases.NewGetActivitySummaryUseCase(h.probe, h.clock)
|
||||
w.export = useCases.NewExportActivityUseCase(h.probe, h.clock)
|
||||
return w
|
||||
|
||||
@@ -100,15 +100,15 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
mail := newMockMailRepo()
|
||||
env.mail = mail
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites())
|
||||
purgeSettings := newMockPurgeSettingsRepo()
|
||||
restartJobs := &fakeJobStore{}
|
||||
env.container = &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder, plainSites()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
|
||||
@@ -126,11 +126,11 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites()),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites()),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
|
||||
|
||||
@@ -43,11 +43,11 @@ const (
|
||||
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
|
||||
renderer := markdown.NewConverter()
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
+25
-1
@@ -61,6 +61,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
|
||||
continue
|
||||
}
|
||||
if filters.SiteID != nil && a.SiteID != *filters.SiteID {
|
||||
continue
|
||||
}
|
||||
if filters.Status == article.StatusOnline && !a.IsOnline {
|
||||
continue
|
||||
}
|
||||
@@ -190,6 +193,27 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
|
||||
if err := m.fail.trigger("Update"); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
a, ok := m.articles[id]
|
||||
if !ok {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
if update.Body != nil {
|
||||
if err, failing := m.failUpdateBodyFor[id]; failing {
|
||||
return article.Article{}, err
|
||||
}
|
||||
a.Body = *update.Body
|
||||
}
|
||||
if update.SiteID != nil {
|
||||
a.SiteID = *update.SiteID
|
||||
}
|
||||
m.articles[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
|
||||
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
|
||||
return article.Article{}, err
|
||||
@@ -232,7 +256,7 @@ var _ article.Repository = &mockArticleRepo{}
|
||||
|
||||
func TestCreateArticle(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
|
||||
t.Run("Valid Title", func(t *testing.T) {
|
||||
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
|
||||
|
||||
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
|
||||
title := strings.Repeat("\U0001F600", tc.runeCount)
|
||||
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
||||
|
||||
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -578,7 +578,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
|
||||
t.Run("The typed sort reaches the repository and an absent sort keeps updated_at descending", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
seedSortArticles(repo)
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
|
||||
if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 {
|
||||
t.Fatalf("unexpected %v %+v", err, sorted)
|
||||
@@ -594,7 +594,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
seedPaginationArticles(repo, 3, 0)
|
||||
repo.fail.failNextWith(method, sentinel)
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("%s: expected sentinel, got %v", method, err)
|
||||
|
||||
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,3 +31,5 @@ func (f *failSwitch) trigger(method string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const migrationPublicURL = "https://www.example.com"
|
||||
+1
-1
@@ -281,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
|
||||
func TestImageUseCases(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
|
||||
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
t.Run("Create Image", func(t *testing.T) {
|
||||
input := imageDomain.ImageInput{
|
||||
|
||||
@@ -113,8 +113,8 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites()),
|
||||
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
|
||||
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
|
||||
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
|
||||
@@ -128,7 +128,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
VariantUsageTracker: e.tracker,
|
||||
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
|
||||
|
||||
@@ -138,7 +138,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
|
||||
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(e.images, e.variants, e.files, e.storage, recorder),
|
||||
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(e.variants, serve, log.New(e.logs, "", 0)),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, hashing.NewSHA256KeyGenerator(), e.users, clock),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock),
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock, plainSites()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
|
||||
env.seedArticle(id, "Article", true, env.urlOf(3))
|
||||
}
|
||||
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -62,27 +62,27 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
|
||||
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
|
||||
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
MergeMediaUseCase: merge,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()),
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites()),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
|
||||
@@ -842,7 +842,7 @@ func TestMediaMerging(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "merged": 2}`)
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "sites_updated": 0, "merged": 2}`)
|
||||
if got, want := env.articles.articles[1].Body, "a "+keep+" b "+keep; got != want {
|
||||
t.Errorf("article 1 body: want %q, got %q", want, got)
|
||||
}
|
||||
@@ -866,7 +866,7 @@ func TestMediaMerging(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "merged": 1}`)
|
||||
expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "sites_updated": 0, "merged": 1}`)
|
||||
})
|
||||
|
||||
t.Run("A copy that is gone answers 409 and changes nothing", func(t *testing.T) {
|
||||
@@ -943,7 +943,7 @@ func TestMediaMerging(t *testing.T) {
|
||||
|
||||
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
|
||||
|
||||
@@ -1038,7 +1038,7 @@ func TestMediaSentinels(t *testing.T) {
|
||||
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
|
||||
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
|
||||
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
|
||||
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestListMediaUseCasePaging(t *testing.T) {
|
||||
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
|
||||
images := newMockImageRepo()
|
||||
variants := newMockVariantFileRepo()
|
||||
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants)
|
||||
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, plainSites())
|
||||
}
|
||||
seed := func(images *mockImageRepo, count int) {
|
||||
for id := uint32(1); id <= uint32(count); id++ {
|
||||
|
||||
@@ -591,7 +591,7 @@ func TestPurgePreview(t *testing.T) {
|
||||
|
||||
func TestPurgeRun(t *testing.T) {
|
||||
counters := func(groups, purged, merged, articles, failed int) string {
|
||||
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"failed_groups":` + strconv.Itoa(failed) + `}`
|
||||
return `{"groups_total":` + strconv.Itoa(groups) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"sites_updated":0,"failed_groups":` + strconv.Itoa(failed) + `}`
|
||||
}
|
||||
|
||||
seedThreeGroups := func(env *mediaEnv) {
|
||||
|
||||
@@ -31,9 +31,9 @@ type schedulerEnv struct {
|
||||
func newSchedulerEnv(t *testing.T) *schedulerEnv {
|
||||
t.Helper()
|
||||
env := newPurgeEnv(t)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites())
|
||||
return &schedulerEnv{
|
||||
mediaEnv: env,
|
||||
runPurge: runPurge,
|
||||
|
||||
@@ -32,8 +32,8 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
|
||||
|
||||
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
|
||||
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites())
|
||||
}
|
||||
|
||||
t.Run("a non-matching fingerprint is a conflict and writes nothing", func(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
|
||||
env.useImages(11, 5)
|
||||
env.useImages(12, 3)
|
||||
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()).Preview()
|
||||
|
||||
var kept []uint32
|
||||
for _, group := range preview.List {
|
||||
@@ -132,7 +132,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
|
||||
env.useImages(10, 1, 2, 3)
|
||||
env.useImages(11, 5)
|
||||
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites()).Preview()
|
||||
|
||||
sum := sha256.Sum256([]byte("1:2,3;5:4;"))
|
||||
if err != nil || preview.Fingerprint != hex.EncodeToString(sum[:]) {
|
||||
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 1, 2)
|
||||
env.useImages(3, 1)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 2)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
repo, store := newMockImageRepo(), newMockImageStorage()
|
||||
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
|
||||
|
||||
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID); err != nil {
|
||||
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, created.ID); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
if len(store.blobs) != 0 || len(repo.images) != 0 {
|
||||
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
|
||||
repo, store := newMockImageRepo(), newMockImageStorage()
|
||||
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, 7)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, 7)
|
||||
|
||||
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
|
||||
t.Errorf("got %v, deleted %v", err, store.deleted)
|
||||
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
|
||||
store.failDelete = errRepositoryFailure
|
||||
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Delete(testCtx, created.ID)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
|
||||
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
|
||||
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
|
||||
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
|
||||
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
|
||||
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
|
||||
if err != nil || result.Merged != 2 {
|
||||
t.Fatalf("merge returned %+v, %v", result, err)
|
||||
@@ -184,7 +184,7 @@ func TestDriverRegistries(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Connect failed: %v", err)
|
||||
}
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("Migrate failed: %v", err)
|
||||
}
|
||||
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
func TestMigrateTwiceKeepsSingleImagePathIndex(t *testing.T) {
|
||||
db := openActivityDatabase(t)
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("second migrate failed: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("third migrate failed: %v", err)
|
||||
}
|
||||
var count int64
|
||||
|
||||
@@ -22,7 +22,7 @@ func migratedMySQL(t *testing.T) (*mysqlTestServer, database.MySQLConfig, *gorm.
|
||||
server := connectMySQLTestServer(t)
|
||||
config := server.createDatabase(t)
|
||||
connection := server.connect(t, config)
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return server, config, connection.DB
|
||||
@@ -67,7 +67,7 @@ func TestMySQLMigration(t *testing.T) {
|
||||
t.Run("migrating twice changes nothing", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Errorf("the second migration must succeed: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -81,7 +81,7 @@ func TestMySQLMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
|
||||
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
|
||||
@@ -77,7 +77,7 @@ func openContractPostgres(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
server := connectPostgresTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate postgres: %v", err)
|
||||
}
|
||||
return connection.DB
|
||||
@@ -88,7 +88,7 @@ func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfi
|
||||
server := connectPostgresTestServer(t)
|
||||
config := server.createDatabase(t)
|
||||
connection := server.connect(t, config)
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return server, config, connection.DB
|
||||
@@ -175,7 +175,7 @@ func TestPostgresMigration(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Errorf("the second migration must succeed: %v", err)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestPostgresMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ import (
|
||||
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ func openContractSQLite(t *testing.T) *gorm.DB {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
connection.DB.Logger = logger.Discard
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
@@ -111,7 +111,7 @@ func openContractMySQL(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
server := connectMySQLTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
if err := database.Migrate(connection.DB, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate mysql: %v", err)
|
||||
}
|
||||
return connection.DB
|
||||
|
||||
@@ -129,10 +129,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
|
||||
return &api.UseCases{
|
||||
Token: token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
|
||||
@@ -142,10 +142,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
|
||||
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo()),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites()),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites()),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
siteOwnerOnlyBody = "Only the owner can do this."
|
||||
siteSessionOnlyBody = "This action needs a real session."
|
||||
)
|
||||
|
||||
func TestSitesReading(t *testing.T) {
|
||||
t.Run("The list puts the default site first and then orders by name", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(4, "Zeta", "zeta", false)
|
||||
e.sites.seed(5, "Alpha", "alpha", false)
|
||||
e.sites.seed(6, "Main", "main", true)
|
||||
e.seedArticle(1, 5, true)
|
||||
e.seedArticle(2, 5, false)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
listed := decodeSiteList(t, rec)
|
||||
names := []string{}
|
||||
for _, s := range listed {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"Main", "Alpha", "Zeta"}) {
|
||||
t.Fatalf("order = %v", names)
|
||||
}
|
||||
if listed[1].ArticleCount != 2 || listed[0].ArticleCount != 0 {
|
||||
t.Errorf("article_count = %+v", listed)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"article_count"`) {
|
||||
t.Errorf("article_count missing: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Who can read the list", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
credentials string
|
||||
send func(e *siteEnv, t *testing.T) int
|
||||
status int
|
||||
}{
|
||||
{"the owner's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/", "").Code
|
||||
}, http.StatusOK},
|
||||
{"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
||||
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "").Code
|
||||
}, http.StatusOK},
|
||||
{"a valid API key", func(e *siteEnv, t *testing.T) int {
|
||||
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
||||
return e.asKey(t, raw, http.MethodGet, "/v1/site/", "").Code
|
||||
}, http.StatusOK},
|
||||
{"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
||||
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/", "").Code
|
||||
}, http.StatusForbidden},
|
||||
{"no credentials", func(e *siteEnv, t *testing.T) int {
|
||||
return e.anonymous(t, http.MethodGet, "/v1/site/", "").Code
|
||||
}, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.credentials, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
if got := c.send(e, t); got != c.status {
|
||||
t.Errorf("status = %d, expected %d", got, c.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site without image or locale returns nulls", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
|
||||
|
||||
if !strings.Contains(rec.Body.String(), `"default_social_image_id":null`) || !strings.Contains(rec.Body.String(), `"locale":null`) {
|
||||
t.Errorf("expected nulls in %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesCreating(t *testing.T) {
|
||||
const shopBody = `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
|
||||
|
||||
t.Run("The owner creates a site", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
body := `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}`
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", body)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := decodeSiteBody(t, rec)
|
||||
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA")}
|
||||
if created.ID == 0 || !reflect.DeepEqual(created, want) {
|
||||
t.Errorf("created = %+v", created)
|
||||
}
|
||||
if stored, ok := e.sites.sites[created.ID]; !ok || stored.Key != "shop" {
|
||||
t.Errorf("site not stored: %+v", e.sites.sites)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Optional fields default", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
|
||||
created := decodeSiteBody(t, e.owner(t, http.MethodPost, "/v1/site/", shopBody))
|
||||
|
||||
if created.DefaultRobots != "index" || created.Locale != nil || created.DefaultSocialImageID != nil || created.IsDefault {
|
||||
t.Errorf("created = %+v", created)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A new site can become the default at creation", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}`)
|
||||
|
||||
created := decodeSiteBody(t, rec)
|
||||
if rec.Code != http.StatusCreated || !created.IsDefault || e.sites.sites[siteMainID].IsDefault {
|
||||
t.Errorf("shop %+v, main %+v", created, e.sites.sites[siteMainID])
|
||||
}
|
||||
if defaults := e.sites.defaults(); !reflect.DeepEqual(defaults, []uint32{created.ID}) {
|
||||
t.Errorf("defaults = %v", defaults)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The base URL is normalised", func(t *testing.T) {
|
||||
cases := [][2]string{
|
||||
{"https://shop.example.com/", "https://shop.example.com"},
|
||||
{"https://Shop.Example.com", "https://shop.example.com"},
|
||||
{"http://localhost:5173/", "http://localhost:5173"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c[0], func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c[0]+`"}`)
|
||||
if got := decodeSiteBody(t, rec).BaseURL; rec.Code != http.StatusCreated || got != c[1] {
|
||||
t.Errorf("status %d, base_url %q, expected %q", rec.Code, got, c[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The base URL must be https except loopback and carry no path", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
sent string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"https://shop.example.com", 201, ""},
|
||||
{"http://localhost:8080", 201, ""},
|
||||
{"http://127.0.0.1:8080", 201, ""},
|
||||
{"http://[::1]:8080", 201, ""},
|
||||
{"http://shop.example.com", 400, "invalid_site_url"},
|
||||
{"https://shop.example.com/blog", 400, "invalid_site_url"},
|
||||
{"https://shop.example.com/?a=1", 400, "invalid_site_url"},
|
||||
{"https://user@shop.example.com", 400, "invalid_site_url"},
|
||||
{"ftp://shop.example.com", 400, "invalid_site_url"},
|
||||
{"shop.example.com", 400, "invalid_site_url"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sent, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c.sent+`"}`)
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
|
||||
}
|
||||
if c.body != "" && bodyText(rec) != c.body {
|
||||
t.Errorf("body = %q, expected %q", bodyText(rec), c.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The key format", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
status int
|
||||
}{
|
||||
{"shop", 201}, {"my-shop-2", 201}, {"ab", 201},
|
||||
{"a", 400}, {"Shop", 400}, {"-shop", 400}, {"shop-", 400}, {"my--shop", 400}, {"my_shop", 400},
|
||||
{strings.Repeat("a", 33), 400}, {"", 400},
|
||||
{strings.Repeat("a", 32), 201},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run("key "+strconv.Quote(c.key), func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "`+c.key+`", "base_url": "https://shop.example.com"}`)
|
||||
if rec.Code != c.status {
|
||||
t.Errorf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A taken key is refused", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", shopBody)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_key_taken")
|
||||
if len(e.sites.sites) != 2 {
|
||||
t.Errorf("a site was added: %+v", e.sites.sites)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Other fields are validated", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
token string
|
||||
}{
|
||||
{"name empty", `{"name": "", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
|
||||
{"name 101 characters", `{"name": "` + strings.Repeat("n", 101) + `", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
|
||||
{"default_robots follow", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "follow"}`, 400, ""},
|
||||
{"locale english", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "english"}`, 400, "invalid_locale"},
|
||||
{"locale fr_FR", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "fr_FR"}`, 400, "invalid_locale"},
|
||||
{"default_social_image_id 999", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_social_image_id": 999}`, 400, "image_not_found"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", c.body)
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.token != "" && bodyText(rec) != c.token {
|
||||
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
|
||||
}
|
||||
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
|
||||
t.Errorf("expected the validator error text, got %q", bodyText(rec))
|
||||
}
|
||||
if len(e.sites.sites) != 1 {
|
||||
t.Errorf("a site was added: %+v", e.sites.sites)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A malformed body is refused", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", "{not json")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
if len(e.sites.sites) != 1 {
|
||||
t.Errorf("a site was added")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A valid locale is accepted and an empty one clears it", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
sent string
|
||||
stored *string
|
||||
}{
|
||||
{`"en"`, stringPointer("en")},
|
||||
{`"fr-CA"`, stringPointer("fr-CA")},
|
||||
{`"zh-Hant-TW"`, stringPointer("zh-Hant-TW")},
|
||||
{`""`, nil},
|
||||
{`null`, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sent, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": `+c.sent+`}`)
|
||||
if rec.Code != http.StatusCreated || !reflect.DeepEqual(decodeSiteBody(t, rec).Locale, c.stored) {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesEditing(t *testing.T) {
|
||||
t.Run("The owner edits a site", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}`)
|
||||
|
||||
updated := decodeSiteBody(t, rec)
|
||||
want := site.Site{ID: 2, Name: "Boutique", Key: "shop", BaseURL: "https://boutique.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr")}
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(updated, want) {
|
||||
t.Errorf("status %d, updated %+v", rec.Code, updated)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Absent fields are untouched", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
shop := e.sites.sites[siteShopID]
|
||||
shop.Locale, shop.DefaultRobots = stringPointer("fr"), "noindex"
|
||||
e.sites.sites[siteShopID] = shop
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
|
||||
|
||||
want := shop
|
||||
want.Name = "Boutique"
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
|
||||
t.Errorf("status %d, site %+v", rec.Code, e.sites.sites[siteShopID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The default social image is set and cleared", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
|
||||
set := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
||||
afterSet := e.sites.sites[siteShopID].DefaultSocialImageID
|
||||
cleared := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": null}`)
|
||||
|
||||
if set.Code != http.StatusOK || afterSet == nil || *afterSet != 5 {
|
||||
t.Errorf("set: status %d, image %v", set.Code, afterSet)
|
||||
}
|
||||
if cleared.Code != http.StatusOK || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
|
||||
t.Errorf("clear: status %d, image %v", cleared.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The key never changes", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
status int
|
||||
body string
|
||||
}{{"shop", 200, ""}, {"store", 400, "key_immutable"}}
|
||||
for _, c := range cases {
|
||||
t.Run(c.key, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"key": "`+c.key+`"}`)
|
||||
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if e.sites.sites[siteShopID].Key != "shop" {
|
||||
t.Errorf("key changed to %q", e.sites.sites[siteShopID].Key)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A rejected key change leaves every other field unchanged", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "key": "store"}`)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "key_immutable")
|
||||
if e.sites.sites[siteShopID].Name != "Shop" {
|
||||
t.Errorf("name changed to %q", e.sites.sites[siteShopID].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The default flag is not editable here", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"is_default": true}`)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "use_set_default")
|
||||
if !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
|
||||
t.Errorf("defaults = %v", e.sites.defaults())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Editing is validated like creation", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
token string
|
||||
}{
|
||||
{"base_url http", `{"base_url": "http://shop.example.com"}`, "invalid_site_url"},
|
||||
{"base_url path", `{"base_url": "https://shop.example.com/x"}`, "invalid_site_url"},
|
||||
{"locale fr_FR", `{"locale": "fr_FR"}`, "invalid_locale"},
|
||||
{"default_social_image_id 999", `{"default_social_image_id": 999}`, "image_not_found"},
|
||||
{"name empty", `{"name": ""}`, ""},
|
||||
{"default_robots follow", `{"default_robots": "follow"}`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
before := e.sites.snapshot()
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.token != "" && bodyText(rec) != c.token {
|
||||
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
|
||||
}
|
||||
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
|
||||
t.Errorf("expected the validator error text, got %q", bodyText(rec))
|
||||
}
|
||||
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
||||
t.Errorf("site changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A missing site is not found", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/999", `{"name": "X"}`)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
||||
})
|
||||
|
||||
t.Run("A no-op edit records nothing", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Shop", "base_url": "https://shop.example.com/", "default_robots": "index", "locale": null}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A malformed id is refused", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesDefault(t *testing.T) {
|
||||
t.Run("The owner makes another site the default", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
||||
|
||||
got := decodeSiteBody(t, rec)
|
||||
if rec.Code != http.StatusOK || got.Name != "Shop" || !got.IsDefault || e.sites.sites[siteMainID].IsDefault {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Setting the current default is a no-op", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/1/default", "")
|
||||
|
||||
if rec.Code != http.StatusOK || e.sites.setDefaultCalls != 0 {
|
||||
t.Errorf("status %d, setDefault calls %d", rec.Code, e.sites.setDefaultCalls)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A missing site cannot become the default", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/999/default", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
||||
})
|
||||
|
||||
t.Run("The swap is atomic", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.sites.fail.failNext("SetDefault")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
|
||||
t.Errorf("status %d, defaults %v", rec.Code, e.sites.defaults())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("New articles start on the new default", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
|
||||
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":2`) {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesDeleting(t *testing.T) {
|
||||
t.Run("A site without articles is deleted", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, exists := e.sites.sites[siteShopID]; exists {
|
||||
t.Errorf("site 2 still exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site with articles needs a destination", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
for id := uint32(1); id <= 3; id++ {
|
||||
e.seedArticle(id, siteShopID, true)
|
||||
}
|
||||
before := e.sites.snapshot()
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_has_articles")
|
||||
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
|
||||
t.Errorf("state changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The articles move in one transaction", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
||||
for id := uint32(1); id <= 3; id++ {
|
||||
e.seedArticle(id, siteShopID, true)
|
||||
}
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent || e.articlesOn(siteBlogID) != 3 {
|
||||
t.Errorf("status %d, articles on 3: %d", rec.Code, e.articlesOn(siteBlogID))
|
||||
}
|
||||
if _, exists := e.sites.sites[siteShopID]; exists {
|
||||
t.Errorf("site 2 still exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
||||
e.seedArticle(1, siteShopID, true)
|
||||
e.sites.fail.failNext("DeleteMovingArticles")
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || e.articlesOn(siteShopID) != 1 {
|
||||
t.Errorf("status %d, articles on 2: %d", rec.Code, e.articlesOn(siteShopID))
|
||||
}
|
||||
if _, exists := e.sites.sites[siteShopID]; !exists {
|
||||
t.Errorf("site 2 vanished")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The articles are moved by one repository operation", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
||||
e.seedArticle(1, siteShopID, true)
|
||||
e.articles.fail.failNext("Update")
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent || e.sites.deleteCalls != 1 || e.sites.deleteReassignments[0] == nil || *e.sites.deleteReassignments[0] != siteBlogID {
|
||||
t.Errorf("status %d, delete calls %d", rec.Code, e.sites.deleteCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unusable destination", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
reassign string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"999", 404, "site_not_found"},
|
||||
{"2", 400, "invalid_reassign"},
|
||||
{"abc", 400, "invalid_reassign"},
|
||||
{"-1", 400, "invalid_reassign"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.reassign, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
for id := uint32(1); id <= 3; id++ {
|
||||
e.seedArticle(id, siteShopID, true)
|
||||
}
|
||||
before := e.sites.snapshot()
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to="+c.reassign, "")
|
||||
expectSiteStatusAndBody(t, rec, c.status, c.body)
|
||||
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
|
||||
t.Errorf("state changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The default site cannot be deleted", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
|
||||
})
|
||||
|
||||
t.Run("The default check comes before the articles check", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
for id := uint32(1); id <= 5; id++ {
|
||||
e.seedArticle(id, siteMainID, true)
|
||||
}
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
|
||||
})
|
||||
|
||||
t.Run("A missing site is not found", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/999", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
||||
})
|
||||
|
||||
t.Run("An unused reassign_to is ignored", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
||||
blogBefore := e.sites.sites[siteBlogID]
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent || !reflect.DeepEqual(e.sites.sites[siteBlogID], blogBefore) {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesOnlyTheOwnerWrites(t *testing.T) {
|
||||
t.Run("Other roles and keys are refused", func(t *testing.T) {
|
||||
requests := []struct{ method, path, body string }{
|
||||
{http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`},
|
||||
{http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`},
|
||||
{http.MethodPatch, "/v1/site/2/default", ""},
|
||||
{http.MethodDelete, "/v1/site/2", ""},
|
||||
}
|
||||
for _, request := range requests {
|
||||
t.Run(request.method+" "+request.path+" with a verified admin's jwt cookie", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
before := e.sites.snapshot()
|
||||
rec := e.as(t, apiKeyDevId, request.method, request.path, request.body)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
|
||||
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
||||
t.Errorf("a site changed")
|
||||
}
|
||||
})
|
||||
t.Run(request.method+" "+request.path+" with a valid API key", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
before := e.sites.snapshot()
|
||||
raw, _ := e.issueKey(apiKeyOwnerId, "Deploy bot")
|
||||
rec := e.asKey(t, raw, request.method, request.path, request.body)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteSessionOnlyBody)
|
||||
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
||||
t.Errorf("a site changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role is checked before the body", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
|
||||
rec := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", "{not json")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const genericFailureSentence = "Something went wrong on our side. Please try again."
|
||||
|
||||
func TestSitesActivityLog(t *testing.T) {
|
||||
t.Run("A successful write is recorded once after the change", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
request string
|
||||
action string
|
||||
prepare func(e *siteEnv)
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{"POST /v1/site/ for \"Shop\"", "site.create", func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`, http.StatusCreated},
|
||||
{"PATCH /v1/site/2 renaming it", "site.edit", func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Boutique", "shop", false)
|
||||
}, http.MethodPatch, "/v1/site/2", `{"name": "Shop"}`, http.StatusOK},
|
||||
{"PATCH /v1/site/2/default", "site.set_default", func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodPatch, "/v1/site/2/default", "", http.StatusOK},
|
||||
{"DELETE /v1/site/2", "site.delete", func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodDelete, "/v1/site/2", "", http.StatusNoContent},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
c.prepare(e)
|
||||
|
||||
rec := e.owner(t, c.method, c.path, c.body)
|
||||
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, c.action, audit.CategorySites, audit.OutcomeSuccess)
|
||||
assertProducerTarget(t, event, "site", "2", "Shop")
|
||||
assertProducerOwnerOrigin(t, event)
|
||||
if event.Category != "sites" {
|
||||
t.Errorf("category = %s", event.Category)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A refused write is recorded as failed with a plain reason", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
request string
|
||||
action string
|
||||
reason string
|
||||
status int
|
||||
prepare func(e *siteEnv)
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"POST /v1/site/ with a taken key", "site.create", "A site with that key already exists.", http.StatusConflict, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "shop", "base_url": "https://other.example.com"}`},
|
||||
{"PATCH /v1/site/2 changing the key", "site.edit", "The key of a site cannot be changed.", http.StatusBadRequest, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodPatch, "/v1/site/2", `{"key": "store"}`},
|
||||
{"DELETE /v1/site/1 on the default site", "site.delete", "The default site cannot be deleted.", http.StatusForbidden, func(e *siteEnv) {}, http.MethodDelete, "/v1/site/1", ""},
|
||||
{"DELETE /v1/site/2 with articles", "site.delete", "The site still has articles.", http.StatusConflict, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
e.seedArticle(1, siteShopID, true)
|
||||
}, http.MethodDelete, "/v1/site/2", ""},
|
||||
{"POST /v1/site/ with an invalid base URL", "site.create", "The site address is not valid.", http.StatusBadRequest, func(e *siteEnv) {}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "http://shop.example.com"}`},
|
||||
{"PATCH /v1/site/2 with an invalid locale", "site.edit", "The locale is not valid.", http.StatusBadRequest, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodPatch, "/v1/site/2", `{"locale": "not a locale"}`},
|
||||
{"PATCH /v1/site/2 with is_default", "site.edit", "Use the default site action to change the default.", http.StatusBadRequest, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
}, http.MethodPatch, "/v1/site/2", `{"is_default": true}`},
|
||||
{"DELETE /v1/site/2 reassigned to itself", "site.delete", "The destination site is not valid.", http.StatusBadRequest, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
e.seedArticle(1, siteShopID, true)
|
||||
}, http.MethodDelete, "/v1/site/2?reassign_to=2", ""},
|
||||
{"POST /v1/site/ when the repository fails with \"db is locked\"", "site.create", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
|
||||
e.sites.fail.failNextWith("Create", errors.New("db is locked"))
|
||||
}, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
|
||||
{"PATCH /v1/site/2/default when the repository fails", "site.set_default", genericFailureSentence, http.StatusInternalServerError, func(e *siteEnv) {
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
e.sites.fail.failNext("SetDefault")
|
||||
}, http.MethodPatch, "/v1/site/2/default", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
c.prepare(e)
|
||||
|
||||
rec := e.owner(t, c.method, c.path, c.body)
|
||||
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, c.action, audit.CategorySites, audit.OutcomeFailed)
|
||||
assertProducerFailure(t, event, c.reason)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Nothing is recorded when there is nothing to attribute", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
request string
|
||||
as uint32
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"PATCH /v1/site/999 as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/999", `{"name": "X"}`},
|
||||
{"DELETE /v1/site/999 as the owner", apiKeyOwnerId, http.MethodDelete, "/v1/site/999", ""},
|
||||
{"POST /v1/site/ with an unknown image", apiKeyOwnerId, http.MethodPost, "/v1/site/", `{"name": "Other", "key": "other", "base_url": "https://other.example.com", "default_social_image_id": 999}`},
|
||||
{"POST /v1/site/ as an admin (403)", apiKeyDevId, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
|
||||
{"POST /v1/site/ with no credentials (401)", 0, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`},
|
||||
{"PATCH /v1/site/abc as the owner", apiKeyOwnerId, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`},
|
||||
{"POST /v1/site/ with a malformed body", apiKeyOwnerId, http.MethodPost, "/v1/site/", "{not json"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.request, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.as(t, c.as, c.method, c.path, c.body)
|
||||
e.noEvent(t)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing audit repository never changes the response", func(t *testing.T) {
|
||||
body := `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
|
||||
healthy := newSiteEnvWithMain(t)
|
||||
healthyRec := healthy.owner(t, http.MethodPost, "/v1/site/", body)
|
||||
broken := newSiteEnvWithMain(t)
|
||||
broken.audit.probe.appendErr = errDiskFull
|
||||
|
||||
rec := broken.owner(t, http.MethodPost, "/v1/site/", body)
|
||||
|
||||
if rec.Code != healthyRec.Code || rec.Body.String() != healthyRec.Body.String() {
|
||||
t.Errorf("broken %d %s, healthy %d %s", rec.Code, rec.Body.String(), healthyRec.Code, healthyRec.Body.String())
|
||||
}
|
||||
if len(broken.sites.sites) != 2 {
|
||||
t.Errorf("the site was not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reads are never recorded", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
|
||||
e.owner(t, http.MethodGet, "/v1/site/", "")
|
||||
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("The key is never a change field", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr", "default_social_image_id": 5}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fields := []string{}
|
||||
for _, change := range e.single(t).Changes {
|
||||
fields = append(fields, change.Field)
|
||||
}
|
||||
if !reflect.DeepEqual(fields, []string{"name", "base_url", "default_robots", "locale", "default_social_image"}) {
|
||||
t.Errorf("fields = %v", fields)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The activity API lists site events", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`)
|
||||
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/activity/?category=sites", "")
|
||||
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
Category string `json:"category"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
actions := []string{}
|
||||
for _, item := range page.Items {
|
||||
if item.Category != "sites" {
|
||||
t.Errorf("item category = %s", item.Category)
|
||||
}
|
||||
actions = append(actions, item.Action)
|
||||
}
|
||||
if len(actions) != 2 || !strings.Contains(strings.Join(actions, ","), "site.create") || !strings.Contains(strings.Join(actions, ","), "site.set_default") {
|
||||
t.Errorf("actions = %v", actions)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type articleListBody struct {
|
||||
Items []struct {
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Online bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Counts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
Draft int `json:"draft"`
|
||||
} `json:"counts"`
|
||||
}
|
||||
|
||||
func decodeArticleList(t *testing.T, rec *httptest.ResponseRecorder) articleListBody {
|
||||
t.Helper()
|
||||
var decoded articleListBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode list %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func (e *siteEnv) seedTitled(id uint32, siteID uint32, title string, online bool) {
|
||||
e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", IsOnline: online, SiteID: siteID}
|
||||
}
|
||||
|
||||
func TestArticlesBelongToASite(t *testing.T) {
|
||||
t.Run("A new article starts on the default site", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
|
||||
stored := e.articles.articles[1]
|
||||
if rec.Code != http.StatusCreated || stored.SiteID != siteMainID || stored.IsOnline {
|
||||
t.Errorf("status %d, stored %+v", rec.Code, stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Article reads carry site_id", func(t *testing.T) {
|
||||
requests := []struct{ name, method, path, body string }{
|
||||
{"GET /v1/article/8", http.MethodGet, "/v1/article/8", ""},
|
||||
{"GET /v1/article/ (each item of items)", http.MethodGet, "/v1/article/", ""},
|
||||
{"PATCH /v1/article/8/publish", http.MethodPatch, "/v1/article/8/publish", ""},
|
||||
{"PATCH /v1/article/8", http.MethodPatch, "/v1/article/8", `{"body": "New"}`},
|
||||
}
|
||||
for _, request := range requests {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", false)
|
||||
rec := e.owner(t, request.method, request.path, request.body)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("POST /v1/article", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
||||
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":1`) {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("The list filters by site key", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(1, siteShopID, "Shop one", true)
|
||||
e.seedTitled(2, siteShopID, "Shop two", false)
|
||||
for id := uint32(3); id <= 5; id++ {
|
||||
e.seedTitled(id, siteMainID, "Main article", true)
|
||||
}
|
||||
|
||||
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop", ""))
|
||||
|
||||
if list.Total != 2 || len(list.Items) != 2 {
|
||||
t.Fatalf("list = %+v", list)
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
if item.SiteID != siteShopID {
|
||||
t.Errorf("item %+v is not on shop", item)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The filter composes with the other parameters", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
params string
|
||||
holds func(title string, online bool) bool
|
||||
min int
|
||||
}{
|
||||
{"status=online", func(_ string, online bool) bool { return online }, 1},
|
||||
{"status=draft", func(_ string, online bool) bool { return !online }, 1},
|
||||
{"q=spring", func(title string, _ bool) bool { return strings.Contains(strings.ToLower(title), "spring") }, 1},
|
||||
{"sort=title:asc", func(string, bool) bool { return true }, 2},
|
||||
{"size=5&page=2", func(string, bool) bool { return true }, 1},
|
||||
{"status=draft&q=launch", func(title string, online bool) bool {
|
||||
return !online && strings.Contains(strings.ToLower(title), "launch")
|
||||
}, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.params, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
titles := []string{"Spring launch", "Spring notes", "Launch plan", "Summer", "Autumn", "Winter", "Zeta"}
|
||||
for index, title := range titles {
|
||||
e.seedTitled(uint32(index+1), siteShopID, title, index%2 == 0)
|
||||
}
|
||||
e.seedTitled(20, siteShopID, "Launch of spring draft", false)
|
||||
e.seedTitled(30, siteMainID, "Spring launch main draft", false)
|
||||
e.seedTitled(31, siteMainID, "Main online", true)
|
||||
|
||||
list := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/?site=shop&"+c.params, ""))
|
||||
|
||||
if len(list.Items) < c.min {
|
||||
t.Fatalf("expected at least %d items, got %+v", c.min, list)
|
||||
}
|
||||
for _, item := range list.Items {
|
||||
if item.SiteID != siteShopID || !c.holds(item.Title, item.Online) {
|
||||
t.Errorf("item %+v does not satisfy shop and %s", item, c.params)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unknown or empty site keys", func(t *testing.T) {
|
||||
t.Run("nope", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(1, siteShopID, "Shop one", true)
|
||||
e.seedTitled(2, siteMainID, "Main draft", false)
|
||||
global := decodeArticleList(t, e.owner(t, http.MethodGet, "/v1/article/", "")).Counts
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/article/?site=nope", "")
|
||||
|
||||
list := decodeArticleList(t, rec)
|
||||
if rec.Code != http.StatusOK || len(list.Items) != 0 || list.Total != 0 || list.Counts != global {
|
||||
t.Errorf("status %d, list %+v, global %+v", rec.Code, list, global)
|
||||
}
|
||||
})
|
||||
t.Run("(empty)", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/article/?site=", "")
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("An anonymous or unverified list is filtered by site and shows online articles only", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(1, siteShopID, "Shop online", true)
|
||||
e.seedTitled(2, siteShopID, "Shop offline", false)
|
||||
|
||||
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||
"anonymous": e.anonymous(t, http.MethodGet, "/v1/article/?site=shop", ""),
|
||||
"unverified": e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/?site=shop", ""),
|
||||
} {
|
||||
list := decodeArticleList(t, rec)
|
||||
if rec.Code != http.StatusOK || len(list.Items) != 1 || list.Items[0].ID != 1 {
|
||||
t.Errorf("%s: status %d, list %+v", name, rec.Code, list)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An offline article is still not found publicly", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(9, siteShopID, "Hidden", false)
|
||||
|
||||
rec := e.anonymous(t, http.MethodGet, "/v1/article/9", "")
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusNotFound, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("An article moves to another site", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
|
||||
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
|
||||
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Any role that can edit the article may choose its site", func(t *testing.T) {
|
||||
credentials := map[string]func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder{
|
||||
"the owner's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
},
|
||||
"a verified admin's jwt cookie": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
},
|
||||
"a valid API key": func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
||||
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
},
|
||||
}
|
||||
for name, send := range credentials {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
if rec := send(e, t); rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The body and the site change together", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body", "site_id": 2}`)
|
||||
|
||||
stored := e.articles.articles[8]
|
||||
if rec.Code != http.StatusOK || stored.Body != "New body" || stored.SiteID != siteShopID {
|
||||
t.Errorf("status %d, stored %+v", rec.Code, stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An update without site_id keeps the site", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"body": "New body"}`)
|
||||
|
||||
if rec.Code != http.StatusOK || e.articles.articles[8].SiteID != siteShopID || e.articles.articles[8].Body != "New body" {
|
||||
t.Errorf("status %d, stored %+v", rec.Code, e.articles.articles[8])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unusable site_id is refused", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"999", 404, "site_not_found"},
|
||||
{"0", 400, ""},
|
||||
{"-1", 400, apperr.BodyErrorMessage},
|
||||
{`"a"`, 400, apperr.BodyErrorMessage},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.value, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
before := e.articles.articles[8]
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": `+c.value+`}`)
|
||||
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) || (c.body == "" && bodyText(rec) == "") {
|
||||
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
||||
}
|
||||
if e.articles.articles[8] != before {
|
||||
t.Errorf("article changed: %+v", e.articles.articles[8])
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site cannot be removed from an article", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": null}`)
|
||||
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
if e.articles.articles[8].SiteID != siteShopID {
|
||||
t.Errorf("article changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Moving to the same site records nothing", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("An empty or unknown update is refused", func(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"title": "New title"}`, `{"body": null}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
before := e.articles.articles[8]
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/article/8", body)
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
if e.articles.articles[8] != before {
|
||||
t.Errorf("article changed")
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is_online is independent of the site", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
|
||||
if !e.articles.articles[8].IsOnline {
|
||||
t.Errorf("article went offline")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Articles keep working after a site rename", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteShopID, "Spring release", true)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/article/8", "")
|
||||
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"site_id":2`) {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site change is recorded in article.edit with site names", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedTitled(8, siteMainID, "Spring release", true)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/article/8", `{"site_id": 2}`)
|
||||
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, "article.edit", "articles", "success")
|
||||
if got := changesJSON(t, event); got != `[{"field":"site","kind":"text","before":"Main","after":"Shop"}]` {
|
||||
t.Errorf("changes = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
siteHandler "RenewCMS/api/handlers/site"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func imageChange(t *testing.T, e *siteEnv) (string, string) {
|
||||
t.Helper()
|
||||
changes := e.single(t).Changes
|
||||
if len(changes) != 1 || changes[0].Field != "default_social_image" || changes[0].Before == nil || changes[0].After == nil {
|
||||
t.Fatalf("changes = %+v", changes)
|
||||
}
|
||||
return *changes[0].Before, *changes[0].After
|
||||
}
|
||||
|
||||
func TestSitesImageChangeLabels(t *testing.T) {
|
||||
t.Run("Switching between two images records both filenames", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "a")
|
||||
e.seedBanner(6, "other.png", "b")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 6}`)
|
||||
|
||||
before, after := imageChange(t, e)
|
||||
if rec.Code != http.StatusOK || before != "banner.png" || after != "other.png" {
|
||||
t.Errorf("status %d, before %q, after %q", rec.Code, before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Setting the same image again records nothing", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "a")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("An image without a filename is labelled by its id", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "a")
|
||||
e.seedBanner(6, "", "b")
|
||||
e.useImageOnSite(siteShopID, 6)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
||||
|
||||
before, after := imageChange(t, e)
|
||||
if before != "6" || after != "banner.png" {
|
||||
t.Errorf("before %q, after %q", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A previous image that no longer exists is labelled by its id", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "a")
|
||||
e.useImageOnSite(siteShopID, 99)
|
||||
|
||||
e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
||||
|
||||
before, after := imageChange(t, e)
|
||||
if before != "99" || after != "banner.png" {
|
||||
t.Errorf("before %q, after %q", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSitesImageUsageCounting(t *testing.T) {
|
||||
t.Run("Renaming an image used by an article and a site counts both", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "a")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/media/5", `{"filename": "renamed"}`)
|
||||
|
||||
var body struct {
|
||||
UsedIn int `json:"used_in"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if rec.Code != http.StatusOK || body.UsedIn != 2 {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A purge keeps the copy that only a site uses", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedDuplicates()
|
||||
e.useImageOnSite(siteShopID, 6)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
||||
|
||||
var body map[string]int
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if _, kept := e.images.images[6]; rec.Code != http.StatusOK || body["merged"] != 1 || !kept {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, gone := e.images.images[5]; gone {
|
||||
t.Errorf("the unused copy was kept")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var errInjectedDatabaseFailure = errors.New("injected database failure")
|
||||
|
||||
type databaseFault struct {
|
||||
armed atomic.Bool
|
||||
}
|
||||
|
||||
func injectDatabaseFault(db *gorm.DB, name string, register func(db *gorm.DB, name string, fn func(*gorm.DB))) *databaseFault {
|
||||
fault := &databaseFault{}
|
||||
register(db, name, func(tx *gorm.DB) {
|
||||
if fault.armed.Load() {
|
||||
_ = tx.AddError(errInjectedDatabaseFailure)
|
||||
}
|
||||
})
|
||||
return fault
|
||||
}
|
||||
|
||||
func faultOnUpdate(db *gorm.DB, name string, fn func(*gorm.DB)) {
|
||||
_ = db.Callback().Update().Before("gorm:update").Register(name, fn)
|
||||
}
|
||||
|
||||
func faultOnQuery(db *gorm.DB, name string, fn func(*gorm.DB)) {
|
||||
_ = db.Callback().Query().Before("gorm:query").Register(name, fn)
|
||||
}
|
||||
|
||||
func TestArticleRepositoryWritesOnSQLite(t *testing.T) {
|
||||
setup := func(t *testing.T) (*persistence.ArticleRepository, *gorm.DB, uint32) {
|
||||
t.Helper()
|
||||
db := openContractSQLite(t)
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
defaultSite, err := persistence.NewSiteRepository(db).GetDefault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo, db, defaultSite.ID
|
||||
}
|
||||
|
||||
t.Run("GetAll returns every stored article", func(t *testing.T) {
|
||||
repo, _, siteID := setup(t)
|
||||
for range 2 {
|
||||
if _, err := repo.Create(articleOn(siteID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if all := repo.GetAll(); len(all) != 2 {
|
||||
t.Errorf("articles = %+v", all)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetAll returns an empty list when the query fails", func(t *testing.T) {
|
||||
repo, db, _ := setup(t)
|
||||
fault := injectDatabaseFault(db, "fault:getall", faultOnQuery)
|
||||
fault.armed.Store(true)
|
||||
|
||||
all := repo.GetAll()
|
||||
|
||||
if all == nil || len(all) != 0 {
|
||||
t.Errorf("articles = %#v", all)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateBody changes only the body", func(t *testing.T) {
|
||||
repo, _, siteID := setup(t)
|
||||
created, _ := repo.Create(articleOn(siteID))
|
||||
|
||||
updated, err := repo.UpdateBody(created.ID, "New body")
|
||||
reread, _ := repo.Get(created.ID)
|
||||
|
||||
if err != nil || updated.Body != "New body" || updated.Title != "Article" || reread.Body != "New body" {
|
||||
t.Errorf("updated %+v, reread %+v, err %v", updated, reread, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update applies each provided field", func(t *testing.T) {
|
||||
repo, db, siteID := setup(t)
|
||||
other, err := persistence.NewSiteRepository(db).Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, _ := repo.Create(articleOn(siteID))
|
||||
body := "New body"
|
||||
|
||||
bodied, err2 := repo.Update(created.ID, article.Update{Body: &body})
|
||||
moved, err3 := repo.Update(created.ID, article.Update{SiteID: &other.ID})
|
||||
untouched, err4 := repo.Update(created.ID, article.Update{})
|
||||
|
||||
if err2 != nil || err3 != nil || err4 != nil {
|
||||
t.Fatalf("errors %v %v %v", err2, err3, err4)
|
||||
}
|
||||
if bodied.Title != "Article" || bodied.Body != body || bodied.SiteID != siteID {
|
||||
t.Errorf("bodied = %+v", bodied)
|
||||
}
|
||||
if moved.SiteID != other.ID || moved.Title != "Article" || moved.Body != body {
|
||||
t.Errorf("moved = %+v", moved)
|
||||
}
|
||||
if untouched.Title != moved.Title || untouched.Body != moved.Body || untouched.SiteID != moved.SiteID {
|
||||
t.Errorf("untouched = %+v, expected %+v", untouched, moved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing articles are reported as not found", func(t *testing.T) {
|
||||
repo, _, _ := setup(t)
|
||||
body := "Body"
|
||||
|
||||
_, errBody := repo.UpdateBody(99, "x")
|
||||
_, errUpdate := repo.Update(99, article.Update{Body: &body})
|
||||
_, errOnline := repo.UpdateIsOnline(99, true)
|
||||
|
||||
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing save surfaces the database error", func(t *testing.T) {
|
||||
repo, db, siteID := setup(t)
|
||||
created, _ := repo.Create(articleOn(siteID))
|
||||
fault := injectDatabaseFault(db, "fault:save", faultOnUpdate)
|
||||
fault.armed.Store(true)
|
||||
body := "Body"
|
||||
|
||||
_, errBody := repo.UpdateBody(created.ID, "x")
|
||||
_, errUpdate := repo.Update(created.ID, article.Update{Body: &body})
|
||||
_, errOnline := repo.UpdateIsOnline(created.ID, true)
|
||||
|
||||
for name, err := range map[string]error{"UpdateBody": errBody, "Update": errUpdate, "UpdateIsOnline": errOnline} {
|
||||
if !errors.Is(err, errInjectedDatabaseFailure) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteRepositoryLookupsAndImages(t *testing.T) {
|
||||
t.Run("GetByKey finds a site and reports a missing one", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
created := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
|
||||
found, err := sites.GetByKey("shop")
|
||||
_, missing := sites.GetByKey("nope")
|
||||
|
||||
if err != nil || found.ID != created.ID || !errors.Is(missing, site.ErrSiteNotFound) {
|
||||
t.Errorf("found %+v, err %v, missing %v", found, err, missing)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AssignImage sets the image on the listed sites only", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
first := mustCreateSite(t, sites, "First", "first", false)
|
||||
second := mustCreateSite(t, sites, "Second", "second", false)
|
||||
|
||||
err := sites.AssignImage([]uint32{first.ID}, 7)
|
||||
emptyErr := sites.AssignImage(nil, 8)
|
||||
|
||||
assigned, _ := sites.Get(first.ID)
|
||||
other, _ := sites.Get(second.ID)
|
||||
if err != nil || emptyErr != nil || assigned.DefaultSocialImageID == nil || *assigned.DefaultSocialImageID != 7 || other.DefaultSocialImageID != nil {
|
||||
t.Errorf("assigned %+v, other %+v, errs %v %v", assigned, other, err, emptyErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteSeedReportsLookupFailure(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
_ = db.Callback().Query().Before("gorm:query").Register("fault:seed", func(tx *gorm.DB) {
|
||||
if _, ordered := tx.Statement.Clauses["ORDER BY"]; ordered {
|
||||
_ = tx.AddError(errInjectedDatabaseFailure)
|
||||
}
|
||||
})
|
||||
|
||||
err := database.SeedDefaultSite(db, migrationPublicURL)
|
||||
|
||||
if !errors.Is(err, errInjectedDatabaseFailure) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteHandlerLogsResponseWriteFailures(t *testing.T) {
|
||||
capture := func(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
previous := log.Writer()
|
||||
log.SetOutput(&out)
|
||||
t.Cleanup(func() { log.SetOutput(previous) })
|
||||
return &out
|
||||
}
|
||||
serve := func(e *siteEnv, w http.ResponseWriter, method string, path string) {
|
||||
c := e.container
|
||||
handler := siteHandler.NewHandler(c.ListSitesUseCase, c.CreateSiteUseCase, c.UpdateSiteUseCase, c.SetDefaultSiteUseCase, c.DeleteSiteUseCase)
|
||||
router := chi.NewRouter()
|
||||
router.Get("/", handler.List)
|
||||
router.Patch("/{id}/default", handler.SetDefault)
|
||||
router.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||
}
|
||||
|
||||
t.Run("A failed list write is logged", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
out := capture(t)
|
||||
|
||||
serve(e, &erroringResponseWriter{}, http.MethodGet, "/")
|
||||
|
||||
if !strings.Contains(out.String(), "Failed to write response") {
|
||||
t.Errorf("log = %q", out.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed single site write is logged", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
out := capture(t)
|
||||
|
||||
serve(e, &erroringResponseWriter{}, http.MethodPatch, "/2/default")
|
||||
|
||||
if !strings.Contains(out.String(), "Failed to write response") {
|
||||
t.Errorf("log = %q", out.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful writes log nothing", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
out := capture(t)
|
||||
|
||||
serve(e, httptest.NewRecorder(), http.MethodGet, "/")
|
||||
serve(e, httptest.NewRecorder(), http.MethodPatch, "/2/default")
|
||||
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("log = %q", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteDeletionIsCheckedInsideTheTransaction(t *testing.T) {
|
||||
setup := func(t *testing.T) (*persistence.SiteRepository, *persistence.ArticleRepository, site.Site, site.Site) {
|
||||
t.Helper()
|
||||
db := openContractSQLite(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
articles := persistence.NewArticleRepository(db)
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
blog := mustCreateSite(t, sites, "Blog", "blog", false)
|
||||
return sites, articles, shop, blog
|
||||
}
|
||||
|
||||
t.Run("A site that gained articles is not deleted without a destination", func(t *testing.T) {
|
||||
sites, articles, shop, _ := setup(t)
|
||||
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := sites.DeleteMovingArticles(shop.ID, nil)
|
||||
|
||||
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteHasArticles) || getErr != nil {
|
||||
t.Errorf("err = %v, get = %v", err, getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A missing destination changes nothing", func(t *testing.T) {
|
||||
sites, articles, shop, _ := setup(t)
|
||||
created, _ := articles.Create(articleOn(shop.ID))
|
||||
missing := uint32(999)
|
||||
|
||||
err := sites.DeleteMovingArticles(shop.ID, &missing)
|
||||
|
||||
moved, _ := articles.Get(created.ID)
|
||||
if _, getErr := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) || getErr != nil || moved.SiteID != shop.ID {
|
||||
t.Errorf("err = %v, get = %v, article site = %d", err, getErr, moved.SiteID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The default site is never deleted", func(t *testing.T) {
|
||||
sites, _, _, _ := setup(t)
|
||||
defaultSite, _ := sites.GetDefault()
|
||||
|
||||
err := sites.DeleteMovingArticles(defaultSite.ID, nil)
|
||||
|
||||
if !errors.Is(err, site.ErrDefaultSite) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Articles move to the destination and the site goes away", func(t *testing.T) {
|
||||
sites, articles, shop, blog := setup(t)
|
||||
created, _ := articles.Create(articleOn(shop.ID))
|
||||
|
||||
err := sites.DeleteMovingArticles(shop.ID, &blog.ID)
|
||||
|
||||
moved, _ := articles.Get(created.ID)
|
||||
_, getErr := sites.Get(shop.ID)
|
||||
if err != nil || moved.SiteID != blog.ID || !errors.Is(getErr, site.ErrSiteNotFound) {
|
||||
t.Errorf("err = %v, article site = %d, get = %v", err, moved.SiteID, getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty site is deleted without a destination", func(t *testing.T) {
|
||||
sites, _, shop, _ := setup(t)
|
||||
|
||||
err := sites.DeleteMovingArticles(shop.ID, nil)
|
||||
|
||||
if _, getErr := sites.Get(shop.ID); err != nil || !errors.Is(getErr, site.ErrSiteNotFound) {
|
||||
t.Errorf("err = %v, get = %v", err, getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Writing an article to a deleted site is refused", func(t *testing.T) {
|
||||
sites, articles, shop, blog := setup(t)
|
||||
created, _ := articles.Create(articleOn(blog.ID))
|
||||
if err := sites.DeleteMovingArticles(shop.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, createErr := articles.Create(articleOn(shop.ID))
|
||||
_, updateErr := articles.Update(created.ID, article.Update{SiteID: &shop.ID})
|
||||
|
||||
unchanged, _ := articles.Get(created.ID)
|
||||
if !errors.Is(createErr, site.ErrSiteNotFound) || !errors.Is(updateErr, site.ErrSiteNotFound) || unchanged.SiteID != blog.ID || len(articles.GetAll()) != 1 {
|
||||
t.Errorf("create %v, update %v, site %d", createErr, updateErr, unchanged.SiteID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteNameAndBaseURLRules(t *testing.T) {
|
||||
t.Run("Names are 1 to 100 characters", func(t *testing.T) {
|
||||
cases := map[string]bool{"": false, "a": true, strings.Repeat("a", 100): true, strings.Repeat("a", 101): false, strings.Repeat("é", 100): true}
|
||||
for name, valid := range cases {
|
||||
if site.IsValidName(name) != valid {
|
||||
t.Errorf("%d runes: expected %v", len([]rune(name)), valid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The seed and the normalizer share the origin rules", func(t *testing.T) {
|
||||
for _, raw := range []string{"", "ftp://example.com", "https://", "://bad", "example.com"} {
|
||||
_, seedErr := site.SeedBaseURL(raw)
|
||||
_, normalizeErr := site.NormalizeBaseURL(raw)
|
||||
if !errors.Is(seedErr, site.ErrInvalidSiteURL) || !errors.Is(normalizeErr, site.ErrInvalidSiteURL) {
|
||||
t.Errorf("%q: seed %v, normalize %v", raw, seedErr, normalizeErr)
|
||||
}
|
||||
}
|
||||
seeded, err := site.SeedBaseURL("HTTP://Example.COM/blog?x=1")
|
||||
if err != nil || seeded != "https://example.com" {
|
||||
t.Errorf("seeded = %q, %v", seeded, err)
|
||||
}
|
||||
if _, err := site.NormalizeBaseURL("http://example.com"); !errors.Is(err, site.ErrInvalidSiteURL) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSiteDomainRules(t *testing.T) {
|
||||
t.Run("NormalizeBaseURL accepts what the rule allows and strips the trailing slash", func(t *testing.T) {
|
||||
accepted := map[string]string{
|
||||
"https://shop.example.com": "https://shop.example.com",
|
||||
"https://shop.example.com/": "https://shop.example.com",
|
||||
"https://SHOP.example.com:8443": "https://shop.example.com:8443",
|
||||
"http://localhost": "http://localhost",
|
||||
"http://[::1]:8080/": "http://[::1]:8080",
|
||||
"HTTPS://shop.example.com": "https://shop.example.com",
|
||||
}
|
||||
for sent, stored := range accepted {
|
||||
if got, err := site.NormalizeBaseURL(sent); err != nil || got != stored {
|
||||
t.Errorf("%q gave %q, %v; expected %q", sent, got, err, stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NormalizeBaseURL refuses everything else", func(t *testing.T) {
|
||||
for _, sent := range []string{"", "http://shop.example.com", "https://", "https://shop.example.com/x", "https://shop.example.com?x", "https://shop.example.com/#top", "https://u:p@shop.example.com", "ftp://shop.example.com", "shop.example.com", "localhost:8080", "https://shop example.com"} {
|
||||
if got, err := site.NormalizeBaseURL(sent); !errors.Is(err, site.ErrInvalidSiteURL) {
|
||||
t.Errorf("%q gave %q, %v; expected ErrInvalidSiteURL", sent, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SeedBaseURL drops the path and upgrades non-loopback http", func(t *testing.T) {
|
||||
accepted := map[string]string{
|
||||
"https://www.example.com/blog": "https://www.example.com",
|
||||
"http://www.example.com": "https://www.example.com",
|
||||
"http://localhost:5173": "http://localhost:5173",
|
||||
"http://[::1]:8080/x": "http://[::1]:8080",
|
||||
"https://user@www.example.com": "https://www.example.com",
|
||||
}
|
||||
for sent, stored := range accepted {
|
||||
if got, err := site.SeedBaseURL(sent); err != nil || got != stored {
|
||||
t.Errorf("%q gave %q, %v; expected %q", sent, got, err, stored)
|
||||
}
|
||||
}
|
||||
for _, sent := range []string{"", "://not a url", "ftp://example.com", "example.com"} {
|
||||
if _, err := site.SeedBaseURL(sent); err == nil {
|
||||
t.Errorf("%q must be refused", sent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NormalizeLocale keeps valid tags and turns empty into none", func(t *testing.T) {
|
||||
for _, locale := range []string{"en", "fr-CA", "zh-Hant-TW", "yue"} {
|
||||
if got, err := site.NormalizeLocale(&locale); err != nil || got == nil || *got != locale {
|
||||
t.Errorf("%q gave %v, %v", locale, got, err)
|
||||
}
|
||||
}
|
||||
empty := ""
|
||||
if got, err := site.NormalizeLocale(&empty); err != nil || got != nil {
|
||||
t.Errorf("empty gave %v, %v", got, err)
|
||||
}
|
||||
if got, err := site.NormalizeLocale(nil); err != nil || got != nil {
|
||||
t.Errorf("nil gave %v, %v", got, err)
|
||||
}
|
||||
for _, locale := range []string{"english", "fr_FR", "f", "FR", "fr-"} {
|
||||
if _, err := site.NormalizeLocale(&locale); !errors.Is(err, site.ErrInvalidLocale) {
|
||||
t.Errorf("%q must be refused", locale)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsValidKey enforces the pattern and the length", func(t *testing.T) {
|
||||
valid := []string{"ab", "shop", "my-shop-2", "a1", strings.Repeat("a", 32)}
|
||||
invalid := []string{"", "a", "Shop", "-shop", "shop-", "my--shop", "my_shop", strings.Repeat("a", 33)}
|
||||
for _, key := range valid {
|
||||
if !site.IsValidKey(key) {
|
||||
t.Errorf("%q must be valid", key)
|
||||
}
|
||||
}
|
||||
for _, key := range invalid {
|
||||
if site.IsValidKey(key) {
|
||||
t.Errorf("%q must be invalid", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteUseCaseRules(t *testing.T) {
|
||||
t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites()).CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
|
||||
if err != nil || created.SiteID != 1 {
|
||||
t.Errorf("created %+v, %v", created, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Creating an article fails when no default site can be read", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites)
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
|
||||
|
||||
if !errors.Is(err, site.ErrSiteNotFound) || repo.createCalled {
|
||||
t.Errorf("err = %v, create called = %v", err, repo.createCalled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A destination site that does not exist leaves the article untouched", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 1}
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(1, "Main", "main", true)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites)
|
||||
missing := uint32(9)
|
||||
|
||||
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
|
||||
|
||||
if !errors.Is(err, site.ErrSiteNotFound) || repo.articles[1].SiteID != 1 {
|
||||
t.Errorf("err = %v, stored %+v", err, repo.articles[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting a site validates the destination before touching anything", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(1, "Main", "main", true)
|
||||
sites.seed(2, "Shop", "shop", false)
|
||||
repo.articles[1] = article.Article{ID: 1, Title: "Valid Title", SiteID: 2}
|
||||
uc := useCases.NewDeleteSiteUseCase(sites, discardRecorder)
|
||||
self := uint32(2)
|
||||
|
||||
for name, command := range map[string]useCases.DeleteSiteCommand{
|
||||
"itself": {ID: 2, ReassignTo: &self},
|
||||
"invalid": {ID: 2, ReassignInvalid: true},
|
||||
"zero": {ID: 2, ReassignTo: idPointer(0)},
|
||||
} {
|
||||
if err := uc.Delete(testCtx, command); !errors.Is(err, site.ErrInvalidReassign) {
|
||||
t.Errorf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
if sites.deleteCalls != 0 {
|
||||
t.Errorf("the repository was called %d times", sites.deleteCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Editing a site with is_default is refused", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
sites := newMockSiteRepo(repo)
|
||||
sites.seed(2, "Shop", "shop", false)
|
||||
uc := useCases.NewUpdateSiteUseCase(sites, newMockImageRepo(), discardRecorder)
|
||||
flag := true
|
||||
|
||||
_, err := uc.Update(testCtx, 2, site.Patch{IsDefault: &flag})
|
||||
|
||||
if !errors.Is(err, site.ErrUseSetDefault) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mediaListBody struct {
|
||||
Items []struct {
|
||||
ID uint32 `json:"id"`
|
||||
UsedIn int `json:"used_in"`
|
||||
} `json:"items"`
|
||||
Counts struct {
|
||||
Unused int `json:"unused"`
|
||||
} `json:"counts"`
|
||||
}
|
||||
|
||||
func (e *siteEnv) useImageOnSite(siteID uint32, imageID uint32) {
|
||||
s := e.sites.sites[siteID]
|
||||
s.DefaultSocialImageID = idPointer(imageID)
|
||||
e.sites.sites[siteID] = s
|
||||
}
|
||||
|
||||
func (e *siteEnv) seedDuplicates() {
|
||||
e.seedBanner(5, "banner.png", "samehash")
|
||||
e.seedBanner(6, "banner-copy.png", "samehash")
|
||||
}
|
||||
|
||||
func TestSitesMediaUsage(t *testing.T) {
|
||||
t.Run("An image used as a default social image is counted as used", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/media/", "")
|
||||
unused := e.owner(t, http.MethodGet, "/v1/media/?filter=unused", "")
|
||||
|
||||
var list, unusedList mediaListBody
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
_ = json.Unmarshal(unused.Body.Bytes(), &unusedList)
|
||||
if len(list.Items) != 1 || list.Items[0].UsedIn != 1 || list.Counts.Unused != 0 || len(unusedList.Items) != 0 {
|
||||
t.Errorf("list %s, unused %s", rec.Body.String(), unused.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting the image clears the site's field", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
|
||||
|
||||
if rec.Code != http.StatusNoContent || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
|
||||
t.Errorf("status %d, image %v", rec.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed image deletion keeps the site's field", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
e.images.failDeleteFor = map[uint32]error{5: errRepositoryFailure}
|
||||
|
||||
rec := e.owner(t, http.MethodDelete, "/v1/media/5", "")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || e.sites.sites[siteShopID].DefaultSocialImageID == nil {
|
||||
t.Errorf("status %d, image %v", rec.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Merging copies repoints the site", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedDuplicates()
|
||||
e.useImageOnSite(siteShopID, 6)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 5, "merge_ids": [6]}`)
|
||||
|
||||
var body map[string]int
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
kept := e.sites.sites[siteShopID].DefaultSocialImageID
|
||||
if rec.Code != http.StatusOK || body["sites_updated"] != 1 || kept == nil || *kept != 5 {
|
||||
t.Errorf("status %d, body %s, image %v", rec.Code, rec.Body.String(), kept)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purge repoints sites the same way", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedDuplicates()
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
e.useImageOnSite(siteShopID, 6)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
||||
|
||||
var body map[string]int
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
kept := e.sites.sites[siteShopID].DefaultSocialImageID
|
||||
if rec.Code != http.StatusOK || body["sites_updated"] != 1 || kept == nil || *kept != 5 {
|
||||
t.Errorf("status %d, body %s, image %v", rec.Code, rec.Body.String(), kept)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed purge group leaves the site pointing at an existing image", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedDuplicates()
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
e.useImageOnSite(siteShopID, 6)
|
||||
e.images.failDeleteFor = map[uint32]error{6: errRepositoryFailure}
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/media/purge", "")
|
||||
|
||||
referenced := e.sites.sites[siteShopID].DefaultSocialImageID
|
||||
if rec.Code != http.StatusOK || referenced == nil {
|
||||
t.Fatalf("status %d, image %v", rec.Code, referenced)
|
||||
}
|
||||
if _, exists := e.images.images[*referenced]; !exists {
|
||||
t.Errorf("the site points at the missing image %d", *referenced)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The usage lookup array shape is unchanged", func(t *testing.T) {
|
||||
e := newSiteEnvWithShop(t)
|
||||
e.seedBanner(5, "banner.png", "hash")
|
||||
e.useImageOnSite(siteShopID, 5)
|
||||
e.articles.articles[1] = articleUsingImage(5)
|
||||
|
||||
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
|
||||
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Body.String(), `[{"id":1,"title":`) || strings.Contains(rec.Body.String(), "site") {
|
||||
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func articleUsingImage(imageID uint32) article.Article {
|
||||
return article.Article{ID: 1, Title: "Spring release", Body: `<img src="` + producersBannerPath + strconv.Itoa(int(imageID)) + `.png">`, IsOnline: true, SiteID: siteMainID}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func openUnmigratedSQLite(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "sites.db"))
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
connection.DB.Logger = logger.Discard
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
return connection.DB
|
||||
}
|
||||
|
||||
func storedSites(t *testing.T, db *gorm.DB) []entity.Site {
|
||||
t.Helper()
|
||||
var sites []entity.Site
|
||||
if err := db.Order("id ASC").Find(&sites).Error; err != nil {
|
||||
t.Fatalf("read sites: %v", err)
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
func TestSiteSeedAndMigration(t *testing.T) {
|
||||
t.Run("A fresh database gets one default site from PUBLIC_URL", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
|
||||
if err := database.Migrate(db, "https://www.example.com/blog"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
sites := storedSites(t, db)
|
||||
if len(sites) != 1 {
|
||||
t.Fatalf("sites = %+v", sites)
|
||||
}
|
||||
seeded := sites[0]
|
||||
if seeded.Name != "Default" || seeded.Key != "default" || seeded.BaseURL != "https://www.example.com" || seeded.DefaultRobots != "index" || seeded.Locale != nil || seeded.DefaultSocialImageID != nil || !seeded.IsDefault {
|
||||
t.Errorf("seeded = %+v", seeded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The seed follows the base URL rule", func(t *testing.T) {
|
||||
cases := [][2]string{
|
||||
{"https://www.example.com/", "https://www.example.com"},
|
||||
{"https://www.example.com/a/b", "https://www.example.com"},
|
||||
{"http://www.example.com", "https://www.example.com"},
|
||||
{"http://localhost:8080", "http://localhost:8080"},
|
||||
{"http://127.0.0.1:8080/x", "http://127.0.0.1:8080"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c[0], func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, c[0]); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if got := storedSites(t, db)[0].BaseURL; got != c[1] {
|
||||
t.Errorf("base_url = %q, expected %q", got, c[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unparseable PUBLIC_URL stops the migration", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
|
||||
err := database.Migrate(db, "://not a url")
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "PUBLIC_URL") {
|
||||
t.Fatalf("expected an error naming PUBLIC_URL, got %v", err)
|
||||
}
|
||||
if sites := storedSites(t, db); len(sites) != 0 {
|
||||
t.Errorf("sites were created: %+v", sites)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Existing articles are backfilled", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := db.AutoMigrate(&legacyArticle{}); err != nil {
|
||||
t.Fatalf("legacy migrate: %v", err)
|
||||
}
|
||||
for index := 0; index < 4; index++ {
|
||||
if err := db.Create(&legacyArticle{Title: "Article", Body: "Body"}).Error; err != nil {
|
||||
t.Fatalf("seed article: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&legacyArticle{}, 4).Error; err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
defaultSite := storedSites(t, db)[0]
|
||||
var unassigned int64
|
||||
db.Unscoped().Model(&entity.Article{}).Where("site_id <> ?", defaultSite.ID).Count(&unassigned)
|
||||
var total int64
|
||||
db.Unscoped().Model(&entity.Article{}).Where("site_id = ?", defaultSite.ID).Count(&total)
|
||||
if unassigned != 0 || total != 4 {
|
||||
t.Errorf("assigned %d, others %d", total, unassigned)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The seed runs once", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
before := storedSites(t, db)
|
||||
|
||||
if err := database.Migrate(db, "https://other.example.org"); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
|
||||
after := storedSites(t, db)
|
||||
if len(after) != 1 || after[0] != before[0] {
|
||||
t.Errorf("before %+v, after %+v", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PUBLIC_URL is not used afterwards", func(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Update("base_url", "https://owner.example.net").Error; err != nil {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("restart migrate: %v", err)
|
||||
}
|
||||
|
||||
if got := storedSites(t, db)[0].BaseURL; got != "https://owner.example.net" {
|
||||
t.Errorf("base_url = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSiteRepositoryContract(t *testing.T) {
|
||||
backends := []contractBackend{
|
||||
{name: "sqlite", open: openContractSQLite},
|
||||
{name: "mysql", open: openContractMySQL},
|
||||
{name: "postgres", open: openContractPostgres},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository)
|
||||
}{
|
||||
{"the migration seeds one default site that reads back with its count", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
seeded, err := sites.GetDefault()
|
||||
if err != nil || seeded.Key != "default" || !seeded.IsDefault || seeded.ArticleCount != 0 {
|
||||
t.Fatalf("default = %+v, %v", seeded, err)
|
||||
}
|
||||
}},
|
||||
{"list puts the default first then orders by name and counts articles", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
zeta := mustCreateSite(t, sites, "Zeta", "zeta", false)
|
||||
alpha := mustCreateSite(t, sites, "alpha", "alpha", false)
|
||||
articles := persistence.NewArticleRepository(db)
|
||||
for range 2 {
|
||||
if _, err := articles.Create(articleOn(alpha.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
listed, err := sites.List()
|
||||
|
||||
if err != nil || len(listed) != 3 || listed[0].Key != "default" || listed[1].ID != alpha.ID || listed[2].ID != zeta.ID {
|
||||
t.Fatalf("listed = %+v, %v", listed, err)
|
||||
}
|
||||
if listed[1].ArticleCount != 2 || listed[2].ArticleCount != 0 {
|
||||
t.Errorf("counts = %+v", listed)
|
||||
}
|
||||
}},
|
||||
{"a duplicate key is refused", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
|
||||
if _, err := sites.Create(site.Site{Name: "Other", Key: "shop", BaseURL: "https://o.example.com", DefaultRobots: "index"}); err == nil {
|
||||
t.Error("expected the duplicate key to be refused")
|
||||
}
|
||||
}},
|
||||
{"creating a default site unflags the previous one", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
created := mustCreateSite(t, sites, "Shop", "shop", true)
|
||||
|
||||
defaultSite, err := sites.GetDefault()
|
||||
|
||||
if err != nil || defaultSite.ID != created.ID {
|
||||
t.Fatalf("default = %+v, %v", defaultSite, err)
|
||||
}
|
||||
assertSingleDefault(t, db)
|
||||
}},
|
||||
{"set default swaps the flag and an unknown id is not found", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
|
||||
swapped, err := sites.SetDefault(shop.ID)
|
||||
_, missing := sites.SetDefault(9999)
|
||||
|
||||
if err != nil || !swapped.IsDefault || !errors.Is(missing, site.ErrSiteNotFound) {
|
||||
t.Fatalf("swapped %+v %v, missing %v", swapped, err, missing)
|
||||
}
|
||||
assertSingleDefault(t, db)
|
||||
}},
|
||||
{"update changes the editable fields and clears nullable ones", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
shop.Name, shop.Locale = "Boutique", stringPointer("fr")
|
||||
|
||||
updated, err := sites.Update(shop)
|
||||
shop.Locale = nil
|
||||
cleared, clearErr := sites.Update(shop)
|
||||
|
||||
if err != nil || updated.Name != "Boutique" || updated.Locale == nil || *updated.Locale != "fr" {
|
||||
t.Fatalf("updated = %+v, %v", updated, err)
|
||||
}
|
||||
if clearErr != nil || cleared.Locale != nil {
|
||||
t.Errorf("cleared = %+v, %v", cleared, clearErr)
|
||||
}
|
||||
if _, err := sites.Update(site.Site{ID: 9999}); !errors.Is(err, site.ErrSiteNotFound) {
|
||||
t.Errorf("expected not found, got %v", err)
|
||||
}
|
||||
}},
|
||||
{"delete moves the articles and removes the site in one operation", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
defaultSite, _ := sites.GetDefault()
|
||||
articles := persistence.NewArticleRepository(db)
|
||||
for range 3 {
|
||||
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID)
|
||||
|
||||
moved, _ := sites.Get(defaultSite.ID)
|
||||
if err != nil || moved.ArticleCount != 3 {
|
||||
t.Fatalf("delete: %v, moved %+v", err, moved)
|
||||
}
|
||||
if _, err := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) {
|
||||
t.Errorf("site still readable: %v", err)
|
||||
}
|
||||
if err := sites.DeleteMovingArticles(shop.ID, nil); !errors.Is(err, site.ErrSiteNotFound) {
|
||||
t.Errorf("expected not found, got %v", err)
|
||||
}
|
||||
}},
|
||||
{"images are cleared and repointed", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
|
||||
image := mustCreateImage(t, newContractRepositories(db), "a.png", "hash-a")
|
||||
other := mustCreateImage(t, newContractRepositories(db), "b.png", "hash-a")
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
shop.DefaultSocialImageID = &other.ID
|
||||
if _, err := sites.Update(shop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repointed, err := sites.RepointImage([]uint32{other.ID}, image.ID)
|
||||
afterRepoint, _ := sites.Get(shop.ID)
|
||||
cleared, clearErr := sites.ClearImage(image.ID)
|
||||
afterClear, _ := sites.Get(shop.ID)
|
||||
|
||||
if err != nil || repointed != 1 || afterRepoint.DefaultSocialImageID == nil || *afterRepoint.DefaultSocialImageID != image.ID {
|
||||
t.Fatalf("repoint: %d %v %+v", repointed, err, afterRepoint)
|
||||
}
|
||||
if clearErr != nil || len(cleared) != 1 || cleared[0] != shop.ID || afterClear.DefaultSocialImageID != nil {
|
||||
t.Errorf("clear: %v %v %+v", cleared, clearErr, afterClear)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, backend := range backends {
|
||||
t.Run(backend.name, func(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
db := backend.open(t)
|
||||
c.run(t, db, persistence.NewSiteRepository(db))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRepositoryIsAtomicOnSQLite(t *testing.T) {
|
||||
t.Run("The swap is atomic", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
failWhenFlagged := "CREATE TRIGGER refuse_flag BEFORE UPDATE OF is_default ON sites WHEN NEW.is_default = 1 BEGIN SELECT RAISE(ABORT, 'refused'); END"
|
||||
if err := db.Exec(failWhenFlagged).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := sites.SetDefault(shop.ID); err == nil {
|
||||
t.Fatal("expected the swap to fail")
|
||||
}
|
||||
|
||||
defaultSite, err := sites.GetDefault()
|
||||
if err != nil || defaultSite.Key != "default" {
|
||||
t.Errorf("default = %+v, %v", defaultSite, err)
|
||||
}
|
||||
assertSingleDefault(t, db)
|
||||
})
|
||||
|
||||
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
|
||||
db := openContractSQLite(t)
|
||||
sites := persistence.NewSiteRepository(db)
|
||||
shop := mustCreateSite(t, sites, "Shop", "shop", false)
|
||||
defaultSite, _ := sites.GetDefault()
|
||||
articles := persistence.NewArticleRepository(db)
|
||||
for range 2 {
|
||||
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("CREATE TRIGGER refuse_delete BEFORE DELETE ON sites BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID); err == nil {
|
||||
t.Fatal("expected the delete to fail")
|
||||
}
|
||||
|
||||
still, err := sites.Get(shop.ID)
|
||||
if err != nil || still.ArticleCount != 2 {
|
||||
t.Errorf("site %+v, %v", still, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustCreateSite(t *testing.T, sites *persistence.SiteRepository, name string, key string, isDefault bool) site.Site {
|
||||
t.Helper()
|
||||
created, err := sites.Create(site.Site{Name: name, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index", IsDefault: isDefault})
|
||||
if err != nil {
|
||||
t.Fatalf("create site %q: %v", key, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func articleOn(siteID uint32) article.Article {
|
||||
return article.Article{Title: "Article", Body: "Body", SiteID: siteID}
|
||||
}
|
||||
|
||||
func assertSingleDefault(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var defaults int64
|
||||
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Count(&defaults).Error; err != nil || defaults != 1 {
|
||||
t.Errorf("expected exactly one default site, got %d (%v)", defaults, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type mockSiteRepo struct {
|
||||
sites map[uint32]site.Site
|
||||
articles *mockArticleRepo
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
createCalls int
|
||||
deleteCalls int
|
||||
deleteReassignments []*uint32
|
||||
setDefaultCalls int
|
||||
}
|
||||
|
||||
func newMockSiteRepo(articles *mockArticleRepo) *mockSiteRepo {
|
||||
return &mockSiteRepo{sites: map[uint32]site.Site{}, articles: articles}
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) seed(id uint32, name string, key string, isDefault bool) site.Site {
|
||||
seeded := site.Site{ID: id, Name: name, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: site.RobotsIndex, IsDefault: isDefault}
|
||||
m.sites[id] = seeded
|
||||
if id > m.nextID {
|
||||
m.nextID = id
|
||||
}
|
||||
return seeded
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) withCount(s site.Site) site.Site {
|
||||
s.ArticleCount = 0
|
||||
for _, a := range m.articles.articles {
|
||||
if a.SiteID == s.ID {
|
||||
s.ArticleCount++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) snapshot() map[uint32]site.Site {
|
||||
copied := make(map[uint32]site.Site, len(m.sites))
|
||||
for id, s := range m.sites {
|
||||
copied[id] = s
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) defaults() []uint32 {
|
||||
ids := []uint32{}
|
||||
for id, s := range m.sites {
|
||||
if s.IsDefault {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) List() ([]site.Site, error) {
|
||||
if err := m.fail.trigger("List"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]site.Site, 0, len(m.sites))
|
||||
for _, s := range m.sites {
|
||||
result = append(result, m.withCount(s))
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].IsDefault != result[j].IsDefault {
|
||||
return result[i].IsDefault
|
||||
}
|
||||
left, right := strings.ToLower(result[i].Name), strings.ToLower(result[j].Name)
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) Get(id uint32) (site.Site, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
found, ok := m.sites[id]
|
||||
if !ok {
|
||||
return site.Site{}, site.ErrSiteNotFound
|
||||
}
|
||||
return m.withCount(found), nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) GetByKey(key string) (site.Site, error) {
|
||||
if err := m.fail.trigger("GetByKey"); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
for _, s := range m.sites {
|
||||
if s.Key == key {
|
||||
return m.withCount(s), nil
|
||||
}
|
||||
}
|
||||
return site.Site{}, site.ErrSiteNotFound
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) GetDefault() (site.Site, error) {
|
||||
if err := m.fail.trigger("GetDefault"); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
for _, id := range m.defaults() {
|
||||
return m.withCount(m.sites[id]), nil
|
||||
}
|
||||
return site.Site{}, site.ErrSiteNotFound
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) Create(s site.Site) (site.Site, error) {
|
||||
m.createCalls++
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
if s.IsDefault {
|
||||
for id, other := range m.sites {
|
||||
other.IsDefault = false
|
||||
m.sites[id] = other
|
||||
}
|
||||
}
|
||||
m.nextID++
|
||||
s.ID = m.nextID
|
||||
m.sites[s.ID] = s
|
||||
return m.withCount(s), nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) Update(s site.Site) (site.Site, error) {
|
||||
if err := m.fail.trigger("Update"); err != nil {
|
||||
return site.Site{}, err
|
||||
}
|
||||
current, ok := m.sites[s.ID]
|
||||
if !ok {
|
||||
return site.Site{}, site.ErrSiteNotFound
|
||||
}
|
||||
current.Name, current.BaseURL, current.DefaultRobots = s.Name, s.BaseURL, s.DefaultRobots
|
||||
current.Locale, current.DefaultSocialImageID = s.Locale, s.DefaultSocialImageID
|
||||
m.sites[s.ID] = current
|
||||
return m.withCount(current), nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) SetDefault(id uint32) (site.Site, error) {
|
||||
m.setDefaultCalls++
|
||||
if _, ok := m.sites[id]; !ok {
|
||||
return site.Site{}, site.ErrSiteNotFound
|
||||
}
|
||||
before := m.snapshot()
|
||||
for otherID, other := range m.sites {
|
||||
other.IsDefault = false
|
||||
m.sites[otherID] = other
|
||||
}
|
||||
if err := m.fail.trigger("SetDefault"); err != nil {
|
||||
m.sites = before
|
||||
return site.Site{}, err
|
||||
}
|
||||
flagged := m.sites[id]
|
||||
flagged.IsDefault = true
|
||||
m.sites[id] = flagged
|
||||
return m.withCount(flagged), nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) DeleteMovingArticles(id uint32, reassignTo *uint32) error {
|
||||
m.deleteCalls++
|
||||
m.deleteReassignments = append(m.deleteReassignments, reassignTo)
|
||||
if err := m.fail.trigger("DeleteMovingArticles"); err != nil {
|
||||
return err
|
||||
}
|
||||
target, ok := m.sites[id]
|
||||
if !ok {
|
||||
return site.ErrSiteNotFound
|
||||
}
|
||||
if target.IsDefault {
|
||||
return site.ErrDefaultSite
|
||||
}
|
||||
if m.withCount(target).ArticleCount > 0 && reassignTo == nil {
|
||||
return site.ErrSiteHasArticles
|
||||
}
|
||||
if reassignTo != nil {
|
||||
if _, exists := m.sites[*reassignTo]; !exists {
|
||||
return site.ErrSiteNotFound
|
||||
}
|
||||
for articleID, a := range m.articles.articles {
|
||||
if a.SiteID == id {
|
||||
a.SiteID = *reassignTo
|
||||
m.articles.articles[articleID] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(m.sites, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) ClearImage(imageID uint32) ([]uint32, error) {
|
||||
if err := m.fail.trigger("ClearImage"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleared := []uint32{}
|
||||
for id, s := range m.sites {
|
||||
if s.DefaultSocialImageID != nil && *s.DefaultSocialImageID == imageID {
|
||||
s.DefaultSocialImageID = nil
|
||||
m.sites[id] = s
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(cleared, func(i, j int) bool { return cleared[i] < cleared[j] })
|
||||
return cleared, nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) AssignImage(siteIDs []uint32, imageID uint32) error {
|
||||
for _, id := range siteIDs {
|
||||
s := m.sites[id]
|
||||
assigned := imageID
|
||||
s.DefaultSocialImageID = &assigned
|
||||
m.sites[id] = s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
if err := m.fail.trigger("RepointImage"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
updated := 0
|
||||
for id, s := range m.sites {
|
||||
if s.DefaultSocialImageID == nil {
|
||||
continue
|
||||
}
|
||||
for _, from := range fromImageIDs {
|
||||
if *s.DefaultSocialImageID == from {
|
||||
target := toImageID
|
||||
s.DefaultSocialImageID = &target
|
||||
m.sites[id] = s
|
||||
updated++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
var _ site.Repository = &mockSiteRepo{}
|
||||
|
||||
const (
|
||||
siteMainID = 1
|
||||
siteShopID = 2
|
||||
siteBlogID = 3
|
||||
)
|
||||
|
||||
type siteEnv struct {
|
||||
*producersEnv
|
||||
sites *mockSiteRepo
|
||||
}
|
||||
|
||||
func newSiteEnv(t *testing.T) *siteEnv {
|
||||
t.Helper()
|
||||
p := newProducersEnv(t)
|
||||
sites := newMockSiteRepo(p.articles)
|
||||
recorder := p.audit.recorder
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites)
|
||||
c := p.container
|
||||
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites)
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites)
|
||||
c.MergeMediaUseCase = mergeMedia
|
||||
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites)
|
||||
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
|
||||
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
|
||||
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
|
||||
c.SetDefaultSiteUseCase = useCases.NewSetDefaultSiteUseCase(sites, recorder)
|
||||
c.DeleteSiteUseCase = useCases.NewDeleteSiteUseCase(sites, recorder)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(c, p.settings))
|
||||
p.router = router
|
||||
return &siteEnv{producersEnv: p, sites: sites}
|
||||
}
|
||||
|
||||
func newSiteEnvWithMain(t *testing.T) *siteEnv {
|
||||
t.Helper()
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(siteMainID, "Main", "main", true)
|
||||
return e
|
||||
}
|
||||
|
||||
func newSiteEnvWithShop(t *testing.T) *siteEnv {
|
||||
t.Helper()
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.sites.seed(siteShopID, "Shop", "shop", false)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *siteEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return e.as(t, apiKeyOwnerId, method, path, body)
|
||||
}
|
||||
|
||||
func (e *siteEnv) anonymous(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return e.as(t, 0, method, path, body)
|
||||
}
|
||||
|
||||
func (e *siteEnv) seedArticle(id uint32, siteID uint32, online bool) {
|
||||
e.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "Body", IsOnline: online, SiteID: siteID}
|
||||
}
|
||||
|
||||
func (e *siteEnv) articlesOn(siteID uint32) int {
|
||||
total := 0
|
||||
for _, a := range e.articles.articles {
|
||||
if a.SiteID == siteID {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func bodyText(rec *httptest.ResponseRecorder) string {
|
||||
return strings.TrimSpace(rec.Body.String())
|
||||
}
|
||||
|
||||
func decodeSiteBody(t *testing.T, rec *httptest.ResponseRecorder) site.Site {
|
||||
t.Helper()
|
||||
var decoded site.Site
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode site %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func decodeSiteList(t *testing.T, rec *httptest.ResponseRecorder) []site.Site {
|
||||
t.Helper()
|
||||
var decoded []site.Site
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("decode sites %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func expectSiteStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if rec.Code != status || bodyText(rec) != body {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, bodyText(rec))
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string { return &value }
|
||||
|
||||
func idPointer(value uint32) *uint32 { return &value }
|
||||
|
||||
func plainSites() *mockSiteRepo {
|
||||
sites := newMockSiteRepo(newMockArticleRepo())
|
||||
sites.seed(1, "Default", "default", true)
|
||||
return sites
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
seedLegacyRows(t, db)
|
||||
before := takeSqliteSnapshot(t, db)
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
after := takeSqliteSnapshot(t, db)
|
||||
@@ -173,17 +173,38 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
delete(after.schema, name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"articles", "idx_articles_site_id"} {
|
||||
delete(before.schema, name)
|
||||
delete(after.schema, name)
|
||||
}
|
||||
if !reflect.DeepEqual(before.schema, after.schema) {
|
||||
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
|
||||
}
|
||||
assertSiteColumnAdded(t, db)
|
||||
delete(after.rows["articles"][0], "site_id")
|
||||
if !reflect.DeepEqual(before.rows, after.rows) {
|
||||
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var columns []string
|
||||
if err := db.Raw("SELECT name FROM pragma_table_info('articles') ORDER BY cid").Scan(&columns).Error; err != nil {
|
||||
t.Fatalf("read article columns: %v", err)
|
||||
}
|
||||
if columns[len(columns)-1] != "site_id" {
|
||||
t.Errorf("expected site_id to be the only added article column, got %v", columns)
|
||||
}
|
||||
var sitesTable int
|
||||
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sites'").Scan(&sitesTable).Error; err != nil || sitesTable != 1 {
|
||||
t.Errorf("expected the sites table to exist: %d %v", sitesTable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path"} {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
|
||||
title := strings.Repeat("a", tc.length)
|
||||
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
||||
@@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
|
||||
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
|
||||
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
t.Run("ListArticlesPage unverified", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -208,7 +208,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
t.Run("ListArticlesPage verified", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -219,7 +219,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
|
||||
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
|
||||
if err != nil {
|
||||
@@ -243,7 +243,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateBody_id1", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -255,7 +255,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateBody_id9", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||
@@ -264,7 +264,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -276,7 +276,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
|
||||
|
||||
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
|
||||
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites())
|
||||
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
|
||||
if !errors.Is(err, article.ErrArticleNotFound) {
|
||||
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
|
||||
@@ -386,7 +386,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
t.Run("id_1", func(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
repo.images[1] = imageDomain.Image{ID: 1}
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
err := uc.Delete(testCtx, 1)
|
||||
if err != nil {
|
||||
@@ -400,7 +400,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
t.Run("id_9", func(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
repo.images[1] = imageDomain.Image{ID: 1}
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites())
|
||||
|
||||
err := uc.Delete(testCtx, 9)
|
||||
if !errors.Is(err, imageDomain.ErrImageNotFound) {
|
||||
|
||||
@@ -21,11 +21,11 @@ import (
|
||||
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites()),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/auth/me',
|
||||
],
|
||||
Settings: ['GET /v1/settings'],
|
||||
Sites: ['GET /v1/site/'],
|
||||
}
|
||||
|
||||
const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
@@ -65,6 +66,12 @@ const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
'DELETE /v1/auth/register',
|
||||
'POST /v1/auth/password-reset',
|
||||
],
|
||||
'Sites · owner only': [
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
],
|
||||
'Settings · owner only': ['PATCH /v1/settings'],
|
||||
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
|
||||
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
|
||||
@@ -79,7 +86,27 @@ const EXPECTED_PUBLIC = [
|
||||
'GET /healthz',
|
||||
]
|
||||
|
||||
const ARTICLE_KEYS = ['id', 'title', 'body', 'body_format', 'is_online', 'created_at', 'updated_at']
|
||||
const ARTICLE_KEYS = [
|
||||
'id',
|
||||
'title',
|
||||
'body',
|
||||
'body_format',
|
||||
'is_online',
|
||||
'site_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
const SITE_KEYS = [
|
||||
'id',
|
||||
'name',
|
||||
'key',
|
||||
'base_url',
|
||||
'default_social_image_id',
|
||||
'default_robots',
|
||||
'locale',
|
||||
'is_default',
|
||||
'article_count',
|
||||
]
|
||||
const MEDIA_ITEM_KEYS = [
|
||||
'id',
|
||||
'filename',
|
||||
@@ -139,20 +166,20 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 24 key-callable routes in 4 groups, in order', () => {
|
||||
it('holds exactly the 25 key-callable routes in 5 groups, in order', () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(24)
|
||||
expect(allEndpoints()).toHaveLength(25)
|
||||
})
|
||||
|
||||
it('holds the 17 session-only rows in 5 sub-groups', () => {
|
||||
it('holds the 21 session-only rows in 6 sub-groups', () => {
|
||||
expect(
|
||||
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
|
||||
).toEqual(EXPECTED_SESSION)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(17)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(21)
|
||||
})
|
||||
|
||||
it('holds the 6 public auth flow rows', () => {
|
||||
@@ -174,7 +201,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(4 + 24 + 17 + 6)
|
||||
expect(described).toHaveLength(5 + 25 + 21 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -296,8 +323,32 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
expectOnlyKeys(parsed(find('PATCH /v1/media/{id}')), MEDIA_ITEM_KEYS)
|
||||
})
|
||||
|
||||
it('uses articles_updated and merged for the merge', () => {
|
||||
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual(['articles_updated', 'merged'])
|
||||
it('uses articles_updated, sites_updated and merged for the merge', () => {
|
||||
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual([
|
||||
'articles_updated',
|
||||
'merged',
|
||||
'sites_updated',
|
||||
])
|
||||
})
|
||||
|
||||
it('adds sites_updated to the purge result', () => {
|
||||
expect(keysOf(parsed(find('POST /v1/media/purge')))).toEqual([
|
||||
'articles_updated',
|
||||
'failed_groups',
|
||||
'groups_purged',
|
||||
'groups_total',
|
||||
'merged',
|
||||
'sites_updated',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the site keys for the site list', () => {
|
||||
const list = parsed(find('GET /v1/site/'))
|
||||
expect(Array.isArray(list)).toBe(true)
|
||||
for (const item of list) {
|
||||
expect(keysOf(item)).toEqual([...SITE_KEYS].sort())
|
||||
}
|
||||
expect(list[0].is_default).toBe(true)
|
||||
})
|
||||
|
||||
it('uses id, title and published for usage items', () => {
|
||||
@@ -360,7 +411,10 @@ describe('Request bodies and query parameters', () => {
|
||||
])
|
||||
expect(find('POST /v1/article/').body[0]!.rule).toContain('3 to 200')
|
||||
expect(find('POST /v1/article/').body[1]!.rule).toContain('10 000')
|
||||
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([['body', 'string', true]])
|
||||
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([
|
||||
['body', 'string', false],
|
||||
['site_id', 'number', false],
|
||||
])
|
||||
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
|
||||
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
|
||||
expect(bodyOf('POST /v1/media/merge')).toEqual([
|
||||
@@ -397,7 +451,7 @@ describe('Request bodies and query parameters', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lists page, size, q, status and sort on the article list only', () => {
|
||||
it('lists page, size, q, status, sort and site on the article list only', () => {
|
||||
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
|
||||
'format',
|
||||
'page',
|
||||
@@ -405,6 +459,7 @@ describe('Request bodies and query parameters', () => {
|
||||
'q',
|
||||
'status',
|
||||
'sort',
|
||||
'site',
|
||||
])
|
||||
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
|
||||
})
|
||||
@@ -516,7 +571,7 @@ describe('Per-endpoint error captions use the real strings', () => {
|
||||
describe('Session only rows', () => {
|
||||
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
|
||||
|
||||
it('claims 403 only on verify, DELETE register, settings, activity, api-key and system rows', () => {
|
||||
it('claims 403 only on verify, DELETE register, sites, settings, activity, api-key and system rows', () => {
|
||||
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
|
||||
'GET /v1/system/update',
|
||||
'POST /v1/system/update/check',
|
||||
@@ -524,6 +579,10 @@ describe('Session only rows', () => {
|
||||
'POST /v1/system/update/restart',
|
||||
'POST /v1/auth/verify',
|
||||
'DELETE /v1/auth/register',
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
'PATCH /v1/settings',
|
||||
'GET /v1/activity',
|
||||
'GET /v1/activity/summary',
|
||||
|
||||
@@ -126,6 +126,12 @@ const ARTICLE_LIST_QUERY: ApiField[] = [
|
||||
required: false,
|
||||
rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.',
|
||||
},
|
||||
{
|
||||
name: 'site',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
|
||||
},
|
||||
]
|
||||
|
||||
const MEDIA_QUERY: ApiField[] = [
|
||||
@@ -168,6 +174,7 @@ const article = (overrides: Record<string, unknown> = {}) => ({
|
||||
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
|
||||
body_format: 'markdown',
|
||||
is_online: true,
|
||||
site_id: 1,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-10T07:00:00Z',
|
||||
...overrides,
|
||||
@@ -189,6 +196,19 @@ const mediaItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const site = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
name: 'Renew blog',
|
||||
key: 'renew-blog',
|
||||
base_url: 'https://blog.renew.example',
|
||||
default_social_image_id: 4,
|
||||
default_robots: 'index',
|
||||
locale: 'en',
|
||||
is_default: true,
|
||||
article_count: 30,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const me = {
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
@@ -374,9 +394,15 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'art-update',
|
||||
'PATCH',
|
||||
'/v1/article/{id}',
|
||||
'Update the body of an article.',
|
||||
'Update the body of an article, or move it to another site. Send body, site_id or both.',
|
||||
'Verified user',
|
||||
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
|
||||
verified(
|
||||
articleErrors(
|
||||
e(400, BODY_MISMATCH),
|
||||
...articleIdErrors,
|
||||
e(404, 'site_not_found'),
|
||||
),
|
||||
),
|
||||
json(article({ body: 'Updated text.', is_online: false })),
|
||||
{
|
||||
query: [FORMAT_QUERY],
|
||||
@@ -385,11 +411,17 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'string',
|
||||
required: true,
|
||||
rule: 'Up to 10 000 characters.',
|
||||
required: false,
|
||||
rule: 'Up to 10 000 characters. An empty string empties the body; null is refused.',
|
||||
},
|
||||
{
|
||||
name: 'site_id',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: 'The id of an existing site, 1 or more. null is refused. At least one of body and site_id is required.',
|
||||
},
|
||||
],
|
||||
bodySample: { body: 'Updated text.' },
|
||||
bodySample: { body: 'Updated text.', site_id: 2 },
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
@@ -523,7 +555,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
e(401, UNAUTHORIZED),
|
||||
e(409, 'Some of the images to merge changed. Refresh and try again.'),
|
||||
]),
|
||||
json({ articles_updated: 5, merged: 2 }),
|
||||
json({ articles_updated: 5, sites_updated: 1, merged: 2 }),
|
||||
{
|
||||
bodyKind: 'json',
|
||||
body: [
|
||||
@@ -629,6 +661,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
groups_purged: 1,
|
||||
merged: 2,
|
||||
articles_updated: 5,
|
||||
sites_updated: 1,
|
||||
failed_groups: 0,
|
||||
}),
|
||||
{
|
||||
@@ -784,6 +817,36 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sites',
|
||||
title: 'Sites',
|
||||
description:
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
|
||||
endpoints: [
|
||||
endpoint(
|
||||
'site-list',
|
||||
'GET',
|
||||
'/v1/site/',
|
||||
'List the sites, the default first and then by name, each with its article count.',
|
||||
'Verified user',
|
||||
verified([e(401, UNAUTHORIZED)]),
|
||||
json([
|
||||
site(),
|
||||
site({
|
||||
id: 2,
|
||||
name: 'Renew shop',
|
||||
key: 'renew-shop',
|
||||
base_url: 'https://shop.renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'noindex',
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 4,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const row = (
|
||||
@@ -819,6 +882,15 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
row('POST', '/v1/auth/password-reset', 'Reset password', false),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sites · owner only',
|
||||
rows: [
|
||||
row('POST', '/v1/site/', 'Create a site'),
|
||||
row('PATCH', '/v1/site/{id}', 'Edit a site'),
|
||||
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
|
||||
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings · owner only',
|
||||
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')],
|
||||
|
||||
@@ -22,6 +22,7 @@ const SECTION_LABELS = [
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
@@ -365,16 +366,16 @@ describe('Authentication', () => {
|
||||
})
|
||||
|
||||
describe('Session only', () => {
|
||||
it('renders the banner, 17 rows without expand controls and the 403 chips', async () => {
|
||||
it('renders the banner, 21 rows without expand controls and the 403 chips', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const session = section(wrapper, 'session')
|
||||
|
||||
expect(session.text()).toContain('17 routes')
|
||||
expect(session.text()).toContain('21 routes')
|
||||
expect(session.text()).toContain(
|
||||
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
|
||||
)
|
||||
const rows = session.findAll('[data-session-row]')
|
||||
expect(rows).toHaveLength(17)
|
||||
expect(rows).toHaveLength(21)
|
||||
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
|
||||
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
|
||||
|
||||
@@ -386,6 +387,10 @@ describe('Session only', () => {
|
||||
'POST /v1/system/update/restart',
|
||||
'POST /v1/auth/verify',
|
||||
'DELETE /v1/auth/register',
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
'PATCH /v1/settings',
|
||||
'GET /v1/activity',
|
||||
'GET /v1/activity/summary',
|
||||
|
||||
Reference in new issue
Block a user