Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421cfafec3 | ||
|
|
2303b12377 | ||
|
|
1a55a39425 | ||
|
|
e82d0ce254 | ||
|
|
161ddc0f7d | ||
|
|
c1e73bda81 | ||
|
|
508a8ab77e |
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainMail "RenewCMS/internal/domain/mail"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainUpdate "RenewCMS/internal/domain/update"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/markdown"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"crypto/ed25519"
|
||||
@@ -35,6 +37,7 @@ type UseCases struct {
|
||||
apiKeyAuthOnce sync.Once
|
||||
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||
Token *jwtauth.JWTAuth
|
||||
PurgeScheduler *scheduler.Runner
|
||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||
GetArticleUseCase *useCases.GetArticleUseCase
|
||||
ListArticlesUseCase *useCases.ListArticlesUseCase
|
||||
@@ -62,6 +65,10 @@ type UseCases struct {
|
||||
DeleteMediaUseCase *useCases.DeleteMediaUseCase
|
||||
MergeMediaUseCase *useCases.MergeMediaUseCase
|
||||
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
|
||||
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
|
||||
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
|
||||
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
|
||||
RunPurgeUseCase *useCases.RunPurgeUseCase
|
||||
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||
@@ -80,6 +87,7 @@ type Repositories struct {
|
||||
ImageRepo domainImage.Repository
|
||||
InviteRepo domainInvite.Repository
|
||||
APIKeyRepo domainAPIKey.Repository
|
||||
PurgeSettingsRepo domainPurge.Repository
|
||||
MailRepo domainMail.Repository
|
||||
ReleaseFeed domainUpdate.ReleaseFeed
|
||||
UpdateStore domainUpdate.StatusStore
|
||||
@@ -116,7 +124,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
||||
panic("Unable to open the database: " + err.Error())
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -147,6 +155,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||
@@ -166,8 +175,13 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
|
||||
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
|
||||
|
||||
return &UseCases{
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
@@ -194,8 +208,12 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
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),
|
||||
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),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
|
||||
|
||||
@@ -2,6 +2,7 @@ package frontend
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/assets"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -93,12 +94,15 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "File not seekable", http.StatusInternalServerError)
|
||||
html, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
http.Error(w, "Index not readable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", indexCacheControl)
|
||||
http.ServeContent(w, r, "index.html", h.modTime, content)
|
||||
if policy := w.Header().Get("Content-Security-Policy"); policy != "" {
|
||||
w.Header().Set("Content-Security-Policy", AllowInlineScripts(policy, html))
|
||||
}
|
||||
http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(html))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const scriptSrcSelf = "script-src 'self'"
|
||||
|
||||
var inlineScriptPattern = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
|
||||
var srcAttributePattern = regexp.MustCompile(`(?i)\ssrc\s*=`)
|
||||
|
||||
func inlineScriptSources(html []byte) []string {
|
||||
var sources []string
|
||||
for _, match := range inlineScriptPattern.FindAllSubmatch(html, -1) {
|
||||
if srcAttributePattern.Match(match[1]) || strings.TrimSpace(string(match[2])) == "" {
|
||||
continue
|
||||
}
|
||||
digest := sha256.Sum256(match[2])
|
||||
sources = append(sources, "'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'")
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func AllowInlineScripts(policy string, html []byte) string {
|
||||
sources := inlineScriptSources(html)
|
||||
if len(sources) == 0 {
|
||||
return policy
|
||||
}
|
||||
return strings.Replace(policy, scriptSrcSelf, scriptSrcSelf+" "+strings.Join(sources, " "), 1)
|
||||
}
|
||||
@@ -19,12 +19,16 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
DeleteUseCase *useCases.DeleteMediaUseCase
|
||||
MergeUseCase *useCases.MergeMediaUseCase
|
||||
UsageUseCase *useCases.GetMediaUsageUseCase
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
DeleteUseCase *useCases.DeleteMediaUseCase
|
||||
MergeUseCase *useCases.MergeMediaUseCase
|
||||
UsageUseCase *useCases.GetMediaUsageUseCase
|
||||
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
|
||||
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
|
||||
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
|
||||
RunPurgeUseCase *useCases.RunPurgeUseCase
|
||||
}
|
||||
|
||||
type RenameRequest struct {
|
||||
@@ -74,14 +78,18 @@ type mergeResponse struct {
|
||||
Merged int `json:"merged"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler {
|
||||
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
|
||||
return &Handler{
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
PurgeSettingsUseCase: purgeSettings,
|
||||
UpdatePurgeSettingsUseCase: updatePurgeSettings,
|
||||
PreviewPurgeUseCase: previewPurge,
|
||||
RunPurgeUseCase: runPurge,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeSettingsRequest struct {
|
||||
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
|
||||
}
|
||||
|
||||
type PurgeRunRequest struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type purgeSettingsResponse struct {
|
||||
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
|
||||
LastPurgeAt *time.Time `json:"last_purge_at"`
|
||||
LastPurgeMerged int `json:"last_purge_merged"`
|
||||
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
NextPurgeAt *time.Time `json:"next_purge_at"`
|
||||
}
|
||||
|
||||
type purgePreviewGroupResponse struct {
|
||||
KeepID uint32 `json:"keep_id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
UsedIn int `json:"used_in"`
|
||||
Merged int `json:"merged"`
|
||||
}
|
||||
|
||||
type purgePreviewResponse struct {
|
||||
Groups int `json:"groups"`
|
||||
Copies int `json:"copies"`
|
||||
Articles int `json:"articles"`
|
||||
List []purgePreviewGroupResponse `json:"list"`
|
||||
More int `json:"more"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type purgeRunResponse struct {
|
||||
GroupsTotal int `json:"groups_total"`
|
||||
GroupsPurged int `json:"groups_purged"`
|
||||
Merged int `json:"merged"`
|
||||
ArticlesUpdated int `json:"articles_updated"`
|
||||
FailedGroups int `json:"failed_groups"`
|
||||
}
|
||||
|
||||
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
|
||||
return purgeSettingsResponse{
|
||||
AutoPurgeEnabled: settings.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: settings.LastPurgeAt,
|
||||
LastPurgeMerged: settings.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
|
||||
EnabledAt: settings.EnabledAt,
|
||||
NextPurgeAt: settings.NextPurgeAt(),
|
||||
}
|
||||
}
|
||||
|
||||
func writePurgeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, purge.ErrInvalidPurgeInterval):
|
||||
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||
case errors.Is(err, purge.ErrPurgeConflict):
|
||||
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, purge.ErrPurgeBusy):
|
||||
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
writeInternalServerError(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
settings, err := h.PurgeSettingsUseCase.Get()
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var request PurgeSettingsRequest
|
||||
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, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
|
||||
AutoPurgeEnabled: request.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
|
||||
})
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
|
||||
preview, err := h.PreviewPurgeUseCase.Preview()
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
|
||||
for _, group := range preview.List {
|
||||
list = append(list, purgePreviewGroupResponse{
|
||||
KeepID: group.KeepID,
|
||||
Filename: group.Filename,
|
||||
URL: group.URL,
|
||||
UsedIn: group.UsedIn,
|
||||
Merged: group.Merged,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, purgePreviewResponse{
|
||||
Groups: preview.Groups,
|
||||
Copies: preview.Copies,
|
||||
Articles: preview.Articles,
|
||||
List: list,
|
||||
More: preview.More,
|
||||
Fingerprint: preview.Fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request PurgeRunRequest
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.RunPurgeUseCase.Run(request.Fingerprint)
|
||||
if err != nil {
|
||||
writePurgeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, purgeRunResponse{
|
||||
GroupsTotal: result.GroupsTotal,
|
||||
GroupsPurged: result.GroupsPurged,
|
||||
Merged: result.Merged,
|
||||
ArticlesUpdated: result.ArticlesUpdated,
|
||||
FailedGroups: result.FailedGroups,
|
||||
})
|
||||
}
|
||||
@@ -95,3 +95,21 @@ func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Hand
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, ok := resolveCurrentUser(getUser, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
|
||||
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package middleware
|
||||
import "net/http"
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"script-src 'self' 'wasm-unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
|
||||
@@ -38,3 +38,7 @@ const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html
|
||||
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
const PurgeBusyErrorMessage = "A purge is already running."
|
||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||
+8
-1
@@ -208,13 +208,20 @@ func NewAPIKeyRouter(container *UseCases) http.Handler {
|
||||
func NewMediaRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
|
||||
|
||||
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.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
|
||||
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
|
||||
r.Get("/purge-preview", mediaHandler.PreviewPurge)
|
||||
r.Post("/purge", mediaHandler.RunPurge)
|
||||
})
|
||||
r.Get("/", mediaHandler.List)
|
||||
r.Post("/", mediaHandler.Upload)
|
||||
r.Post("/merge", mediaHandler.Merge)
|
||||
|
||||
+12
-3
@@ -81,7 +81,7 @@ func run() error {
|
||||
validator.InitValidator()
|
||||
|
||||
server := newServer(api.InitRoutes(container, settings), settings)
|
||||
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record)
|
||||
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.PurgeScheduler.Run)
|
||||
}
|
||||
|
||||
func initEnvVariables() {
|
||||
@@ -132,9 +132,18 @@ func newServer(handler http.Handler, settings config.Settings) *http.Server {
|
||||
}
|
||||
}
|
||||
|
||||
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error) error {
|
||||
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error, runScheduler func(context.Context)) error {
|
||||
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
schedulerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(schedulerDone)
|
||||
runScheduler(stopSignal)
|
||||
}()
|
||||
defer func() {
|
||||
stop()
|
||||
<-schedulerDone
|
||||
}()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go listen(server, serverErr, settings, recordRunning)
|
||||
|
||||
@@ -30,6 +30,29 @@ Nothing under `internal/` imports `api/`.
|
||||
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
|
||||
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
|
||||
|
||||
### Media purge
|
||||
|
||||
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`, and `SaveLastRun`, which writes only the `last_purge_*` columns so a settings change made during a run is never overwritten) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
|
||||
|
||||
The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `RunPurge` depend only on domain interfaces, an injected clock and an injected `*log.Logger`, so a scheduler can call them. `PreviewPurge` and `RunPurge` share one plan (`purgePlan.go`): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest `created_at`, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. `RunPurge` takes a non-blocking in-process lock (`ErrPurgeBusy` for a concurrent call), refuses a non-matching fingerprint with `ErrPurgeConflict`, merges group by group with `MergeMedia` (a failing group is counted and skipped), unions the `UpdatedArticleIDs` of the fully merged groups, and records `last_purge_*` after every run.
|
||||
|
||||
#### Automatic purge scheduler
|
||||
|
||||
`PurgeSchedulerUseCase.Tick(now)` returns the `purge.RunResult` of the run, whether it ran and an error. It reads the purge settings on every call and runs `RunPurge.Run(nil)` (no fingerprint) when `Settings.NextPurgeAt()` is set and `now` is not before it. It never writes settings: only `RunPurge` records `last_purge_*`, through `SaveLastRun`, which moves the anchor forward even when nothing was purged. The runner in `internal/infrastructure/scheduler` calls `Tick` and logs one line with `groups_purged`, `merged`, `articles_updated` and `failed_groups` when a run happened. It calls `Tick` once after a startup delay of 1 minute, which catches up a run missed during downtime in a single purge, then once every hour. `api/dependecyInjection.go` builds it and `cmd/server/main.go` starts it with the signal context of the server. Changes to the settings apply on the next tick, with no restart.
|
||||
|
||||
- Overlap guard: a manual run holds the `RunPurge` lock, so `Tick` returns `ErrPurgeBusy` at once. The runner treats it as a skip, logs nothing and re-evaluates at the next tick.
|
||||
- Failures: any other error is logged and the loop keeps going. There is no retry inside a tick; the next hourly tick evaluates the schedule again.
|
||||
- Shutdown: on SIGINT or SIGTERM the runner stops taking ticks and waits, with no time limit, for a `Tick` in progress. `main` waits for the runner to return before the database is closed.
|
||||
|
||||
`middleware.RequireOwnerOrAdmin` lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the `/{id}` routes:
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /v1/media/purge-settings` | Settings with `next_purge_at`. |
|
||||
| `PATCH /v1/media/purge-settings` | Partial update of `auto_purge_enabled` and `auto_purge_interval_days`. |
|
||||
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
|
||||
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
|
||||
|
||||
### Image metadata backfill
|
||||
|
||||
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
|
||||
|
||||
@@ -33,6 +33,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
|
||||
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
|
||||
|
||||
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
|
||||
|
||||
@@ -34,7 +34,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
|
||||
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
|
||||
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
|
||||
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
@@ -42,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
## Other boards
|
||||
|
||||
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
||||
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
|
||||
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
|
||||
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
|
||||
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
||||
|
||||
@@ -814,3 +814,18 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
|
||||
| RestartOverlay | custom full-page `role="status"` | title, text, startedAt, slow | reload | App shell |
|
||||
| UpdateBanner | UAlert | state: 'available'\|'ready' | go-to-settings, dismiss | Home (owner) |
|
||||
| UpdatePill | UBadge | none | none | DashboardShell Settings item (dot on mobile) |
|
||||
|
||||
## Added with Media cleanup
|
||||
### PurgeDuplicatesDialog
|
||||
Used by Settings ("Purge now") and Media, Duplicates tab ("Purge duplicates"). Built on `UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first, confirm is `color="error"`.
|
||||
- Props: `open`, `preview` (`groups`, `copies`, `articles`, `list[]`, `more`), `status` (`loading | ready | purging | nothing | error | partial | conflict`).
|
||||
- While `purging`: confirm shows `loader-circle` and reads "Purging…"; `dismissible` false, Cancel disabled.
|
||||
- Group row: 40×30 thumbnail, filename, "Kept · used in N articles", right-aligned "N copies merged". Max 5 rows, then "and N more groups".
|
||||
- Notices sit above the summary in a `UAlert` (soft): error for failure, warning for partial and conflict.
|
||||
- Emits `purged` (with counts). The parent shows the toast.
|
||||
|
||||
### MediaCleanupCard
|
||||
Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" suffix, read-only lines, divider, outline button. Saves on change and on blur or Enter; shows "Saving…" then "Saved" in the header. Read-only mode disables controls, hides the button and shows an info `UAlert`.
|
||||
|
||||
### Media toolbar button
|
||||
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
|
||||
@@ -15,11 +15,11 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
||||
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
|
||||
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
|
||||
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
||||
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
|
||||
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
|
||||
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
||||
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
|
||||
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Media
|
||||
|
||||
- **Route:** `/media` (verified users)
|
||||
- **API:** GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
|
||||
- **API:** GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
|
||||
@@ -12,7 +12,7 @@ New route for verified users, in the sidebar between Articles and Users with the
|
||||
|
||||
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected).
|
||||
|
||||
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. Below md the toolbar stacks: search, tabs, then sort and toggle.
|
||||
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. On the Duplicates tab, owners and admins also get a secondary "Purge duplicates" button (`merge` icon) after the sort menu (full width, 44 px, in the stacked toolbar on mobile), hidden while there are no groups; it opens the PurgeDuplicatesDialog described in [settings](settings.md#media-cleanup-second-card). Below md the toolbar stacks: search, tabs, then sort and toggle.
|
||||
|
||||
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid.
|
||||
|
||||
@@ -149,6 +149,7 @@ Grid items are buttons, one tab stop each. Enter opens the detail panel, Space t
|
||||
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
|
||||
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
|
||||
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
|
||||
| Duplicates: purge all | Owner or admin, groups exist. "Purge duplicates" opens the purge dialog (preview, confirm, progress, results), the same one as Settings "Purge now". "Merge into this one" is unchanged. |
|
||||
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
|
||||
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
|
||||
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Settings
|
||||
|
||||
- **Route:** `/settings`
|
||||
- **API:** GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
|
||||
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (second card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status).
|
||||
@@ -125,3 +125,55 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
|
||||
| Admin view | Admins see the version and the note, with no buttons. Up to date: "You're up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill. |
|
||||
| Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. |
|
||||
| Update already running (409) | Another owner started it. The card switches to that run's progress. |
|
||||
|
||||
## Media cleanup (second card)
|
||||
|
||||
Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `MediaFrame.dc.html`).
|
||||
API: `GET/PATCH /v1/media/purge-settings` (owner, admin), `GET /v1/media/purge-preview`, `POST /v1/media/purge`. 401 and 403 are global: no local copy.
|
||||
|
||||
Rule: a group is purged only if at least one copy is used in an article. All copies merge into the one used in the most articles (ties: oldest, then lowest id). Groups with no used copy are left alone. Automatic purge is off by default.
|
||||
|
||||
### Card
|
||||
Second card below Version, 20 px gap, same card styling. Page max width 720 px.
|
||||
- Title: "Media cleanup". Description: "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one."
|
||||
- "Automatic purge" / helper "Runs in the background. Off by default." / USwitch.
|
||||
- When on: "Every" / helper "Between 1 and 365 days." / number input, suffix "days", default 30.
|
||||
- Read-only lines: "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" or "Never run". When on, also "Next purge: 14 Jul 2026".
|
||||
- Divider, then "Purge now" / "Merge every group of duplicates that has an image in use, right away." / outline button "Purge duplicates" (merge).
|
||||
- No Save button. Saves on toggle and on blur or Enter of the days field. "Saved" (check, success) sits in the card header and fades after 2 s; "Saving…" (loader-circle) while pending.
|
||||
- Non-owner/admin: switch and input disabled, button and divider hidden, info alert "Only owners and admins can change this."
|
||||
|
||||
### States (names matched exactly)
|
||||
- Default: off, never run
|
||||
- On: 30 days, last run
|
||||
- Saving
|
||||
- Saved
|
||||
- Save failed: "Couldn't save. Try again in a moment." under the field; the control reverts.
|
||||
- Validation error: "Enter a whole number of days between 1 and 365." Nothing is sent.
|
||||
- Read-only (non-admin)
|
||||
- Loading skeleton (after 150 ms)
|
||||
- Offline: banner "You're offline. Changing automatic purge and purging duplicates need a connection." Switch, input and button disabled.
|
||||
|
||||
### Dialog
|
||||
UModal, `sm:max-w-md`, bottom sheet on mobile, cancel first.
|
||||
- Title "Purge duplicates?". Description "Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
- Summary: "4 groups · 9 copies will be deleted · 12 articles will be updated". Singular: "1 group · 1 copy will be deleted · 2 articles will be updated".
|
||||
- Bordered list of up to 5 groups: thumbnail, filename, "Kept · used in 4 articles", and "2 copies merged". More: "and 3 more groups".
|
||||
- Link "Review in Duplicates" to `/media?filter=duplicates`.
|
||||
- Buttons: "Cancel", danger "Purge 4 groups". Pending: "Purging…" with loader-circle, dialog can't be dismissed.
|
||||
- Loading preview: skeleton rows, confirm disabled.
|
||||
- Nothing to purge: icon copy, "Nothing to purge", "No duplicate is used in an article, so there is nothing to merge.", button "Close". No confirm.
|
||||
- Error (stays open): "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
- Partial: "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish." Buttons "Close", "Retry". Summary refreshes to the remaining groups.
|
||||
- Conflict (409): "The library changed, so nothing was purged. Check the new summary and try again." Summary refreshes.
|
||||
|
||||
### Results
|
||||
- Toast (5 s): circle-check, "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
- "Last purge" updates in place; "Next purge" moves forward by the interval.
|
||||
- An unattended run shows no toast. The next visit shows the updated line.
|
||||
|
||||
### Media entry point
|
||||
Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort menu; stacked toolbar on mobile. Opens the same dialog. Hidden for non-owner/admin and when there are no groups. "Merge into this one" is unchanged.
|
||||
|
||||
### Components (Nuxt UI v4)
|
||||
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`.
|
||||
@@ -144,9 +144,10 @@ class Component extends DCLogic {
|
||||
if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts }
|
||||
if (s.limited) parts.push(Alert('info', 'timer', 'Too many uploads', 'For your security, wait a moment before uploading again.'))
|
||||
if (s.error) parts.push(Alert('error', 'circle-x', 'Couldn\'t load your images', 'Check your connection and try again.', 'Try again'))
|
||||
const pb = tab === 'dups' && !s.member && !s.dupEmpty && B('Purge duplicates', { icon: 'merge', dis: s.offline, full: m })
|
||||
const tb = m
|
||||
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()))
|
||||
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), Toggle()))
|
||||
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()), pb && D('display:flex', pb))
|
||||
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), pb, Toggle()))
|
||||
parts.push(tb)
|
||||
if (UP) parts.push(UpPanel())
|
||||
if (s.loading) parts.push(Skel())
|
||||
@@ -189,6 +190,7 @@ class Component extends DCLogic {
|
||||
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
||||
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
|
||||
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
|
||||
if (k === 'purge') return { title: 'Purge duplicates?', desc: 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.', body: [S('font-weight:500;color:var(--ui-text-highlighted)', '4 groups · 9 copies will be deleted · 12 articles will be updated'), D('border:1px solid var(--ui-border)', ...[[12, 'floorboards.jpg', 4, '2 copies'], [7, 'IMG_4471.jpg', 2, '1 copy'], [2, 'kitchen-after.webp', 3, '3 copies'], [4, 'logo-renew.svg', 5, '3 copies']].map(([id, n, u, c], i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(byId(id), 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(u))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap', c + ' merged')))), S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))], cancel: 'Cancel', confirm: 'Purge 4 groups', tone: 'danger' }
|
||||
if (k === 'remove') return { title: 'Remove this image from the article?', desc: 'It\'s removed from this article only. The file stays in your library and in any other article that uses it.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(1), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Stays in the library · 3.9 MB')))], cancel: 'Cancel', confirm: 'Remove image' }
|
||||
}
|
||||
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : o.top || 140) + 'px 16px 16px',
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
rootRef = React.createRef()
|
||||
|
||||
@@ -223,6 +223,8 @@ class Component extends DCLogic {
|
||||
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: 'Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message.' }),
|
||||
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
||||
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: 'The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board.' }),
|
||||
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
|
||||
Mp({ tab: 'dups', dupEmpty: true, name: 'Duplicates: empty', trigger: 'No groups. The Duplicates tab loses its count badge.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'merge' }, name: 'Merge: confirm', trigger: '"Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'merge', loading: true }, name: 'Merge: in progress', trigger: 'Request in flight. The dialog can\'t be dismissed.' }),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link rel="icon" href="brand/favicon.svg">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Media cleanup screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Media cleanup</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">A Media cleanup card on Settings, a purge dialog, and an entry point on the Media Duplicates tab (also in <a href="RenewCMS Screens Back-office.dc.html">the Back-office board</a>). 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
|
||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
}
|
||||
apply() {
|
||||
const el = this.rootRef.current
|
||||
if (!el) return
|
||||
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() { this.apply() }
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() {
|
||||
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, ...o, m: { ...o, ...mo } })
|
||||
const L = { d: '14 Jun 2026', c: 3, a: 5 }
|
||||
const on = { on: true, days: '30', last: L, next: '14 Jul 2026' }
|
||||
const D = (k, name, trigger, o = {}) => W(name, trigger, { ...on, last: null, on: false, dialog: { k, ...(o.dg || {}) }, ...o.s })
|
||||
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
|
||||
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
|
||||
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
|
||||
const base = { last: null }
|
||||
const ST = (o) => ({ ...base, ...o })
|
||||
const Mp = (o) => ({ isMedia: true, kind: 'page', ...o, m: { isMedia: true, kind: 'page', ...o } })
|
||||
return this.finish([
|
||||
{ label: 'Settings', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A second card below Version, 20 px gap, same card styling. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
|
||||
W('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', ST({})),
|
||||
W('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', on),
|
||||
W('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...on, saving: true, focusDays: true }),
|
||||
W('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...on, saved: true }),
|
||||
W('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...on, saveErr: true, days: '30', focusDays: true }),
|
||||
W('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...on, days: '400', invalid: true, focusDays: true }),
|
||||
W('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...on, ro: true }),
|
||||
W('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
|
||||
W('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...on, offline: true })
|
||||
] },
|
||||
{ label: 'Dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
|
||||
D('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
|
||||
D('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
|
||||
D('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { dg: { pv: pvMore } }),
|
||||
D('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
|
||||
D('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
|
||||
D('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
|
||||
D('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { dg: { pv: pvOne } }),
|
||||
D('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { dg: { pv: pv3 } })
|
||||
] },
|
||||
{ label: 'Results', title: 'Results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
|
||||
W('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: { d: '5 Oct 2026', c: 9, a: 12 }, toast: true }),
|
||||
W('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: { d: '5 Oct 2026', c: 9, a: 12 }, next: '4 Nov 2026', toast: true })
|
||||
] },
|
||||
{ label: 'Media', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. Per-group "Merge into this one" is unchanged.', states: [
|
||||
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Dialog from Media', trigger: 'The same dialog as on Settings.' }),
|
||||
Mp({ tab: 'dups', member: true, name: 'Member: button hidden', trigger: 'Other verified users.' }),
|
||||
Mp({ tab: 'dups', offline: true, name: 'Offline', trigger: 'The button is disabled with the existing banner.' })
|
||||
], tableTitle: 'Status to copy', table: [
|
||||
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
|
||||
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
|
||||
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
|
||||
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
|
||||
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
|
||||
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
|
||||
] }
|
||||
])
|
||||
}
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||
renderVals() {
|
||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||
const on = this.state.mode === k
|
||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div style="display:contents">{{ view }}</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
|
||||
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
|
||||
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
|
||||
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
|
||||
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
|
||||
const bh = m ? 44 : 32
|
||||
const B = (label, o = {}) => {
|
||||
const k = o.k || 'outline'
|
||||
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
|
||||
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
|
||||
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
|
||||
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
|
||||
}
|
||||
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
|
||||
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
|
||||
const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
|
||||
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
|
||||
const Alert = (tone, icon, text, title) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
|
||||
const hl = 'color:var(--ui-text-highlighted)'
|
||||
const mono = "font-family:'IBM Plex Mono',monospace"
|
||||
|
||||
// ---------- card
|
||||
const dis = !!(s.ro || s.offline || s.loading)
|
||||
const on = !!s.on
|
||||
const Sw = () => D('display:flex;align-items:center;justify-content:center;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), D('position:relative;width:36px;height:20px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (s.focusSw ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), D('position:absolute;top:1px;width:16px;height:16px;left:' + (on ? 17 : 1) + 'px;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';border:1px solid ' + (on ? 'transparent' : 'var(--ui-border-accented)') + ';box-sizing:border-box')))
|
||||
const Row = (label, help, ctl, stack) => D('display:flex;' + (m || stack ? 'flex-direction:column;align-items:stretch;' : 'align-items:flex-start;justify-content:space-between;') + 'gap:' + (m ? 12 : 16) + 'px', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;' + hl, label), help && S('font-size:12px;color:var(--ui-text-muted)', help)), ctl)
|
||||
const hasErr = s.invalid || s.saveErr
|
||||
const errTxt = s.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.'
|
||||
const Days = () => D('display:flex;flex-direction:column;gap:6px;' + (m ? '' : 'align-items:flex-end;'),
|
||||
D('display:flex;align-items:center;width:' + (m ? '100%' : '140px') + ';min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (hasErr ? 'var(--ui-error)' : s.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (s.focusDays && !hasErr ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;opacity:' + (dis ? 0.4 : 1), S('flex:1;min-width:0;padding:0 12px;' + mono + ';' + hl, s.days ?? '30'), S('padding:0 12px;color:var(--ui-text-muted);' + mono + ';font-size:13px', 'days')),
|
||||
hasErr && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;' + (m ? '' : 'max-width:300px;text-align:right;'), I('circle-alert', 14, 'margin-top:2px'), errTxt))
|
||||
const stat = s.saving ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)', I('loader-circle', 14), 'Saving…') : s.saved ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)', I('check', 14), 'Saved') : null
|
||||
const last = s.last === undefined ? null : s.last
|
||||
const lastLine = last ? 'Last purge: ' + last.d + ' · ' + last.c + ' copies merged · ' + last.a + ' articles updated' : 'Never run'
|
||||
const Cleanup = () => {
|
||||
const kids = [
|
||||
D('display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Media cleanup'), S('color:var(--ui-text-muted);text-wrap:pretty', 'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.')), D('height:24px;display:flex;align-items:center;flex-shrink:0', stat))
|
||||
]
|
||||
if (s.ro) kids.push(Alert('info', 'info', 'Only owners and admins can change this.'))
|
||||
kids.push(Row('Automatic purge', 'Runs in the background. Off by default.', Sw()))
|
||||
if (on) kids.push(Row('Every', 'Between 1 and 365 days.', Days()))
|
||||
kids.push(D('display:flex;flex-direction:column;gap:4px;color:var(--ui-text-toned)', S('', lastLine), on && S('', 'Next purge: ' + (s.next || '14 Jul 2026'))))
|
||||
if (!s.ro) {
|
||||
kids.push(D('height:1px;background:var(--ui-border)'))
|
||||
kids.push(Row('Purge now', 'Merge every group of duplicates that has an image in use, right away.', D('display:flex;' + (m ? '' : 'flex-shrink:0'), B('Purge duplicates', { icon: 'merge', dis: !!(s.offline || s.loading), full: m, ring: s.focusBtn }))))
|
||||
}
|
||||
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
|
||||
}
|
||||
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
|
||||
const CleanupSkel = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', SkelBar('140px', 16), SkelBar('90%'), SkelBar('55%'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('120px'), SkelBar('180px', 11)), SkelBar('36px', 20)), SkelBar('60%'), D('height:1px;background:var(--ui-border)'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('100px'), SkelBar('220px', 11)), SkelBar('140px', bh)))
|
||||
const pad = m ? 16 : 24
|
||||
const page = D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;position:relative', D('max-width:720px;display:flex;flex-direction:column;gap:20px',
|
||||
D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + hl, 'Settings'), S('color:var(--ui-text-muted)', 'Workspace information.')),
|
||||
D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;align-items:center;justify-content:space-between;gap:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Version'), S('font:400 14px/22px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'v2.2.0-beta')),
|
||||
s.loading ? CleanupSkel() : Cleanup()))
|
||||
|
||||
// ---------- dialog
|
||||
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
|
||||
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
||||
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.dialog && s.dialog.pv) }
|
||||
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
|
||||
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
|
||||
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
|
||||
const dg = s.dialog
|
||||
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
|
||||
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
|
||||
o.center ? D('padding:28px 24px 0;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
|
||||
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
|
||||
body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body),
|
||||
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
|
||||
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
|
||||
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
|
||||
const Dialog = () => {
|
||||
const k = dg.k, pv = PV
|
||||
const gp = 'Purge ' + pl(pv.groups, 'group')
|
||||
const note = (tone, icon, t) => Alert(tone, icon, t)
|
||||
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
|
||||
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
|
||||
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
|
||||
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
|
||||
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
|
||||
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
|
||||
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
|
||||
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
|
||||
}
|
||||
const Toast = () => D('position:absolute;z-index:60;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-success)', I('circle-check')), D('flex:1;display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + hl, 'Duplicates purged'), S('color:var(--ui-text-muted)', '9 copies merged. 12 articles updated.')), I('x'))
|
||||
|
||||
// ---------- shell
|
||||
const nav = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const mk = (z) => h('svg', { width: z, height: z, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px',
|
||||
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mk(26), S('font-weight:600;font-size:14px;' + hl, 'RenewCMS')),
|
||||
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === 'settings' ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === 'settings' ? '#111111' : 'var(--ui-text-toned)') + ';font-weight:' + (k === 'settings' ? 500 : 400), I(ic), t)),
|
||||
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;' + hl, S('width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
|
||||
const topbar = m
|
||||
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;' + hl, S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', 'Settings'))
|
||||
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('font-weight:500;' + hl, 'Settings'))
|
||||
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changing automatic purge and purging duplicates need a connection.'))
|
||||
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
|
||||
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, offline, page), dg && Dialog(), s.toast && Toast())
|
||||
return { view: root }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -281,3 +281,27 @@ Added by the Media screens, none of which exist yet:
|
||||
- `POST /v1/media/merge`: `{ keep_id, merge_ids }`. Rewrites article bodies from the merged copies' URLs to the kept image's URL, then deletes the copies. 200 with `{ articles_updated, merged }`, 404, 409 when a listed copy is gone or the group changed.
|
||||
- `GET /v1/media/{id}/usage`: `[{ id, title, published }]`, read from article bodies.
|
||||
- Images no longer belong to an article, and deleting an article no longer deletes images. `POST /v1/article/{id}/image` and `DELETE /v1/article/{id}/image/{imageId}` are replaced by the endpoints above.
|
||||
|
||||
## Added with Media cleanup
|
||||
### Media cleanup
|
||||
| State | Surface | Behaviour |
|
||||
|---|---|---|
|
||||
| Default | Settings card | Off, "Never run", no "Every" row |
|
||||
| On | Settings card | "Every" 30 days, last and next run |
|
||||
| Saving / Saved | Settings card header | "Saving…" then "Saved" for 2 s, polite live region |
|
||||
| Save failed | Under the field | "Couldn't save. Try again in a moment." Control reverts |
|
||||
| Validation | Under the field | "Enter a whole number of days between 1 and 365." Nothing sent |
|
||||
| Read-only | Settings card | Controls disabled, button hidden, info alert |
|
||||
| Loading | Settings card | Skeleton after 150 ms |
|
||||
| Offline | Settings and Media | Banner as on Media. Controls and buttons disabled |
|
||||
| Preview loading | Dialog | Skeleton rows, confirm disabled |
|
||||
| Confirm | Dialog | Summary, up to 5 groups, link to Duplicates |
|
||||
| In progress | Dialog | "Purging…", loader-circle, not dismissible |
|
||||
| Nothing to purge | Dialog | Info state, "Close" only |
|
||||
| Error | Dialog | Notice, stays open, confirm retries |
|
||||
| Partial | Dialog | Warning, summary refreshes, "Close" and "Retry" |
|
||||
| Conflict (409) | Dialog | Warning, summary refreshes |
|
||||
| Success | Toast, 5 s | "Duplicates purged" / "9 copies merged. 12 articles updated." "Last purge" updates in place |
|
||||
| Unattended run | Settings | No toast. Updated on next visit |
|
||||
|
||||
401 and 403 are global and have no local copy.
|
||||
@@ -33,3 +33,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
||||
| `v2.8.0-beta` | v2.8.0-beta - API keys and API reference |
|
||||
| `v2.8.1-beta` | v2.8.1-beta - Docker deployment |
|
||||
| `v2.8.2-beta` | v2.8.2-beta - Security headers and clearer log in errors |
|
||||
| `v2.8.3-beta` | v2.8.3-beta - Security policy fixes for the web app |
|
||||
@@ -0,0 +1,18 @@
|
||||
This release fixes the security headers added in v2.8.2-beta, which blocked parts of the web app in the browser.
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- The web app's icons now display again. They are bundled into the app instead of being fetched from an external icon service, which the security policy blocked.
|
||||
- The light and dark mode script in the page is now allowed by the security policy, so the color mode no longer fails to apply on load.
|
||||
- The security policy now allows WebAssembly, which parts of the web app need.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Add a Vite plugin that bundles only the Lucide icons the app uses.
|
||||
- Compute a hash for each inline script in the served page and add it to the `script-src` directive, instead of allowing inline scripts in general.
|
||||
- Update the security headers tests.
|
||||
|
||||
## Upgrading
|
||||
|
||||
- Replace the binary, or pull the new image. No schema change and no new environment variable.
|
||||
- If you are on v2.8.2-beta, upgrade: that version's policy broke icons and color mode.
|
||||
@@ -0,0 +1,34 @@
|
||||
This release adds a duplicate purge to the media library: owners and admins can merge identical images in one action, or let RenewCMS do it on a schedule.
|
||||
|
||||
## New
|
||||
|
||||
**Media duplicate purge**
|
||||
|
||||
- Purge duplicates from the Duplicates tab of the media library with the new toolbar button, or from the new Media cleanup card in Settings. A summary shows how many copies would be merged and how many articles would be updated before you confirm.
|
||||
- For each group of identical images used in at least one article, the most used copy is kept, with ties going to the oldest. Every other copy is merged into it and the articles that used them are rewritten. Groups where no copy is used are left alone.
|
||||
- If the library changes between the summary and the confirmation, nothing is purged and you are asked to check the new summary.
|
||||
- Turn on automatic purge in Settings and choose an interval from 1 to 365 days, 30 by default. The first run happens one interval after you turn it on, then once per interval after each run.
|
||||
- The Media cleanup card shows the last run, with the number of copies merged and articles updated, and the date of the next one.
|
||||
- The purge is reserved for owners and admins. Editors get a clear message instead of the controls.
|
||||
- The new screens are available in English and French.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /v1/media/purge-settings` returns the automatic purge settings: `auto_purge_enabled`, `auto_purge_interval_days`, `last_purge_at`, `last_purge_merged`, `last_purge_articles_updated` and `next_purge_at`, which is `null` while automatic purge is off.
|
||||
- `PATCH /v1/media/purge-settings` updates `auto_purge_enabled` and `auto_purge_interval_days`. An interval outside 1 to 365 answers `400`.
|
||||
- `GET /v1/media/purge-preview` returns what a purge would do, with a `fingerprint` of the plan. It changes nothing.
|
||||
- `POST /v1/media/purge` runs the purge. Send the preview's `fingerprint` to refuse a run if the library changed: a stale fingerprint answers `409`, and so does a purge that is already running.
|
||||
- All four routes answer `403` with "Only owners and admins can do this." for any other role, and accept a session only, like the rest of the media routes.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Add the purge domain, a SQLite repository for the single-row purge settings, and the use cases that build the plan, preview it, run it and decide when a scheduled run is due.
|
||||
- Add an hourly scheduler, started with the server after a one-minute delay and stopped on shutdown. It reads the settings on every tick, so changes apply without a restart, and a failed run is retried at the next tick.
|
||||
- Add the `RequireOwnerOrAdmin` middleware.
|
||||
- Add the media purge, scheduler and Settings card specifications and QA procedures to `features/`, the new screens to `docs/design/`, and Go and Vitest suites for each part.
|
||||
|
||||
## Upgrading
|
||||
|
||||
- Replace the binary, or pull the new image. The purge settings table is created automatically at startup. No new environment variable is required.
|
||||
- Automatic purge is off after the upgrade. Nothing is merged until an owner or admin runs a purge or turns it on.
|
||||
- A purge cannot be undone: merged copies are removed. Check the summary before confirming.
|
||||
@@ -0,0 +1,385 @@
|
||||
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to turn on automatic duplicate purging and to purge duplicates by hand
|
||||
So that identical images are merged without leaving Settings or the Media library
|
||||
|
||||
Background:
|
||||
Given the API of media_purge.feature and media_purge_scheduler.feature is live
|
||||
And every call goes through useApi; 401 is global (/login?redirect=<path>&reason=expired);
|
||||
a 403 on a verified account has no local copy and redirects nowhere
|
||||
And the calls are:
|
||||
| call | body or response |
|
||||
| GET /v1/media/purge-settings | {auto_purge_enabled, auto_purge_interval_days, last_purge_at, last_purge_merged, last_purge_articles_updated, enabled_at, next_purge_at} |
|
||||
| PATCH /v1/media/purge-settings | {"auto_purge_enabled": bool} or {"auto_purge_interval_days": int}, answers the settings above |
|
||||
| GET /v1/media/purge-preview | {groups, copies, articles, list: [{keep_id, filename, url, used_in, merged}], more, fingerprint} |
|
||||
| POST /v1/media/purge | {"fingerprint": "<from the preview>"}, answers {groups_total, groups_purged, merged, articles_updated, failed_groups} |
|
||||
And errors are plain text; 409 carries "The library changed, so nothing was purged. Check the new
|
||||
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
|
||||
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
|
||||
other role is a mocked value in tests
|
||||
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
|
||||
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
|
||||
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
|
||||
drawn in docs/design/source/SettingsFrame.dc.html
|
||||
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
|
||||
UModal, USkeleton, useToast) and design tokens only
|
||||
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
|
||||
open, preview, status (loading, ready, purging, nothing, error, partial, conflict); it emits
|
||||
purged with the counts; the parent shows the toast
|
||||
And plural rules: "1 group" / "N groups", "1 copy" / "N copies", "1 article" / "N articles"
|
||||
And skeleton placeholders show only after 150 ms of loading
|
||||
And a Partial result is a 200 whose failed_groups is above 0 (the board's 207 is only a label)
|
||||
And dates are shown in the browser's timezone, from last_purge_at and next_purge_at in fixtures
|
||||
And no state here is clock-sensitive
|
||||
|
||||
Rule: The Media cleanup card on /settings
|
||||
|
||||
Scenario: Default: off, never run
|
||||
Given GET /v1/media/purge-settings resolves 200 with auto_purge_enabled false,
|
||||
auto_purge_interval_days 30, last_purge_at null, next_purge_at null
|
||||
And the user is the owner
|
||||
When the user opens /settings
|
||||
Then the card titled "Media cleanup" reads "Merge identical images so your library stays tidy.
|
||||
Articles that use a copy are switched to the most used one."
|
||||
And "Automatic purge" with the helper "Runs in the background. Off by default." and a switch that is off
|
||||
And no "Every" row and no "Next purge" line
|
||||
And the line "Never run" is shown
|
||||
And a divider, then "Purge now" with the helper "Merge every group of duplicates that has an image
|
||||
in use, right away." and an outline button "Purge duplicates" with the merge icon
|
||||
And the card has no Save button
|
||||
|
||||
Scenario: On: 30 days, last run
|
||||
Given GET resolves auto_purge_enabled true, auto_purge_interval_days 30,
|
||||
last_purge_at "2026-06-14T09:00:00Z", last_purge_merged 3, last_purge_articles_updated 5,
|
||||
next_purge_at "2026-07-14T09:00:00Z"
|
||||
When the user opens /settings
|
||||
Then the switch is on
|
||||
And "Every" with the helper "Between 1 and 365 days." and a number input showing 30 with the suffix "days"
|
||||
And the lines "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" and "Next purge: 14 Jul 2026"
|
||||
|
||||
Scenario Outline: The Last purge line is pluralised
|
||||
Given last_purge_at is "2026-06-14T09:00:00Z", last_purge_merged <merged> and last_purge_articles_updated <articles>
|
||||
When the card renders
|
||||
Then the line reads "<line>"
|
||||
|
||||
Examples:
|
||||
| merged | articles | line |
|
||||
| 3 | 5 | Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated |
|
||||
| 1 | 1 | Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated |
|
||||
| 0 | 0 | Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated |
|
||||
|
||||
Scenario: Turning the switch on saves at once
|
||||
Given the Default state
|
||||
When the user turns the switch on
|
||||
Then PATCH /v1/media/purge-settings is sent with exactly {"auto_purge_enabled": true}
|
||||
And while it is pending "Saving…" with loader-circle sits in the card header and the controls stay usable
|
||||
When it resolves 200 with auto_purge_interval_days 30 and next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Saving…" is replaced by "Saved" with a check, in the success colour, in the card header
|
||||
And the "Every" row shows 30 and "Next purge: 4 Nov 2026" is shown
|
||||
And "Saved" is announced politely and disappears after 2 s
|
||||
|
||||
Scenario: Turning the switch off saves at once and hides the schedule
|
||||
Given the On state
|
||||
When the user turns the switch off
|
||||
Then PATCH is sent with exactly {"auto_purge_enabled": false}
|
||||
And on 200 with next_purge_at null the "Every" row and the "Next purge" line are hidden
|
||||
And the "Last purge" line is unchanged
|
||||
|
||||
Scenario Outline: The days field saves on blur or Enter, once
|
||||
Given the On state and the days field holds 30
|
||||
When the user types "<typed>" and <commit>
|
||||
Then PATCH is sent exactly once with {"auto_purge_interval_days": <sent>}
|
||||
And on 200 "Saved" shows in the header and "Next purge" follows the response's next_purge_at
|
||||
|
||||
Examples:
|
||||
| typed | commit | sent |
|
||||
| 45 | leaves the field | 45 |
|
||||
| 45 | presses Enter | 45 |
|
||||
| 1 | presses Enter | 1 |
|
||||
| 365 | leaves the field | 365 |
|
||||
|
||||
Scenario: Enter then the resulting blur is one request
|
||||
Given the On state
|
||||
When the user types 45, presses Enter and the field then loses focus
|
||||
Then exactly one PATCH is sent
|
||||
|
||||
Scenario: An unchanged value sends nothing
|
||||
Given the On state and the days field holds 30
|
||||
When the user focuses the field and leaves it without changing it
|
||||
Then no PATCH is sent and no "Saving…" shows
|
||||
|
||||
Scenario Outline: Validation error: nothing is sent
|
||||
Given the On state
|
||||
When the user types "<typed>" and leaves the field or presses Enter
|
||||
Then no PATCH is sent
|
||||
And the field keeps "<typed>" and gets the error border
|
||||
And under the field, with circle-alert, the message "Enter a whole number of days between 1 and 365." shows
|
||||
And no "Saving…" and no "Saved" show
|
||||
|
||||
Examples:
|
||||
| typed |
|
||||
| |
|
||||
| 0 |
|
||||
| 366 |
|
||||
| 400 |
|
||||
| 1.5 |
|
||||
|
||||
Scenario: A validation error clears when the value becomes valid
|
||||
Given the Validation error state with "400"
|
||||
When the user types 60 and presses Enter
|
||||
Then the message disappears and PATCH is sent with {"auto_purge_interval_days": 60}
|
||||
|
||||
Scenario Outline: Save failed: the control reverts
|
||||
Given the On state with 30 days
|
||||
When the user <action> and PATCH answers <failure>
|
||||
Then the control returns to its previous value (<previous>)
|
||||
And "Couldn't save. Try again in a moment." shows under the field (under the "Automatic purge" row for the switch)
|
||||
And no "Saved" shows
|
||||
|
||||
Examples:
|
||||
| action | failure | previous |
|
||||
| types 45 and presses Enter | 500 | 30 |
|
||||
| types 45 and presses Enter | a network error | 30 |
|
||||
| turns the switch off | 500 | on |
|
||||
|
||||
Scenario: The save-failed message goes away on the next successful save
|
||||
Given the Save failed state
|
||||
When the user types 45 and presses Enter and PATCH answers 200
|
||||
Then the failure message is gone and "Saved" shows
|
||||
|
||||
Scenario: Role changed (403 on PATCH)
|
||||
Given the On state for an owner who has been changed to a role that is not owner or admin
|
||||
When the user turns the switch off and PATCH answers 403
|
||||
Then the switch returns to on, with no message, no toast and no redirect
|
||||
And the session is refreshed and the card switches to the Read-only state
|
||||
|
||||
Scenario: Read-only (non-admin)
|
||||
Given the session role is not owner or admin
|
||||
When the user opens /settings
|
||||
Then GET /v1/media/purge-settings is not sent
|
||||
And the switch is off, the days input shows 30 and the line "Never run" shows, as placeholders
|
||||
And the switch and the days input are disabled
|
||||
And an info alert reads "Only owners and admins can change this."
|
||||
And the "Purge now" divider, row and button are not rendered
|
||||
And no PATCH can be sent from the card
|
||||
|
||||
Scenario: Loading skeleton after 150 ms
|
||||
Given GET /v1/media/purge-settings has not resolved
|
||||
When the user opens /settings
|
||||
Then during the first 150 ms the card shows no skeleton
|
||||
And after 150 ms a skeleton replaces the card body (title bar, two text bars, switch row, schedule bar, divider, button row), the card is aria-busy="true" and the button is disabled
|
||||
When it resolves
|
||||
Then the Default or On state replaces it and aria-busy is gone
|
||||
|
||||
Scenario Outline: The settings could not be loaded
|
||||
Given GET /v1/media/purge-settings <failure>
|
||||
When the user opens /settings
|
||||
Then the card shows an error alert "Couldn't reach RenewCMS. Check your connection and try again." with "Try again" (rotate-cw)
|
||||
And the Version and Updates cards are unaffected
|
||||
When the user activates "Try again"
|
||||
Then the loading state shows and GET is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| fails (network) |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
When the user opens /settings
|
||||
Then a banner reads "You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
And the switch, the days input and "Purge duplicates" are disabled (40 % opacity)
|
||||
And nothing is sent when they are activated
|
||||
|
||||
Scenario: 401 is global
|
||||
Given any call of the card answers 401
|
||||
Then the user goes to /login?redirect=/settings&reason=expired and the card shows no copy of its own
|
||||
|
||||
Rule: PurgeDuplicatesDialog opened from "Purge duplicates" in the card
|
||||
|
||||
Background:
|
||||
Given an owner on /settings and the card in the On state
|
||||
|
||||
Scenario: Opening shows the preview loading
|
||||
When the user activates "Purge duplicates"
|
||||
Then GET /v1/media/purge-preview is sent
|
||||
And a dialog opens (UModal, sm:max-w-md, a bottom sheet on mobile) titled "Purge duplicates?" with the description
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
And focus is on the first button, "Cancel"
|
||||
And after 150 ms skeleton rows replace the summary and list, and the confirm button, labelled
|
||||
"Purge duplicates", is disabled
|
||||
|
||||
Scenario: Confirm: summary, up to 5 groups and the link
|
||||
Given the preview is groups 4, copies 9, articles 12, a list of 4 groups, more 0
|
||||
When it resolves
|
||||
Then the summary reads "4 groups · 9 copies will be deleted · 12 articles will be updated"
|
||||
And a bordered list shows a row per group: a 40 x 30 thumbnail of its url, the filename, "Kept · used in N articles"
|
||||
and, right-aligned, "N copies merged"
|
||||
And no "and N more" line
|
||||
And the link "Review in Duplicates" (arrow-right) points to /media?filter=duplicates
|
||||
And the buttons are "Cancel" and the danger confirm "Purge 4 groups"
|
||||
|
||||
Scenario Outline: The summary and rows are pluralised
|
||||
Given the preview is groups <g>, copies <c>, articles <a> with one group kept in <u> article(s) and <m> merged
|
||||
Then the summary reads "<summary>"
|
||||
And the confirm reads "<confirm>"
|
||||
And the row reads "Kept · used in <ku>" and "<km>"
|
||||
|
||||
Examples:
|
||||
| g | c | a | u | m | summary | confirm | ku | km |
|
||||
| 4 | 9 | 12 | 4 | 2 | 4 groups · 9 copies will be deleted · 12 articles will be updated | Purge 4 groups | 4 articles | 2 copies merged |
|
||||
| 1 | 1 | 2 | 2 | 1 | 1 group · 1 copy will be deleted · 2 articles will be updated | Purge 1 group | 2 articles | 1 copy merged |
|
||||
| 1 | 1 | 1 | 1 | 1 | 1 group · 1 copy will be deleted · 1 article will be updated | Purge 1 group | 1 article | 1 copy merged |
|
||||
|
||||
Scenario: Confirm: many groups
|
||||
Given the preview is groups 8, copies 15, articles 24, a list of 5 groups and more 3
|
||||
When it resolves
|
||||
Then exactly 5 rows show, followed by "and 3 more groups"
|
||||
And the confirm reads "Purge 8 groups"
|
||||
|
||||
Scenario: "and 1 more group" is singular
|
||||
Given the preview has more 1
|
||||
Then the line reads "and 1 more group"
|
||||
|
||||
Scenario: Nothing to purge
|
||||
Given the preview is groups 0 with an empty list
|
||||
When it resolves
|
||||
Then the dialog shows an info icon, "Nothing to purge" and "No duplicate is used in an article, so there is nothing to merge."
|
||||
And the only button is "Close"; there is no confirm, no summary and no link
|
||||
And "Close" closes the dialog and sends nothing else
|
||||
|
||||
Scenario: The preview could not be loaded
|
||||
Given GET /v1/media/purge-preview answers 500 or fails
|
||||
Then the dialog shows the error notice "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
And the confirm is disabled and offers no purge until the preview loads again
|
||||
|
||||
Scenario: Cancelling changes nothing
|
||||
Given the Confirm state
|
||||
When the user activates "Cancel", presses Escape or clicks the overlay
|
||||
Then the dialog closes, no POST is sent and focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: The fingerprint goes from the preview to the purge
|
||||
Given the preview carries fingerprint "ab12"
|
||||
When the user activates "Purge 4 groups"
|
||||
Then POST /v1/media/purge is sent once with exactly {"fingerprint": "ab12"}
|
||||
|
||||
Scenario: In progress: the dialog cannot be dismissed
|
||||
Given POST /v1/media/purge has not resolved
|
||||
Then the confirm reads "Purging…" with loader-circle and is disabled
|
||||
And "Cancel" is disabled
|
||||
And Escape, a click on the overlay and a swipe-down do nothing
|
||||
|
||||
Scenario: Success: the dialog closes, a toast shows and Last purge updates in place
|
||||
Given the preview was groups 4, copies 9, articles 12
|
||||
When POST answers 200 with {groups_total 4, groups_purged 4, merged 9, articles_updated 12, failed_groups 0}
|
||||
Then the dialog closes, the dialog emits purged with merged 9 and articles_updated 12
|
||||
And a toast with circle-check shows for 5 s reading "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
And GET /v1/media/purge-settings is sent again
|
||||
And with last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 9, last_purge_articles_updated 12 the line reads
|
||||
"Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated", without a page reload
|
||||
And focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: Success with automatic purge on moves Next purge forward
|
||||
Given automatic purge is on with interval 30
|
||||
When a purge succeeds and the refreshed settings carry next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Next purge: 4 Nov 2026" shows, and with automatic purge off no "Next purge" line shows
|
||||
|
||||
Scenario: The toast is pluralised
|
||||
When POST answers 200 with merged 1, articles_updated 1 and failed_groups 0
|
||||
Then the toast reads "Duplicates purged" / "1 copy merged. 1 article updated."
|
||||
|
||||
Scenario Outline: Error: the dialog stays open and confirm retries
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers <failure>
|
||||
Then an error notice (soft alert, circle-x) reads "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." above the summary
|
||||
And the dialog stays open with the summary and list unchanged, and "Cancel" and "Purge 4 groups" enabled
|
||||
When the user activates "Purge 4 groups" again
|
||||
Then POST is sent again with {"fingerprint": "ab12"}
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Partial: some groups failed
|
||||
Given the preview was 4 groups
|
||||
When POST answers 200 with {groups_total 4, groups_purged 3, merged 8, articles_updated 10, failed_groups 1}
|
||||
Then a warning notice (soft alert, triangle-alert) reads "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish."
|
||||
And GET /v1/media/purge-preview is sent again and the summary and list refresh to the remaining groups
|
||||
And GET /v1/media/purge-settings is sent again so "Last purge" refreshes
|
||||
And the buttons are "Close" and "Retry" (rotate-cw, danger)
|
||||
And no toast shows and the dialog does not close
|
||||
When the user activates "Retry"
|
||||
Then POST is sent with the refreshed fingerprint
|
||||
|
||||
Scenario: Partial that leaves nothing to purge
|
||||
Given a Partial result and the refreshed preview has groups 0
|
||||
Then the dialog shows "Nothing to purge" with "Close"
|
||||
|
||||
Scenario: Conflict (409): the library changed
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers 409 with "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
Then a warning notice reads "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
And GET /v1/media/purge-preview is sent again and the summary refreshes (for example 3 groups · 7 copies · 10 articles)
|
||||
And the buttons are "Cancel" and "Purge 3 groups"
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
When the user activates "Purge 3 groups"
|
||||
Then POST is sent with the new fingerprint
|
||||
|
||||
Scenario: Conflict (409): a purge is already running
|
||||
When POST answers 409 with "A purge is already running."
|
||||
Then a warning notice shows the server's text, the preview refreshes and the dialog stays open
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
Then "Purge duplicates" in the card is disabled and the dialog cannot be opened
|
||||
|
||||
Rule: The Duplicates toolbar button on /media
|
||||
|
||||
Scenario: The button shows on the Duplicates tab for an owner or admin
|
||||
Given the user is owner or admin and /media?filter=duplicates holds at least one duplicate group
|
||||
Then the secondary (outline) button "Purge duplicates" with the merge icon shows after the sort menu
|
||||
and before the view toggle in the toolbar, as MediaFrame.dc.html draws it
|
||||
And on a stacked mobile toolbar it is the last row, full width and 44 px high, under the sort and view row
|
||||
|
||||
Scenario Outline: The button is hidden
|
||||
Given <condition>
|
||||
Then the toolbar has no "Purge duplicates" button
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| the filter is All |
|
||||
| the filter is Unused |
|
||||
| the Duplicates tab has no groups ("No duplicates" shows) |
|
||||
| the session role is not owner or admin |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false on the Duplicates tab
|
||||
Then the button is disabled, the existing offline banner of Media shows and nothing opens
|
||||
|
||||
Scenario: The button opens the same dialog
|
||||
When the user activates "Purge duplicates" on the Duplicates tab
|
||||
Then GET /v1/media/purge-preview is sent and PurgeDuplicatesDialog opens in the loading state
|
||||
And every status, notice, button and request of the previous Rule behaves identically
|
||||
|
||||
Scenario: After a purge from Media the list reloads like after a merge
|
||||
Given the Duplicates tab lists 4 groups
|
||||
When a purge succeeds (200, failed_groups 0)
|
||||
Then the toast "Duplicates purged" / "9 copies merged. 12 articles updated." shows
|
||||
And the list and the tab counts are requested again, as after "Merge into this one"
|
||||
And when no group is left "No duplicates" shows and the button is gone
|
||||
|
||||
Scenario: Partial from Media reloads the list when the dialog closes
|
||||
When the purge answers 200 with failed_groups 1 and the user activates "Close"
|
||||
Then the list and the tab counts are requested again
|
||||
|
||||
Scenario: "Review in Duplicates" from Media only closes the dialog
|
||||
Given the dialog is open on /media?filter=duplicates
|
||||
When the user follows "Review in Duplicates"
|
||||
Then the dialog closes and the route is unchanged
|
||||
|
||||
Scenario: "Merge into this one" is unchanged
|
||||
Given the Duplicates tab lists a group
|
||||
Then each group still shows "Merge into this one" and it still sends POST /v1/media/merge with {keep_id, merge_ids}
|
||||
@@ -0,0 +1,102 @@
|
||||
# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button)
|
||||
|
||||
No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`.
|
||||
|
||||
Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Suggested files, next to the existing view specs: `web/src/views/__tests__/SettingsMediaCleanup.spec.ts`, `web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts`, `web/src/views/__tests__/MediaPurgeEntry.spec.ts`. Reuse `mediaTestKit.ts` and the existing Settings/Media mount helpers.
|
||||
|
||||
Setup:
|
||||
- Mock `useApi().request` per route and method: GET and PATCH `/v1/media/purge-settings`, GET `/v1/media/purge-preview`, POST `/v1/media/purge`. Assert the exact path, method and JSON body of every call.
|
||||
- Mock the session store: `role: 'owner'`, `role: 'admin'`, and for the non-admin cases a role cast to `UserRole` (for example `'editor' as never`) or undefined.
|
||||
- Fake timers for the 150 ms skeleton, the 2 s "Saved" and the 5 s toast. No test freezes the system date: every date comes from a fixture ISO string with a midday UTC time (for example `2026-06-14T09:00:00Z`), formatted by the SPA.
|
||||
- Offline: set `navigator.onLine` to false and dispatch `offline`, or set `isOffline` from `useApi`.
|
||||
- Reject with `ApiError(status, undefined, body)` and `NetworkError` as in the existing specs.
|
||||
- Fixtures reuse the board data: settings `last 14 Jun 2026 / 3 copies / 5 articles`, `next 14 Jul 2026`; preview 4 groups / 9 copies / 12 articles with floorboards.jpg (4, 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3); many: 8 / 15 / 24 with lime-plaster.jpg (2, 1) and more 3.
|
||||
|
||||
Cases (one `it` per scenario or Examples row; `it.each` for outlines):
|
||||
|
||||
Settings card
|
||||
1. Default: one GET on open, title, description, label, helper, switch off, no "Every", no "Next purge", "Never run", divider, "Purge now" row, button "Purge duplicates", no Save button.
|
||||
2. On: switch on, "Every" 30 with "days" suffix and helper, both lines; the Last purge pluralisation rows (3/5, 1/1, 0/0).
|
||||
3. Toggle on: PATCH body is exactly `{ auto_purge_enabled: true }`; "Saving…" while pending; then "Saved" in a polite live region; gone after 2 s; the "Every" row and "Next purge" follow the response.
|
||||
4. Toggle off: body exactly `{ auto_purge_enabled: false }`; schedule hidden, Last purge kept.
|
||||
5. Days commit, table-driven (45 blur, 45 Enter, 1 Enter, 365 blur): exactly one PATCH with `{ auto_purge_interval_days: n }`. Enter then blur is one PATCH. Unchanged value sends nothing.
|
||||
6. Validation, table-driven ("", 0, 366, 400, 1.5): no `request` call, field keeps the typed text, error message "Enter a whole number of days between 1 and 365." under the field, error border; a valid value afterwards clears it and sends.
|
||||
7. Save failed (PATCH 500, then NetworkError, for the days field and for the toggle): value reverts, the "Couldn't save. Try again in a moment." copy shows in the right place, no "Saved"; a later success clears it.
|
||||
8. Read-only: role not owner/admin leads to no call to `/v1/media/purge-settings`, placeholders (switch off, 30, "Never run"), disabled switch and input, the info alert "Only owners and admins can change this.", no divider, no "Purge now" row, no button.
|
||||
9. 403 on PATCH: the control reverts, no toast, no redirect, `session.refresh` is called, and a role that is no longer owner/admin yields the Read-only card.
|
||||
10. Loading: 149 ms no skeleton, 150 ms skeleton and `aria-busy="true"`, button disabled; resolve and assert both are gone.
|
||||
11. Settings load failure (500, network): the inline alert, "Try again" reloads.
|
||||
12. Offline: banner text "You're offline. Changing automatic purge and purging duplicates need a connection.", switch, input and button disabled, nothing sent.
|
||||
13. 401 on any call: `redirectToLogin` path used, no local copy in the card.
|
||||
|
||||
Dialog
|
||||
14. Open: GET preview sent, title and description, focus on "Cancel", skeleton after 150 ms (not before), confirm disabled with the label "Purge duplicates".
|
||||
15. Confirm: summary, rows (thumbnail src from `url`, filename, "Kept · used in N articles", "N copies merged"), link `href="/media?filter=duplicates"` with "Review in Duplicates", buttons, confirm label "Purge 4 groups". Singular rows (1/1/2, 1/1/1).
|
||||
16. Many groups: 5 rows then "and 3 more groups"; "and 1 more group" for more 1.
|
||||
17. Nothing: groups 0 gives "Nothing to purge" copy and a single "Close"; no confirm, summary or link.
|
||||
18. Cancel, Escape and overlay close without POST and return focus to the opener.
|
||||
19. Fingerprint: POST body is exactly `{ fingerprint: '<preview fingerprint>' }`, sent once.
|
||||
20. In progress (POST unresolved): label "Purging…" with the loader icon, confirm and Cancel disabled; Escape, overlay click and a close attempt do nothing.
|
||||
21. Success: dialog closes, `purged` emitted with `{ merged, articles_updated }`, toast "Duplicates purged" / "9 copies merged. 12 articles updated." with a 5 s duration, a second GET `/v1/media/purge-settings`, "Last purge" and "Next purge" updated without remount, focus back on the opener; singular toast.
|
||||
22. Error (500 and network): error notice, dialog open, summary unchanged, confirm retries with the same fingerprint, no toast, Last purge unchanged.
|
||||
23. Partial (200, failed_groups 1, groups_total 4, groups_purged 3): the warning text with "3 of 4", a second GET preview, summary refreshed, settings refetched, buttons "Close" and "Retry", no toast, a Retry POST uses the new fingerprint; a refreshed empty preview gives "Nothing to purge".
|
||||
24. Conflict (409 with the conflict text): warning, preview refetched, summary and confirm label refreshed, no toast; the next confirm sends the new fingerprint. Busy 409 text shows the server's text.
|
||||
25. Preview failure: error notice and no purge possible until it loads.
|
||||
26. Offline: the button is disabled and no dialog opens.
|
||||
|
||||
Media
|
||||
27. Button presence table: shown for owner and admin on the Duplicates tab with at least one group; hidden on All, on Unused, with no groups, and for a non-owner/admin role.
|
||||
28. Offline: disabled, the existing banner shows, nothing opens.
|
||||
29. DOM order of the toolbar: Sort, then "Purge duplicates", then the view toggle; the button carries the mobile classes for full width and a 44 px height (`h-11`) in the stacked toolbar.
|
||||
30. Opening from Media: same dialog statuses as cases 14 to 25, driven through the same mocks.
|
||||
31. After a successful purge: the toast, and the media list and counts are requested again (same assertion helper as the merge success case in `MediaDuplicates.spec.ts`); the last group leaving shows "No duplicates" and removes the button. Partial then "Close" reloads the list.
|
||||
32. "Review in Duplicates" from Media closes the dialog and leaves the route unchanged.
|
||||
33. "Merge into this one" regression: the existing merge cases still pass unchanged.
|
||||
|
||||
Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the other Settings specs must tolerate a second card (any assertion about "no switch or input" is scoped to the Version card). `features/settings_page.feature` wording ("one card") is updated at its next amendment, not in this slice.
|
||||
|
||||
## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark)
|
||||
|
||||
References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
|
||||
|
||||
Board sample data to reuse:
|
||||
- Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z").
|
||||
- Success fixtures: last 5 Oct 2026, 9 copies, 12 articles. "Success" has automatic purge off (no Next purge); "Success, automatic purge on" has 30 days and next 4 Nov 2026. Both show the toast "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
- Preview groups (ids 12, 7, 2, 4, 11): floorboards.jpg (used 4, merged 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3, SVG with alpha), lime-plaster.jpg (2, 1). Default preview: 4 groups, 9 copies, 12 articles with the first four. Many: 8 / 15 / 24 with all five and more 3. Partial: refreshed preview 1 group / 1 copy / 2 articles (IMG_4471.jpg), POST answers 200 `groups_total 4, groups_purged 3, failed_groups 1`. Conflict: POST 409, refreshed preview 3 groups / 7 copies / 10 articles (floorboards.jpg, kitchen-after.webp, logo-renew.svg).
|
||||
- Thumbnails: reuse the thumbnail fixtures the existing media states draw.
|
||||
- Frame values: page max width 720, 20 px between cards; card padding 20 (16 mobile), 16 gap; title 15/24 semibold; switch 36 x 20 with a 44 px hit area on mobile; days input 140 px wide (100 % on mobile), 40 px high (44 mobile), IBM Plex Mono 13 px (16 px on mobile), suffix "days" in mono; rows stack on mobile; "Purge duplicates" is 32 px high (44 mobile, full width); dialog 448 px wide, 140 px from the top on desktop, bottom sheet on mobile with the buttons column-reversed; toast 356 px wide at the bottom right (full width on mobile).
|
||||
|
||||
Settings screen, group "Settings: Media cleanup":
|
||||
- [ ] Default: off, never run
|
||||
- [ ] On: 30 days, last run
|
||||
- [ ] Saving (PATCH hangs, days field focused)
|
||||
- [ ] Saved
|
||||
- [ ] Save failed
|
||||
- [ ] Validation error (days field holds "400")
|
||||
- [ ] Read-only (non-admin) (mocked role, placeholders, no GET)
|
||||
- [ ] Loading skeleton
|
||||
- [ ] Offline
|
||||
|
||||
Settings screen, group "Purge duplicates: dialog":
|
||||
- [ ] Loading preview, [ ] Confirm, [ ] Confirm: many groups, [ ] In progress, [ ] Nothing to purge, [ ] Error, [ ] Partial failure, [ ] Conflict (409)
|
||||
|
||||
Settings screen, group "Results and feedback":
|
||||
- [ ] Success, [ ] Success, automatic purge on (the setup holds the toast open so its 5 s timeout cannot end before the capture)
|
||||
|
||||
Media screen, group "Media: Duplicates tab entry point":
|
||||
- [ ] Duplicates tab with the button, [ ] Dialog from Media, [ ] Member: button hidden (mocked role), [ ] Offline (button disabled, existing banner copy)
|
||||
|
||||
Reuse the existing `mediaDuplicatesApi` fixtures for the Duplicates tab and add the purge-preview mock.
|
||||
|
||||
Not covered by screenshots, check by hand: Tab order (switch, days field, "Purge duplicates"; in the dialog Cancel then confirm then the link); focus ring on the switch and field; Enter and Escape in the dialog; focus return to the opener after close; the polite announcement of "Saving…" then "Saved"; hover states; reduced motion on the skeleton and the loader icons; swipe-down is ignored while purging on a touch device.
|
||||
|
||||
## Manual verification (real API, `task dev`)
|
||||
|
||||
- With an owner, turn automatic purge on, set 7 days, reload /settings: the values persist and "Next purge" matches `next_purge_at`.
|
||||
- Upload two identical images, use the copy in an article, press "Purge duplicates": the preview matches, the purge merges them, the toast shows, "Last purge" updates, the file of the copy is gone from `UPLOAD_DIR`.
|
||||
- Open the dialog, upload another duplicate from a second tab, confirm: the Conflict notice and the refreshed summary show.
|
||||
- Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings and /media?filter=duplicates on a hard refresh, since Vitest never exercises the embedded frontend.
|
||||
@@ -0,0 +1,432 @@
|
||||
Feature: Owners and admins can merge duplicate images in use, with persisted purge settings
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings
|
||||
So that articles end up pointing at the most used copy of an image and the other copies are removed
|
||||
|
||||
Background:
|
||||
Given internal/domain/purge defines the entity Settings with AutoPurgeEnabled (default false),
|
||||
AutoPurgeIntervalDays (default 30, valid 1 to 365), LastPurgeAt, LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated and EnabledAt (unset or 0 by default), plus a Repository
|
||||
interface with Get, Save and SaveLastRun, and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict
|
||||
(the library changed since the fingerprint) and ErrPurgeBusy (a run is already in progress),
|
||||
all asserted with errors.Is
|
||||
And Repository.Get returns the defaults when nothing was ever saved (a single-row table)
|
||||
And the use cases GetPurgeSettings, UpdatePurgeSettings, PreviewPurge and RunPurge depend only
|
||||
on domain interfaces, take no HTTP types and are reusable by a scheduler
|
||||
And the use cases receive an injectable clock and an injected *log.Logger
|
||||
And an image's "used in" is the count of articles, online or draft, whose body contains the
|
||||
image's stored name, read from article bodies each time, as in ListMedia
|
||||
And a duplicate group is the set of images sharing one non-empty sha256, as from
|
||||
image.Repository.ListDuplicates
|
||||
And the purge plan is: a group is planned only if at least one copy is used in an article, the
|
||||
kept copy is the most used one (ties: oldest created_at, then lowest id), and every other
|
||||
copy of the group, used or unused, is merged into it. A group with no used copy is skipped
|
||||
And the fingerprint is a hex SHA-256 of the planned groups ordered by kept id, with their kept
|
||||
id and sorted merged ids; an empty plan has a fixed fingerprint
|
||||
And useCases.MergeMediaResult keeps ArticlesUpdated and Merged, and gains UpdatedArticleIDs,
|
||||
the ids of the articles actually rewritten (empty when the merge was rolled back or failed)
|
||||
And RunPurge merges each group with the MergeMedia semantics and unions UpdatedArticleIDs of the
|
||||
fully merged groups; it never recomputes the articles from the plan
|
||||
And next_purge_at is max(last_purge_at, enabled_at) + auto_purge_interval_days when automatic
|
||||
purge is on and null when it is off; with no last_purge_at it is enabled_at + interval
|
||||
And /v1/media/purge-settings, /v1/media/purge-preview and /v1/media/purge are mounted in
|
||||
api/router.go inside the NewMediaRouter chain (Verifier, Authenticator, RequireCurrentToken,
|
||||
RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and
|
||||
registered before the /{id} routes
|
||||
And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value,
|
||||
including an empty role, answers 403 with apperr.OwnerOrAdminRequiredErrorMessage
|
||||
("Only owners and admins can do this.") as plain text from http.Error
|
||||
And a stale fingerprint answers 409 with apperr.PurgeConflictErrorMessage ("The library changed,
|
||||
so nothing was purged. Check the new summary and try again.") and a busy run answers 409 with
|
||||
apperr.PurgeBusyErrorMessage ("A purge is already running.")
|
||||
And the purge settings range error is apperr.PurgeIntervalErrorMessage ("Enter a whole number of
|
||||
days between 1 and 365.")
|
||||
And request and response bodies are JSON with snake_case fields; errors are plain text
|
||||
And the request body of PATCH is validated by validate tags and validator.Validate.Struct
|
||||
|
||||
Scenario Outline: Every purge route refuses callers that are not allowed
|
||||
When the client sends <method> <path> <credentials>
|
||||
Then the response status is <status>
|
||||
And the body is <body>
|
||||
And no purge setting, image or article is read or changed
|
||||
|
||||
Examples:
|
||||
| method | path | credentials | status | body |
|
||||
| GET | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| PATCH | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| GET | /v1/media/purge-preview | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| POST | /v1/media/purge | no cookie | 401 | the existing jwtauth response (status only) |
|
||||
| GET | /v1/media/purge-settings | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of an unverified owner | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins both pass the role guard
|
||||
When the client sends <method> <path> with a valid cookie of a verified <role>
|
||||
Then the response status is not 401 and not 403
|
||||
|
||||
Examples:
|
||||
| role | method | path |
|
||||
| owner | GET | /v1/media/purge-settings |
|
||||
| admin | GET | /v1/media/purge-settings |
|
||||
| owner | GET | /v1/media/purge-preview |
|
||||
| admin | GET | /v1/media/purge-preview |
|
||||
|
||||
Rule: Purge settings
|
||||
|
||||
Scenario: Nothing was ever saved, so the defaults are served
|
||||
When the client sends GET /v1/media/purge-settings with a valid cookie of the owner
|
||||
Then the response status is 200
|
||||
And the body is {"auto_purge_enabled": false, "auto_purge_interval_days": 30,
|
||||
"last_purge_at": null, "last_purge_merged": 0, "last_purge_articles_updated": 0,
|
||||
"enabled_at": null, "next_purge_at": null}
|
||||
|
||||
Scenario: Turning automatic purge on stamps enabled_at and computes next_purge_at
|
||||
Given the clock reads 2026-10-05T09:00:00Z and the settings are the defaults
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then the response status is 200
|
||||
And "auto_purge_enabled" is true, "auto_purge_interval_days" is 30
|
||||
And "enabled_at" is "2026-10-05T09:00:00Z" and "last_purge_at" is null
|
||||
And "next_purge_at" is "2026-11-04T09:00:00Z"
|
||||
And a following GET /v1/media/purge-settings returns the same body
|
||||
|
||||
Scenario: next_purge_at counts from the last purge when it is later than enabled_at
|
||||
Given automatic purge is on with interval 10, enabled_at 2026-10-01T00:00:00Z and last_purge_at 2026-10-04T12:00:00Z
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then "next_purge_at" is "2026-10-14T12:00:00Z"
|
||||
|
||||
Scenario: next_purge_at counts from enabled_at when it is later than the last purge
|
||||
Given automatic purge is on with interval 10, enabled_at 2026-10-05T09:00:00Z and last_purge_at 2026-09-01T00:00:00Z
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then "next_purge_at" is "2026-10-15T09:00:00Z"
|
||||
|
||||
Scenario: Turning automatic purge off and on again never yields a next_purge_at in the past
|
||||
Given automatic purge was on, last_purge_at is 2026-09-01T00:00:00Z, and it was turned off
|
||||
And the clock reads 2026-10-05T09:00:00Z and the interval is 30
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then "enabled_at" is "2026-10-05T09:00:00Z"
|
||||
And "last_purge_at" is still "2026-09-01T00:00:00Z"
|
||||
And "next_purge_at" is "2026-11-04T09:00:00Z"
|
||||
|
||||
Scenario: Changing only the interval keeps enabled_at
|
||||
Given automatic purge is on since 2026-10-01T00:00:00Z with interval 30, no purge yet, and the clock reads 2026-10-05T09:00:00Z
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
|
||||
Then the response status is 200
|
||||
And "auto_purge_interval_days" is 7 and "enabled_at" is still "2026-10-01T00:00:00Z"
|
||||
And "next_purge_at" is "2026-10-08T00:00:00Z"
|
||||
|
||||
Scenario: Patching "on" while already on does not restamp enabled_at
|
||||
Given automatic purge is on since 2026-10-01T00:00:00Z and the clock reads 2026-10-05T09:00:00Z
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then "enabled_at" is still "2026-10-01T00:00:00Z"
|
||||
|
||||
Scenario: Turning automatic purge off nulls next_purge_at and keeps the history
|
||||
Given automatic purge is on with last_purge_at 2026-10-04T12:00:00Z, last_purge_merged 3 and last_purge_articles_updated 5
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": false}
|
||||
Then "auto_purge_enabled" is false and "next_purge_at" is null
|
||||
And "last_purge_at", "last_purge_merged" (3) and "last_purge_articles_updated" (5) are unchanged
|
||||
|
||||
Scenario Outline: The interval accepts whole numbers from 1 to 365
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": <days>}
|
||||
Then the response status is 200 and "auto_purge_interval_days" is <days>
|
||||
|
||||
Examples:
|
||||
| days |
|
||||
| 1 |
|
||||
| 30 |
|
||||
| 365 |
|
||||
|
||||
Scenario Outline: An invalid patch is 400 and nothing changes
|
||||
When the client sends PATCH /v1/media/purge-settings with <body>
|
||||
Then the response status is 400
|
||||
And the body is <message>
|
||||
And the stored settings are unchanged
|
||||
|
||||
Examples:
|
||||
| body | message |
|
||||
| {"auto_purge_interval_days": 0} | apperr.PurgeIntervalErrorMessage |
|
||||
| {"auto_purge_interval_days": 366} | apperr.PurgeIntervalErrorMessage |
|
||||
| {"auto_purge_interval_days": -1} | apperr.PurgeIntervalErrorMessage |
|
||||
| {"auto_purge_interval_days": 1.5} | apperr.BodyErrorMessage |
|
||||
| {"auto_purge_interval_days": "7"} | apperr.BodyErrorMessage |
|
||||
| {"auto_purge_enabled": "yes"} | apperr.BodyErrorMessage |
|
||||
| not json | apperr.BodyErrorMessage |
|
||||
|
||||
Scenario: An empty object changes nothing
|
||||
When the client sends PATCH /v1/media/purge-settings with {}
|
||||
Then the response status is 200 and the body equals the current settings
|
||||
|
||||
Scenario: The use case rejects an out-of-range interval with the domain sentinel
|
||||
When UpdatePurgeSettings receives interval 0, or 366
|
||||
Then errors.Is(err, purge.ErrInvalidPurgeInterval) is true
|
||||
And Repository.Save is not called
|
||||
|
||||
Scenario: A repository failure is a generic 500
|
||||
Given the purge settings repository's next Get fails
|
||||
When the client sends GET /v1/media/purge-settings
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: A failing Save on PATCH is a generic 500
|
||||
Given the purge settings repository's next Save fails
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: The purge routes are not taken by the /{id} routes
|
||||
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
|
||||
Then the settings are updated and no image is renamed
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the preview use case is invoked
|
||||
|
||||
Rule: Purge plan and preview
|
||||
|
||||
Background:
|
||||
Given the clock reads 2026-10-05T09:00:00Z
|
||||
And images are created in id order, oldest first
|
||||
And articles reference images by their stored name
|
||||
|
||||
Scenario: A group with a used copy is planned, merging every other copy into the most used
|
||||
Given images 1, 2 and 3 share sha256 A with filenames "hero.jpg", "hero-copy.jpg", "hero-2.jpg"
|
||||
And article 10 uses image 1, articles 11 and 12 use image 2, article 13 uses image 3
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the response status is 200
|
||||
And "groups" is 1, "copies" is 2 and "articles" is 2
|
||||
And "list" is [{"keep_id": 2, "filename": "hero-copy.jpg", "url": "<stored url of image 2>",
|
||||
"used_in": 2, "merged": 2}]
|
||||
And "more" is 0 and "fingerprint" is a 64-character hex string
|
||||
|
||||
Scenario: Ties on usage keep the oldest copy, then the lowest id
|
||||
Given images 4 and 5 share sha256 B, created at the same instant, each used in one article
|
||||
And images 6 and 7 share sha256 C, image 7 is older than image 6, each used in one article
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the "keep_id" of the group B is 4 and the "keep_id" of the group C is 7
|
||||
|
||||
Scenario: A single used copy with unused siblings keeps the used one
|
||||
Given images 8, 9 and 10 share sha256 D and only image 10 is used, in one article
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the group has keep_id 10, used_in 1, merged 2
|
||||
And "articles" is 0, since no article references a merged-away copy
|
||||
|
||||
Scenario: A group whose copies are all unused is skipped
|
||||
Given images 11 and 12 share sha256 E and no article references either
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 0, "copies" is 0, "articles" is 0, "list" is [] and "more" is 0
|
||||
|
||||
Scenario: A library with no duplicates
|
||||
Given the library holds three images with distinct sha256 values
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then the response status is 200
|
||||
And "groups" is 0 and "list" is an empty array in the raw JSON, not null
|
||||
And "fingerprint" equals the fingerprint of any other empty plan
|
||||
|
||||
Scenario: Images without a hash are never grouped
|
||||
Given images 13 and 14 both have an empty sha256 and both are used
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 0
|
||||
|
||||
Scenario: An article that uses copies of two groups counts once
|
||||
Given groups A (keep 1, copy 2) and B (keep 3, copy 4) and one article that uses images 2 and 4
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 2, "copies" is 2 and "articles" is 1
|
||||
|
||||
Scenario: An article that uses both the kept copy and a copy is rewritten
|
||||
Given an article uses images 1 and 2 of one group where image 1 is kept
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "articles" is 1
|
||||
|
||||
Scenario: The list shows at most 5 groups and reports the rest
|
||||
Given 7 planned groups
|
||||
When the client sends GET /v1/media/purge-preview
|
||||
Then "groups" is 7, "list" has 5 entries in ascending keep_id order and "more" is 2
|
||||
|
||||
Scenario: The preview changes nothing
|
||||
Given a planned group
|
||||
When the client sends GET /v1/media/purge-preview twice
|
||||
Then both bodies are equal
|
||||
And no image is deleted, no article body changes and no purge setting is saved
|
||||
|
||||
Scenario Outline: The fingerprint reacts to the plan and to nothing else
|
||||
Given a planned group with kept image 2 and merged image 3
|
||||
When <change>
|
||||
Then the preview fingerprint is <result>
|
||||
|
||||
Examples:
|
||||
| change | result |
|
||||
| nothing changes | identical |
|
||||
| a new unused copy is uploaded to the group | different |
|
||||
| a copy is deleted from the group | different |
|
||||
| article usage changes so that another copy becomes kept | different |
|
||||
| an unrelated image with a unique sha256 is uploaded | identical |
|
||||
| an article body changes without changing which is kept | identical |
|
||||
| a group with no used copy gains an unused copy | identical |
|
||||
|
||||
Rule: Running a purge
|
||||
|
||||
Background:
|
||||
Given the clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: A run merges every planned group and persists the result
|
||||
Given group A: images 1 (used in article 10), 2 (used in articles 11 and 12) and 3 (unused)
|
||||
And group B: images 4 (used in article 12) and 5 (unused)
|
||||
And group C: images 6 and 7, both unused
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 2, "groups_purged": 2, "merged": 3, "articles_updated": 1, "failed_groups": 0}
|
||||
And images 1 and 3 are gone from the image repository, image 2 remains
|
||||
And image 5 is gone, image 4 remains, and images 6 and 7 both remain
|
||||
And article 10's body now uses image 2's stored name
|
||||
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 3 and last_purge_articles_updated 1
|
||||
And only article 10 is rewritten, since article 12 uses only kept copies
|
||||
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are untouched
|
||||
|
||||
Scenario: An article rewritten by two groups is counted once in articles_updated
|
||||
Given an article uses a copy of group A and a copy of group B
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then "articles_updated" counts that article once, from the union of the groups' UpdatedArticleIDs
|
||||
|
||||
Scenario: A single used copy with unused siblings is merged without rewriting any article
|
||||
Given images 8, 9 and 10 share a hash and only image 10 is used
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the body is {"groups_total": 1, "groups_purged": 1, "merged": 2, "articles_updated": 0, "failed_groups": 0}
|
||||
And images 8 and 9 are gone, image 10 remains and no article body changed
|
||||
|
||||
Scenario: A matching fingerprint is accepted
|
||||
Given the preview returned fingerprint F
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
|
||||
Then the response status is 200 and the purge ran
|
||||
|
||||
Scenario: A stale fingerprint answers 409 and changes nothing
|
||||
Given the preview returned fingerprint F
|
||||
And an unused copy is then uploaded to a planned group
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
|
||||
Then the response status is 409
|
||||
And the body is apperr.PurgeConflictErrorMessage
|
||||
And no image is deleted, no article body changes and the stored last_purge_* values are unchanged
|
||||
|
||||
Scenario: A fingerprint that matches no plan at all is a conflict, not a validation error
|
||||
When the client sends POST /v1/media/purge with {"fingerprint": "not-a-real-fingerprint"}
|
||||
Then the response status is 409 and the body is apperr.PurgeConflictErrorMessage
|
||||
And nothing changed
|
||||
|
||||
Scenario: A run without a fingerprint always runs the current plan
|
||||
Given the library changed since any preview
|
||||
When the client sends POST /v1/media/purge with an empty body
|
||||
Then the response status is 200 and the current plan ran
|
||||
|
||||
Scenario: A library with nothing to purge answers zeros and is still recorded
|
||||
Given the library holds no planned group
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 0, "groups_purged": 0, "merged": 0, "articles_updated": 0, "failed_groups": 0}
|
||||
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 0 and last_purge_articles_updated 0
|
||||
And a scheduler reading next_purge_at sees it move forward from that run
|
||||
|
||||
Scenario: The fingerprint of the empty plan on an empty plan runs as a no-op
|
||||
Given the library holds no planned group
|
||||
When the client sends POST /v1/media/purge with the empty plan's fingerprint
|
||||
Then the response status is 200 and the body has groups_total 0
|
||||
|
||||
Scenario: A group that fails while rewriting bodies is rolled back and contributes no ids
|
||||
Given groups A, B and C are planned, in that order
|
||||
And the article repository's UpdateBody fails for the second article that group B must rewrite
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And the body is {"groups_total": 3, "groups_purged": 2, "merged": <copies of A and C>, "articles_updated": <distinct articles of A and C>, "failed_groups": 1}
|
||||
And group B's article bodies are back to their original text and all of group B's images remain
|
||||
And group B's UpdatedArticleIDs is empty, so no article of group B is in "articles_updated"
|
||||
And the stored last_purge_merged and last_purge_articles_updated equal the response totals
|
||||
|
||||
Scenario: A group that fails after rewriting bodies is reported as failed and contributes nothing
|
||||
Given the image repository's Delete fails for a merged image of group A, and group B is planned
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1
|
||||
And group A contributes 0 to "merged" and its article ids are not added to "articles_updated", even though its article bodies stay switched to the kept copy
|
||||
And "articles_updated" and "merged" count only the fully merged group B
|
||||
And a following GET /v1/media/purge-preview plans group A again, and the next run converges because the plan is recomputed from current data
|
||||
|
||||
Scenario: Every group failing is still a 200 with the counters
|
||||
Given every UpdateBody fails
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 200
|
||||
And "groups_purged" is 0 and "failed_groups" equals "groups_total"
|
||||
And the stored last_purge_at is set
|
||||
|
||||
Scenario: A second run while one is in progress is rejected as busy, not queued
|
||||
Given a purge run is in progress and blocked inside the image repository
|
||||
When a second POST /v1/media/purge arrives, with or without a fingerprint
|
||||
Then it answers 409 with apperr.PurgeBusyErrorMessage at once
|
||||
And it changed nothing and saved no last_purge_* values
|
||||
When the first run is released
|
||||
Then it finishes normally with its own counters
|
||||
And a later POST /v1/media/purge is accepted
|
||||
|
||||
Scenario: The use case distinguishes a busy run from a stale fingerprint
|
||||
When RunPurge receives a non-matching fingerprint
|
||||
Then errors.Is(err, purge.ErrPurgeConflict) is true and errors.Is(err, purge.ErrPurgeBusy) is false
|
||||
When RunPurge is called while another run holds the guard
|
||||
Then errors.Is(err, purge.ErrPurgeBusy) is true and errors.Is(err, purge.ErrPurgeConflict) is false
|
||||
And in both cases no repository write happens
|
||||
|
||||
Scenario: A run via the use case with no fingerprint needs no HTTP types
|
||||
When a scheduler later calls RunPurge with no fingerprint
|
||||
Then it gets the result struct and the same last_purge_* persistence applies
|
||||
|
||||
Scenario: A malformed body is 400
|
||||
When the client sends POST /v1/media/purge with "not json"
|
||||
Then the response status is 400 and the body is apperr.BodyErrorMessage
|
||||
And nothing changed
|
||||
|
||||
Scenario: A repository failure while planning is a generic 500 and nothing changes
|
||||
Given the image repository's ListDuplicates fails
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And no last_purge_* values are saved
|
||||
|
||||
Scenario: A failing save of the run result is a logged 500 and the merges stay
|
||||
Given the purge settings repository's SaveLastRun fails after the merges succeeded
|
||||
When the client sends POST /v1/media/purge with {}
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
And the merges that already happened stay done
|
||||
And the error is written to the injected *log.Logger
|
||||
And the next POST /v1/media/purge converges, because the plan is recomputed from current data
|
||||
|
||||
Scenario: The preview and the run use the same plan
|
||||
When the client sends GET /v1/media/purge-preview and then POST /v1/media/purge with the returned fingerprint
|
||||
Then the run's "groups_total" equals the preview's "groups"
|
||||
And the run's "merged" equals the preview's "copies" and its "articles_updated" equals the preview's "articles"
|
||||
|
||||
Rule: MergeMedia keeps its contract while exposing the rewritten ids
|
||||
|
||||
Scenario: Existing merges report the same counts and now also the ids
|
||||
When MergeMedia.Merge succeeds for a group that rewrites articles 1 and 2
|
||||
Then Merged and ArticlesUpdated are unchanged for existing callers
|
||||
And UpdatedArticleIDs is [1, 2]
|
||||
|
||||
Scenario: A rolled-back merge returns no ids
|
||||
When MergeMedia.Merge fails while rewriting a body
|
||||
Then it returns the error and an empty result
|
||||
|
||||
@manual
|
||||
Rule: Persistence and wiring (verified by hand, see the qa.md)
|
||||
|
||||
Scenario: The purge settings table is a single row created by AutoMigrate
|
||||
Given a database with no purge settings table
|
||||
When the server boots
|
||||
Then the table exists, GET /v1/media/purge-settings returns the defaults and no row is required beforehand
|
||||
And repeated PATCH calls keep exactly one row
|
||||
|
||||
Scenario: Settings survive a restart
|
||||
Given automatic purge was turned on with interval 7
|
||||
When the server restarts
|
||||
Then GET /v1/media/purge-settings returns the same values
|
||||
|
||||
Scenario: A real purge removes the merged files from UPLOAD_DIR
|
||||
Given duplicates uploaded through the real server
|
||||
When an owner runs POST /v1/media/purge
|
||||
Then the merged copies' files are gone from UPLOAD_DIR and the kept file remains
|
||||
@@ -0,0 +1,78 @@
|
||||
# QA procedure: media_purge
|
||||
|
||||
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Fakes:
|
||||
- `mockPurgeSettingsRepo` (domain `purge.Repository`): holds one `Settings`, `Get` returns the defaults when nothing was saved, `Save` records the call count, with armed failure switches `failGet` and `failSave`.
|
||||
- Reuse or extend `mockImageRepo` (`test/image_test.go`): `ListDuplicates()` (images with a non-empty sha256 shared by at least two rows, ordered `sha256, id`), `Delete` with `failDeleteFor`, `Get`, `FindByHash`, `Counts`. Add a hook that lets a test block inside `ListDuplicates` or `Delete` on a channel, to hold a run in progress.
|
||||
- Reuse or extend `mockArticleRepo` (`test/article_test.go`): `FindByFilters`, `FindByBodyContaining`, `UpdateBody` with a per-id failure switch.
|
||||
- The user fake: a verified owner (id 1), a verified admin (id 2), a verified user with `Role("editor")` (id 3), a verified user with an empty role (id 5), and an unverified owner (id 4).
|
||||
3. Wire the real use cases over the fakes: the four purge use cases (constructors chosen by the coder), `NewGetUserUseCase(userRepo)`, plus the existing media use cases. Inject a fixed clock (2026-10-05T09:00:00Z) and a `*log.Logger` writing to a `bytes.Buffer`. Add the four purge handler methods to the media handler, built with `media.NewHandler(...)`.
|
||||
4. Build `jwtauth.New("HS256", []byte("test-secret"), nil)` and mount on a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`:
|
||||
- `jwtauth.Verifier` at the top, then `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`.
|
||||
- The purge routes inside `r.Group` with `middleware.RequireOwnerOrAdmin(getUser)`, registered BEFORE `Patch("/{id}")` and `Delete("/{id}")`.
|
||||
- Also register `Get("/")`, `Post("/merge")`, `Patch("/{id}")` and `Delete("/{id}")` so route precedence is exercised.
|
||||
- Mount under `/v1/media`.
|
||||
5. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), "token_version": ...})` as in other HTTP tests. Build a stale-token cookie with a lower `token_version`.
|
||||
6. Seeding helper `seedGroup(images ...)` assigning ids in order, strictly increasing `CreatedAt` unless the case says otherwise, and `articleUsing(id, images ...)` building bodies that contain each image's stored name (`image.StoredName(path)`).
|
||||
7. Compare JSON bodies by decoding, except where the case says "raw body" (null versus `[]`, null versus absent). Error bodies are plain text: compare to `message + "\n"`.
|
||||
|
||||
## Auth and role checks (table-driven)
|
||||
8. For each row of the "refuse callers" outline (4 routes x no cookie, stale, unverified, role "editor", empty role), assert the status and body. Assert the settings, image and article fakes saw no call (only the user lookup of the middleware).
|
||||
9. Owner and admin cookies: GET `/v1/media/purge-settings` and GET `/v1/media/purge-preview` return a status other than 401 and 403.
|
||||
10. Allowlist guard: add a case proving a role other than `owner`/`admin` (for example "OWNER" in upper case, or "superuser") is refused with 403, so the middleware is not a blocklist.
|
||||
|
||||
## Settings
|
||||
11. Defaults: empty repo, GET returns the exact default body. Assert the raw JSON has `"last_purge_at":null`, `"enabled_at":null` and `"next_purge_at":null` (not omitted).
|
||||
12. PATCH on: assert `enabled_at` equals the fixed clock, `next_purge_at` = enabled_at + 30 days, the GET after returns the same body, and `Save` was called once.
|
||||
13. next_purge_at basis (max rule): last_purge_at later than enabled_at counts from last_purge_at; enabled_at later than last_purge_at counts from enabled_at; no last_purge_at counts from enabled_at.
|
||||
14. Re-enable step: seed on with `last_purge_at` 2026-09-01, PATCH `false`, then PATCH `true` at the fixed clock with interval 30. Assert `enabled_at` is the clock, `last_purge_at` is still 2026-09-01, and `next_purge_at` is 2026-11-04T09:00:00Z, strictly after the clock.
|
||||
15. Interval-only PATCH keeps `enabled_at`. PATCH `true` while already on keeps `enabled_at`. PATCH `false` nulls `next_purge_at` and keeps last_purge_*.
|
||||
16. Interval table (1, 30, 365 accepted). Invalid table (0, 366, -1 give `apperr.PurgeIntervalErrorMessage`; 1.5, "7", "yes", not json give `apperr.BodyErrorMessage`). Assert the repo state and the `Save` count are unchanged after each 400.
|
||||
17. `{}` PATCH returns 200 equal to the current settings.
|
||||
18. Use-case-level: `UpdatePurgeSettings` with interval 0 and 366 returns an error with `errors.Is(err, purge.ErrInvalidPurgeInterval)` and no `Save`. At the handler, a value of 0 must be rejected by the validate tag and never reach the use case.
|
||||
19. Failure rows: `failGet` gives 500 "Internal server error\n"; `failSave` on PATCH gives 500.
|
||||
20. Route precedence: PATCH `/v1/media/purge-settings` reaches the settings handler and does not touch `Rename`.
|
||||
|
||||
## Preview
|
||||
21. Build each Background scenario with the seed helpers and assert the decoded `groups`, `copies`, `articles`, `list` and `more`:
|
||||
- used group (keep 2 with used_in 2; copies 2; articles 2);
|
||||
- tie-breaks: same CreatedAt gives the lowest id, different CreatedAt gives the oldest (assert both orders by swapping ids);
|
||||
- a single used copy with unused siblings (articles 0);
|
||||
- an all-unused group;
|
||||
- no duplicates (raw `"list":[]`);
|
||||
- empty-hash images ignored;
|
||||
- an article touching two groups counts once;
|
||||
- an article using kept plus a copy counts once;
|
||||
- 7 groups give 5 list entries in ascending keep_id order and `more` 2. Each entry has `keep_id`, `filename`, `url`, `used_in`, `merged`.
|
||||
22. Preview purity: call twice, assert equal bodies, assert zero `Delete`, `UpdateBody` and `Save` calls.
|
||||
23. Fingerprint table: compute a baseline, apply each change from the outline, and assert equal or different. Assert it is a 64-char lowercase hex string and that two empty plans share it.
|
||||
|
||||
## Run
|
||||
24. Full run scenario: assert the response body, the remaining images, the rewritten article bodies (check bytes), and `Save` called once with `last_purge_at` = clock, `last_purge_merged` = 3 and `last_purge_articles_updated` = 1, and that `auto_purge_*` and `enabled_at` are untouched. Article 12 uses only kept copies, so only article 10 is rewritten.
|
||||
25. `articles_updated` source: it must come from the union of `MergeMediaResult.UpdatedArticleIDs` returned by the group merges, never from the plan. Prove it with the rolled-back case in step 29: the plan contains B's articles, the response does not.
|
||||
26. Used-sibling run with zero article rewrites (`articles_updated` 0, `UpdateBody` never called).
|
||||
27. Fingerprint: match gives 200. A stale fingerprint gives 409 with `apperr.PurgeConflictErrorMessage + "\n"`, no `Delete`, no `UpdateBody`, no `Save`. A garbage string gives 409 with the same body. No fingerprint, `{}` and an empty body all run.
|
||||
28. Empty plan: 200 zeros, `Save` with `last_purge_at` = clock and zero counters, and a run with the empty plan's fingerprint is also 200.
|
||||
29. Partial failure, rolled back: arm `UpdateBody` to fail for the second article of group B (plan order A, B, C). Assert the body counters, B's bodies restored byte for byte, all B images present, A and C merged, B's articles absent from `articles_updated`, and `Save` counters equal the response.
|
||||
30. Partial failure, delete: arm `failDeleteFor` for one of group A's merged images and plan B too. Assert `failed_groups` 1, `groups_purged` 1, A contributes 0 to `merged` and none of its ids to `articles_updated` (only B counts), A's article bodies stay switched to the kept copy, and a following preview plans A again. Run the purge again with the failure disarmed and assert it converges (A's leftover copy is merged away). Then arm every `UpdateBody` and assert `groups_purged` 0, `failed_groups` = `groups_total` and `last_purge_at` still saved.
|
||||
31. Concurrency: start run 1 in a goroutine, blocked in the image fake (wait on a "started" channel). Send run 2 (once with a fingerprint, once without). Assert both answer 409 with `apperr.PurgeBusyErrorMessage + "\n"` immediately (use a timeout) and no `Save`/`Delete` calls from them. Release run 1, assert its 200 body and that a later run is accepted. Run this test with `-race`.
|
||||
32. Sentinels at the use-case level, asserted separately: a stale fingerprint gives `errors.Is(err, purge.ErrPurgeConflict)` true and `errors.Is(err, purge.ErrPurgeBusy)` false; a busy guard gives `ErrPurgeBusy` true and `ErrPurgeConflict` false. Call RunPurge without any HTTP package to prove it is reusable (that test file section imports no `net/http`).
|
||||
33. Malformed body gives 400 `apperr.BodyErrorMessage`. `ListDuplicates` failure gives 500 and no `Save`.
|
||||
34. Persist failure: arm `failSave` on the run-result save after merges succeed. Assert 500 "Internal server error\n", the merged images are still gone, the logger buffer contains the error text, then disarm and run again and assert 200 with no remaining groups (convergence from current data).
|
||||
35. Preview versus run parity: the run's `groups_total`, `merged` and `articles_updated` equal the preview's `groups`, `copies` and `articles` on the same seed.
|
||||
36. MergeMedia contract: extend the existing merge tests only by adding assertions. `Merged` and `ArticlesUpdated` are unchanged, `UpdatedArticleIDs` lists the rewritten ids in ascending order, and a rolled-back merge returns the error and an empty result. Existing callers and tests must compile and pass unchanged.
|
||||
|
||||
## Layering guards
|
||||
37. `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package. The new use cases import no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...` package. `internal/domain/purge` imports only the stdlib and other `internal/domain/*` packages. Use a `go list -deps` or grep-style test as in media_library.qa.md.
|
||||
|
||||
## Manual verification (not exercised by `task test`)
|
||||
- AutoMigrate: boot against a scratch SQLite file (`task dev`) and check the purge settings table exists, GET returns the defaults, repeated PATCH keeps one row, and values survive a restart. The mapper round-trips `last_purge_at`/`enabled_at` including null.
|
||||
- Real purge against real uploads: upload duplicates through `POST /v1/media/`, reference them from articles, run the preview then the purge. The merged files disappear from `UPLOAD_DIR`, the kept file loads through `/static/uploadedImages/`, and article bodies are switched.
|
||||
- Concurrent real requests with two shells (`curl` with the `jwt` cookie): the second gets 409 "A purge is already running." while the first runs.
|
||||
- Role check with a real admin account (invite then accept). Use a hand-edited user row with an empty or unknown role to check the 403.
|
||||
- The persist-failure log line appears in the server output (check by making the database read-only during a purge).
|
||||
- Docs: `docs/architecture.md` lists the `purge` domain package, the four use cases, the settings repository, the new routes and `RequireOwnerOrAdmin`. Read it and compare. No SMTP and no env var are involved.
|
||||
- Out of scope: the scheduler (part 2) and the SPA (part 3).
|
||||
@@ -0,0 +1,286 @@
|
||||
Feature: Duplicate images are purged automatically on the configured schedule
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want the duplicate purge to run by itself every few days once I turn it on
|
||||
So that I do not have to remember to purge, and the Settings "Last purge" line stays current
|
||||
|
||||
Background:
|
||||
Given part 1 provides purge.Settings, purge.Repository (Get, Save and SaveLastRun) and RunPurgeUseCase.Run(fingerprint *string)
|
||||
And a new use case PurgeSchedulerUseCase in internal/infrastructure/useCases depends only on purge.Repository
|
||||
and a RunPurge port, takes no HTTP types and imports no persistence, mailer, models, gorm, net/http, chi or api
|
||||
And PurgeSchedulerUseCase.Tick(now time.Time) (result purge.RunResult, ran bool, err error) reads the settings on every call,
|
||||
where result carries groups purged, copies merged and articles updated and is empty when ran is false
|
||||
And a run is due when auto_purge_enabled is true, enabled_at is set and now is not before
|
||||
Settings.NextPurgeAt(), that is max(last_purge_at, enabled_at) + auto_purge_interval_days days
|
||||
And a scheduled run is RunPurge.Run(nil): no fingerprint, no HTTP, no user-visible message
|
||||
And a run that finds nothing to purge still records last_purge_at with merged 0 and articles_updated 0,
|
||||
so the anchor moves forward (this is RunPurge's existing behaviour)
|
||||
And the scheduler never writes settings itself: only RunPurge records last_purge_*,
|
||||
through Repository.SaveLastRun, which writes only the last_purge_* fields and never the whole settings row
|
||||
And there is no env var: the schedule (auto_purge_enabled, auto_purge_interval_days, enabled_at,
|
||||
last_purge_at) lives in the purge settings row, and is read again on every tick
|
||||
And a runner in the new package internal/infrastructure/scheduler calls Tick on a ticker every 1 hour,
|
||||
runs one Tick after a startup delay of 1 minute, and stops when its context is cancelled
|
||||
And the runner takes its clock, its tick source and its startup delay by injection, so tests use a fake clock
|
||||
and hand-fed channels, with no real sleeping and no real time.Ticker
|
||||
And api/dependecyInjection.go builds the runner and cmd/server/main.go only starts it
|
||||
And the runner uses the *log.Logger already used by RunPurge (log.Default() in the container)
|
||||
|
||||
Rule: When a run is due
|
||||
|
||||
Background:
|
||||
Given the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Automatic purge off never runs, whatever the dates are
|
||||
Given auto_purge_enabled is false, interval 1, enabled_at 2020-01-01T00:00:00Z and last_purge_at 2020-01-02T00:00:00Z
|
||||
When Tick is called with the fake clock's time
|
||||
Then ran is false and err is nil
|
||||
And RunPurge was not called and Save was not called
|
||||
|
||||
Scenario: The defaults never run
|
||||
Given nothing was ever saved, so the settings are the defaults
|
||||
When Tick is called
|
||||
Then ran is false and RunPurge was not called
|
||||
|
||||
Scenario: On, never ran, not due yet
|
||||
Given auto_purge_enabled is true, interval 30, enabled_at 2026-09-20T09:00:00Z and no last_purge_at
|
||||
When Tick is called
|
||||
Then ran is false and RunPurge was not called
|
||||
|
||||
Scenario: On, never ran, due since enabled_at plus the interval
|
||||
Given auto_purge_enabled is true, interval 10, enabled_at 2026-09-25T09:00:00Z and no last_purge_at
|
||||
When Tick is called
|
||||
Then RunPurge is called once with no fingerprint and ran is true
|
||||
|
||||
Scenario Outline: The anchor is the later of last_purge_at and enabled_at
|
||||
Given auto_purge_enabled is true and interval 10
|
||||
And enabled_at is <enabled_at> and last_purge_at is <last_purge_at>
|
||||
When Tick is called at 2026-10-05T09:00:00Z
|
||||
Then RunPurge is called <calls> time(s)
|
||||
|
||||
Examples:
|
||||
| enabled_at | last_purge_at | calls | why |
|
||||
| 2026-09-01T00:00:00Z | 2026-09-20T00:00:00Z | 1 | last purge + 10 days = 2026-09-30, due |
|
||||
| 2026-09-01T00:00:00Z | 2026-09-28T00:00:00Z | 0 | last purge + 10 days = 2026-10-08, not due |
|
||||
| 2026-09-28T00:00:00Z | 2026-09-01T00:00:00Z | 0 | enabled_at is later, 2026-10-08, not due |
|
||||
| 2026-09-20T00:00:00Z | 2026-09-01T00:00:00Z | 1 | enabled_at is later, 2026-09-30, due |
|
||||
| 2026-09-25T09:00:00Z | none | 1 | exactly now is due (2026-10-05T09:00:00Z) |
|
||||
| 2026-09-25T09:00:01Z | none | 0 | one second before due |
|
||||
|
||||
Scenario: Due exactly at the boundary, not before
|
||||
Given the next purge is 2026-10-05T09:00:00Z
|
||||
When Tick is called at 2026-10-05T08:59:59Z
|
||||
Then RunPurge is not called
|
||||
When Tick is called at 2026-10-05T09:00:00Z
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: A run advances the anchor, so the next tick is not due
|
||||
Given a due run with RunPurge backed by the fake settings repository
|
||||
When Tick is called twice with the same time
|
||||
Then RunPurge is called once
|
||||
And last_purge_at equals the fake clock's time
|
||||
|
||||
Scenario: A due run that finds nothing to purge still moves the clock forward
|
||||
Given a due schedule and a library with no planned group
|
||||
When Tick is called
|
||||
Then the stored last_purge_at is the clock's time, last_purge_merged is 0 and last_purge_articles_updated is 0
|
||||
And a second Tick at the same time does not run
|
||||
And a Tick after the interval has elapsed runs again
|
||||
|
||||
Scenario: A run keeps auto_purge_enabled, the interval and enabled_at
|
||||
Given a due schedule
|
||||
When Tick runs
|
||||
Then auto_purge_enabled, auto_purge_interval_days and enabled_at are unchanged in the stored settings
|
||||
|
||||
Scenario: Enabled with no enabled_at is not due
|
||||
Given auto_purge_enabled is true and enabled_at is unset
|
||||
When Tick is called
|
||||
Then RunPurge is not called and the scheduler does not stamp enabled_at itself
|
||||
|
||||
Rule: Settings changes take effect on the next tick, with no restart
|
||||
|
||||
Background:
|
||||
Given the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Shortening the interval makes a run due on the next tick
|
||||
Given auto_purge_enabled is true since 2026-09-25T09:00:00Z with interval 30 and no purge yet
|
||||
And Tick did not run
|
||||
When UpdatePurgeSettings sets auto_purge_interval_days to 7
|
||||
And Tick is called again
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: Lengthening the interval postpones the run
|
||||
Given auto_purge_enabled is true since 2026-09-20T09:00:00Z with interval 10 and no purge yet, so a run is due
|
||||
When UpdatePurgeSettings sets auto_purge_interval_days to 30
|
||||
And Tick is called
|
||||
Then RunPurge is not called
|
||||
|
||||
Scenario: Turning it off stops runs
|
||||
Given a due schedule
|
||||
When UpdatePurgeSettings sets auto_purge_enabled to false
|
||||
And Tick is called
|
||||
Then RunPurge is not called
|
||||
|
||||
Scenario: Turning it on resets the anchor to the moment of the switch
|
||||
Given auto_purge_enabled was turned off, last_purge_at is 2026-09-01T00:00:00Z, the interval is 30 and the clock reads 2026-10-05T09:00:00Z
|
||||
When UpdatePurgeSettings sets auto_purge_enabled to true
|
||||
And Tick is called at once
|
||||
Then RunPurge is not called, because enabled_at is now
|
||||
When Tick is called at 2026-11-04T09:00:00Z
|
||||
Then RunPurge is called once
|
||||
|
||||
Scenario: The scheduler reads the settings at every tick
|
||||
When Tick is called three times with different stored settings
|
||||
Then Repository.Get is called three times and no value is cached between ticks
|
||||
|
||||
Rule: Failures never crash the loop and are retried at the next tick
|
||||
|
||||
Background:
|
||||
Given a due schedule and the fake clock reads 2026-10-05T09:00:00Z
|
||||
|
||||
Scenario: Reading the settings fails
|
||||
Given the purge settings repository's next Get fails
|
||||
When Tick is called
|
||||
Then ran is false and err is the repository error
|
||||
And RunPurge was not called
|
||||
And the next Tick, with Get working again, runs the purge
|
||||
|
||||
Scenario: RunPurge returns an error
|
||||
Given RunPurge fails, for example ListDuplicates fails
|
||||
When Tick is called
|
||||
Then err is that error and last_purge_at is unchanged
|
||||
And RunPurge was called once, not retried inside the same Tick
|
||||
And the next Tick, with the failure cleared, runs the purge
|
||||
|
||||
Scenario: Saving the run result fails after the merges succeeded
|
||||
Given the purge settings repository's SaveLastRun fails
|
||||
When Tick is called
|
||||
Then err is returned and the merges stay done
|
||||
And the next Tick plans again from current data and converges
|
||||
|
||||
Scenario: A run that partially fails still records what it merged
|
||||
Given group A merges and group B's UpdateBody fails
|
||||
When Tick is called
|
||||
Then ran is true and err is nil
|
||||
And last_purge_at is set and last_purge_merged and last_purge_articles_updated count only group A
|
||||
And group B is planned again by the next run
|
||||
|
||||
Scenario: A settings change made during a run survives
|
||||
Given a run is in progress and about to record its result
|
||||
When UpdatePurgeSettings turns auto_purge_enabled off and sets the interval to 5 meanwhile
|
||||
And the run completes
|
||||
Then the stored auto_purge_enabled is false and the interval is 5
|
||||
And last_purge_at, last_purge_merged and last_purge_articles_updated hold the run's values
|
||||
|
||||
Scenario: A manual run is in progress
|
||||
Given a manual POST /v1/media/purge holds the RunPurge guard
|
||||
When Tick is called with a due schedule
|
||||
Then the scheduler does not queue or wait: it returns at once with errors.Is(err, purge.ErrPurgeBusy)
|
||||
And nothing is saved by the scheduler
|
||||
And the runner treats it as a skip, not a failure, and the next tick re-evaluates
|
||||
When the manual run finishes and records last_purge_at
|
||||
Then the next Tick finds the schedule not due and does not run
|
||||
|
||||
Scenario: A failing tick does not stop the runner
|
||||
Given the runner is running with a hand-fed tick channel
|
||||
And the first Tick returns an error and the second returns ran true
|
||||
When two ticks are fed
|
||||
Then the error is written to the injected logger
|
||||
And the runner is still running and the second Tick was executed
|
||||
And no Tick is called between the two fed ticks (no tight retry loop)
|
||||
|
||||
Scenario: A completed run logs one line with the counts
|
||||
Given Tick returns ran true with groups purged 3, merged 5, articles updated 2 and failed groups 1
|
||||
When the runner receives the tick
|
||||
Then exactly one line is logged: "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1"
|
||||
And a run that found nothing to purge logs the same line with every count at 0
|
||||
|
||||
Scenario: A due Tick returns what the run did
|
||||
Given a due schedule and one planned group
|
||||
When Tick is called
|
||||
Then ran is true and the result holds the run's groups purged, merged and articles updated
|
||||
And a Tick that is not due returns an empty result
|
||||
|
||||
Scenario: A busy skip is not logged as an error
|
||||
Given Tick returns ErrPurgeBusy
|
||||
When the runner receives the tick
|
||||
Then it logs no error line and keeps waiting for the next tick
|
||||
|
||||
Rule: The runner starts, ticks and stops with the server
|
||||
|
||||
Scenario: One Tick shortly after startup
|
||||
Given a runner with a hand-fed startup signal standing in for the 1 minute delay
|
||||
When the startup signal fires
|
||||
Then Tick is called once with the fake clock's time
|
||||
And no Tick is called before the signal
|
||||
|
||||
Scenario: A due run is caught up at startup after downtime
|
||||
Given the server was down for 40 days, the interval is 30 and last_purge_at is 40 days old
|
||||
When the runner's startup Tick fires
|
||||
Then RunPurge is called once, and only once however many ticks were missed
|
||||
|
||||
Scenario: A Tick on each hourly tick
|
||||
Given the runner is running
|
||||
When three hourly ticks are fed
|
||||
Then Tick is called three times, each with the fake clock's time at that tick
|
||||
|
||||
Scenario: Cancelling the context stops the runner
|
||||
Given the runner is idle
|
||||
When its context is cancelled
|
||||
Then Run returns promptly
|
||||
And no further Tick is called even if a tick is fed after cancel
|
||||
|
||||
Scenario: Shutdown waits for a run in progress without a time limit
|
||||
Given Tick is blocked inside RunPurge (fake image repository blocked on a channel)
|
||||
When the context is cancelled
|
||||
Then Run does not return yet
|
||||
When the blocked run is released and completes
|
||||
Then Run returns after the run has recorded last_purge_at
|
||||
And the database is closed only after Run has returned
|
||||
|
||||
Scenario: Cancelled before the startup signal
|
||||
Given the context is cancelled before the startup signal
|
||||
Then Tick is never called and Run returns
|
||||
|
||||
Rule: The Settings page shows the result (HTTP observable, no SPA change)
|
||||
|
||||
Scenario: A scheduled run updates the Last purge values served by the settings endpoint
|
||||
Given an owner, automatic purge on with interval 7, enabled_at 2026-09-20T09:00:00Z and a planned group
|
||||
And the fake clock reads 2026-10-05T09:00:00Z
|
||||
When Tick runs
|
||||
And the client sends GET /v1/media/purge-settings with a valid cookie of the owner
|
||||
Then "last_purge_at" is "2026-10-05T09:00:00Z"
|
||||
And "last_purge_merged" and "last_purge_articles_updated" hold the run's counts
|
||||
And "next_purge_at" is "2026-10-12T09:00:00Z"
|
||||
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are unchanged
|
||||
|
||||
Scenario: A scheduled run with nothing to purge shows zeros, not "Never run"
|
||||
Given automatic purge on and due and no planned group
|
||||
When Tick runs and the client sends GET /v1/media/purge-settings
|
||||
Then "last_purge_at" is "2026-10-05T09:00:00Z", "last_purge_merged" is 0 and "last_purge_articles_updated" is 0
|
||||
|
||||
Scenario: A scheduled run answers no HTTP request and returns no message
|
||||
When Tick runs
|
||||
Then no HTTP handler is involved and the only observable results are the stored settings, the images, the article bodies and a log line
|
||||
|
||||
Rule: Layering and documentation
|
||||
|
||||
Scenario: The scheduler stays inside the domain boundary
|
||||
Then PurgeSchedulerUseCase imports only the stdlib, internal/domain/* and package-local use case types
|
||||
And internal/infrastructure/scheduler imports no persistence, mailer, models or gorm package
|
||||
And only api/dependecyInjection.go constructs it and only cmd/server/main.go starts it
|
||||
|
||||
Scenario: The docs describe the scheduler and state that there is no env var
|
||||
Then docs/architecture.md describes the scheduler, the hourly tick, the startup tick, the due rule, the overlap guard and the unbounded shutdown wait
|
||||
And docs/configuration.md states that automatic purge has no env var and that its schedule lives in the database
|
||||
And no new Go, TS, docs or config file contains a comment
|
||||
|
||||
@manual
|
||||
Rule: Wiring verified by hand (see the qa.md)
|
||||
|
||||
Scenario: The runner starts with the real server and stops on SIGINT or SIGTERM
|
||||
Given the server runs and automatic purge is on and due
|
||||
When one minute elapses after startup
|
||||
Then a purge ran and the Settings "Last purge" line shows the new date on the next visit
|
||||
When SIGTERM is sent during a run
|
||||
Then the process exits only after the run finished, and the SQLite file is not corrupted
|
||||
@@ -0,0 +1,56 @@
|
||||
# QA procedure: media_purge_scheduler
|
||||
|
||||
Package: `test` (black-box). New file `test/media_purge_scheduler_test.go`. Go only: no SPA. No env var. Fake type names must be unique in the package. Reuse part 1's `mockPurgeSettingsRepo`, `mockImageRepo`, `mockArticleRepo`, `seedGroup` and `articleUsing` from test/media_purge_test.go and test/media_purge_http_test.go. No real sleeping, no `time.Ticker`, no `time.Sleep`, no live server, no real SQLite file.
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once (only needed by the HTTP cases).
|
||||
2. Fakes:
|
||||
- `fakeClock` (mutable, `Now()` and `Set(t)`), starting at 2026-10-05T09:00:00Z. Pass `clock.Now` as the `now func() time.Time` of RunPurge and UpdatePurgeSettings so last_purge_at and enabled_at come from it.
|
||||
- `mockPurgeSettingsRepo` with `Get`, `Save` and `SaveLastRun` (writes only the last_purge_* fields), failure switches for each, a write counter shared by Save and SaveLastRun, and a hook called before SaveLastRun writes.
|
||||
- A `fakePurgeRunPort` wrapping the real `RunPurgeUseCase`. It counts calls, records the fingerprint argument (must be nil), and can be armed to return an error. A second variant uses the real RunPurge directly for the integration cases.
|
||||
- Hand-fed `tickCh` and `startCh` channels passed to the runner in `internal/infrastructure/scheduler` in place of time.NewTicker and the startup delay. Use a `bytes.Buffer` logger.
|
||||
3. Wire the real `PurgeSchedulerUseCase` over the settings fake, the run port and the clock.
|
||||
|
||||
## Tick decisions (table-driven)
|
||||
4. Off never runs: off with ancient dates; the defaults; assert ran false, err nil, zero RunPurge calls, zero Save calls.
|
||||
5. Not due, due, boundary: enabled_at + interval one second after now gives no run; exactly now gives a run; assert the fingerprint argument is nil.
|
||||
6. Anchor table: the six rows of the outline. Cross-check each row against `Settings.NextPurgeAt()` to prove the scheduler reuses it.
|
||||
7. Enabled with unset enabled_at never runs and the scheduler never stamps it.
|
||||
8. Repeat the same Tick twice with the real RunPurge: one run (anchor moved), and `last_purge_at` equals the clock. Advance the clock past the interval: a third Tick runs.
|
||||
9. Empty library: due Tick records `last_purge_at` = clock, merged 0, articles_updated 0, and leaves auto_purge_enabled, interval and enabled_at untouched.
|
||||
10. Settings changes: shorten the interval (via `UpdatePurgeSettings` on the same fake) and a run becomes due; lengthen it and a due run is postponed; toggle off and no run; toggle off then on gives enabled_at = clock, no run now, a run once the clock passes +30 days. Assert `Get` is called once per Tick.
|
||||
|
||||
## Failures
|
||||
11. `failGet`: Tick returns the error, RunPurge count 0, the next Tick with the fake healed runs.
|
||||
12. RunPurge error (`ListDuplicates` failure): error returned, `last_purge_at` unchanged, one RunPurge call per Tick, recovery on the next Tick.
|
||||
13. `SaveLastRun` failure after merges: error returned, merged images still gone, next Tick converges.
|
||||
14. Partial failure (UpdateBody failing for group B): ran true, err nil, `last_purge_at` set, counters count group A only.
|
||||
15. Busy: block a manual `RunPurge.Run(nil)` inside the image fake (started channel). Call Tick with a due schedule and assert it returns promptly (use a timeout) with `errors.Is(err, purge.ErrPurgeBusy)`, with no `Save` from the scheduler. Release; the next Tick is not due because the manual run recorded `last_purge_at`. Run with `-race`.
|
||||
|
||||
## Runner
|
||||
16. Feed a startup signal: exactly one Tick, with the clock's time; no Tick before the signal.
|
||||
17. Feed three ticks: three Ticks. Feed none: no Tick. Assert no Tick between fed ticks.
|
||||
18. Error then success from Tick: the error text is in the logger buffer, the loop continues, the second Tick executes, and `Run` has not returned.
|
||||
19. Busy from Tick: no error line in the logger; the loop continues.
|
||||
19b. Completed run: Tick returns ran true with a known `purge.RunResult`; the logger buffer holds exactly one line with groups_purged, merged, articles_updated and failed_groups. An empty result logs zeros.
|
||||
19c. Tick result: a due Tick returns the run result; a Tick that is not due returns the empty result.
|
||||
19d. Lost update: block the settings fake just before `SaveLastRun` writes, PATCH auto_purge_enabled off and the interval to 5 through `UpdatePurgeSettings`, release; the stored auto_purge_* survive and last_purge_* are the run's. The persistence repository test checks that `SaveLastRun` leaves the other columns untouched and creates the default row when none exists.
|
||||
20. Cancel the context while idle: `Run` returns within a timeout; then feed a tick and assert no Tick happens. Cancel before the startup signal: no Tick at all.
|
||||
21. Shutdown waits without a bound: block RunPurge in the image fake, cancel the context, assert `Run` has not returned (select with a short timeout), release, assert `Run` returns and `last_purge_at` was recorded. `-race`.
|
||||
22. The runner and the use case spawn no goroutine that outlives `Run` (verify with a done channel closed on return).
|
||||
|
||||
## HTTP-observable check (existing settings endpoint)
|
||||
23. Mount the part 1 media router (same setup as media_purge.qa.md) over the same fakes. Seed on, interval 7, enabled_at 2026-09-20T09:00:00Z, one planned group. Tick, then GET `/v1/media/purge-settings` with the owner cookie. Assert `last_purge_at` 2026-10-05T09:00:00Z, counts equal the run, `next_purge_at` 2026-10-12T09:00:00Z, and the other fields unchanged. Repeat with an empty library: zeros and a non-null `last_purge_at`.
|
||||
|
||||
## Layering guards
|
||||
24. The scheduler use case imports no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...`. Same grep-style test as media_purge.qa.md. `internal/infrastructure/scheduler` imports no `persistence`, `mailer`, `models` or `gorm`, and no `api/...`.
|
||||
25. Grep the new and edited files (Go, docs) for comments: none; only tool directives.
|
||||
|
||||
## Docs
|
||||
26. `docs/architecture.md` has a scheduler paragraph (hourly tick, startup tick, due rule from `NextPurgeAt`, overlap guard from RunPurge, unbounded shutdown wait, failures retried at the next tick). `docs/configuration.md` states that there is no env var and that the schedule lives in the database. Read both files and confirm.
|
||||
|
||||
## Manual (not automated)
|
||||
27. Real boot with `task dev:api` or the built binary: turn automatic purge on, set interval 1 and backdate `enabled_at` in the SQLite file (or wait); about a minute after start, a purge runs, `GET /v1/media/purge-settings` shows the new `last_purge_at`, and Settings "Last purge" is updated on the next visit.
|
||||
28. Send SIGINT or SIGTERM during a long run: the process exits only after the run ended, with no database error on close.
|
||||
29. A scheduled run and a manual "Purge now" at the same moment: one wins, the other answers 409 busy (HTTP) or is skipped (scheduler).
|
||||
30. Docker: the same behaviour in the container, with state on /data.
|
||||
@@ -0,0 +1,56 @@
|
||||
package purge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidPurgeInterval = errors.New("invalid purge interval")
|
||||
var ErrPurgeConflict = errors.New("the library changed since the fingerprint")
|
||||
var ErrPurgeBusy = errors.New("a purge is already running")
|
||||
|
||||
const (
|
||||
DefaultIntervalDays = 30
|
||||
MinIntervalDays = 1
|
||||
MaxIntervalDays = 365
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
|
||||
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
|
||||
LastPurgeAt *time.Time `json:"last_purge_at"`
|
||||
LastPurgeMerged int `json:"last_purge_merged"`
|
||||
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
|
||||
EnabledAt *time.Time `json:"enabled_at"`
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{AutoPurgeIntervalDays: DefaultIntervalDays}
|
||||
}
|
||||
|
||||
func ValidateIntervalDays(days int) error {
|
||||
if days < MinIntervalDays || days > MaxIntervalDays {
|
||||
return ErrInvalidPurgeInterval
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Settings) NextPurgeAt() *time.Time {
|
||||
if !s.AutoPurgeEnabled || s.EnabledAt == nil {
|
||||
return nil
|
||||
}
|
||||
base := *s.EnabledAt
|
||||
if s.LastPurgeAt != nil && s.LastPurgeAt.After(base) {
|
||||
base = *s.LastPurgeAt
|
||||
}
|
||||
next := base.AddDate(0, 0, s.AutoPurgeIntervalDays)
|
||||
return &next
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
GroupsTotal int
|
||||
GroupsPurged int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
FailedGroups int
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package purge
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository interface {
|
||||
Get() (Settings, error)
|
||||
Save(settings Settings) error
|
||||
SaveLastRun(at time.Time, merged int, articlesUpdated int) error
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func PurgeSettingsToDomain(m models.PurgeSettings) purge.Settings {
|
||||
return purge.Settings{
|
||||
AutoPurgeEnabled: m.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: m.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: m.LastPurgeAt,
|
||||
LastPurgeMerged: m.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: m.LastPurgeArticlesUpdated,
|
||||
EnabledAt: m.EnabledAt,
|
||||
}
|
||||
}
|
||||
|
||||
func PurgeSettingsToModel(s purge.Settings) models.PurgeSettings {
|
||||
return models.PurgeSettings{
|
||||
AutoPurgeEnabled: s.AutoPurgeEnabled,
|
||||
AutoPurgeIntervalDays: s.AutoPurgeIntervalDays,
|
||||
LastPurgeAt: s.LastPurgeAt,
|
||||
LastPurgeMerged: s.LastPurgeMerged,
|
||||
LastPurgeArticlesUpdated: s.LastPurgeArticlesUpdated,
|
||||
EnabledAt: s.EnabledAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PurgeSettings struct {
|
||||
ID uint32 `gorm:"primaryKey"`
|
||||
AutoPurgeEnabled bool `gorm:"not null"`
|
||||
AutoPurgeIntervalDays int `gorm:"not null"`
|
||||
LastPurgeAt *time.Time
|
||||
LastPurgeMerged int `gorm:"not null"`
|
||||
LastPurgeArticlesUpdated int `gorm:"not null"`
|
||||
EnabledAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const purgeSettingsRowID uint32 = 1
|
||||
|
||||
type PurgeSettingsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domainPurge.Repository = &PurgeSettingsRepository{}
|
||||
|
||||
func NewPurgeSettingsRepository(db *gorm.DB) *PurgeSettingsRepository {
|
||||
return &PurgeSettingsRepository{db}
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Get() (domainPurge.Settings, error) {
|
||||
var model entity.PurgeSettings
|
||||
err := p.db.First(&model, purgeSettingsRowID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domainPurge.DefaultSettings(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return domainPurge.Settings{}, err
|
||||
}
|
||||
return mappers.PurgeSettingsToDomain(model), nil
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Save(settings domainPurge.Settings) error {
|
||||
model := mappers.PurgeSettingsToModel(settings)
|
||||
model.ID = purgeSettingsRowID
|
||||
return p.db.Save(&model).Error
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
|
||||
result := p.db.Model(&entity.PurgeSettings{}).Where("id = ?", purgeSettingsRowID).Updates(map[string]any{
|
||||
"last_purge_at": at,
|
||||
"last_purge_merged": merged,
|
||||
"last_purge_articles_updated": articlesUpdated,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected > 0 {
|
||||
return result.Error
|
||||
}
|
||||
settings := domainPurge.DefaultSettings()
|
||||
settings.LastPurgeAt = &at
|
||||
settings.LastPurgeMerged = merged
|
||||
settings.LastPurgeArticlesUpdated = articlesUpdated
|
||||
return p.Save(settings)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TickInterval = time.Hour
|
||||
StartupDelay = time.Minute
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Tick(now time.Time) (purge.RunResult, bool, error)
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
job Job
|
||||
now func() time.Time
|
||||
ticks <-chan time.Time
|
||||
startup <-chan time.Time
|
||||
logger *log.Logger
|
||||
release func()
|
||||
}
|
||||
|
||||
func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
|
||||
return &Runner{job: job, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
|
||||
}
|
||||
|
||||
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
|
||||
ticker := time.NewTicker(TickInterval)
|
||||
startup := time.NewTimer(StartupDelay)
|
||||
runner := NewRunner(job, time.Now, ticker.C, startup.C, logger)
|
||||
runner.release = func() {
|
||||
ticker.Stop()
|
||||
startup.Stop()
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context) {
|
||||
defer r.release()
|
||||
startup := r.startup
|
||||
for ctx.Err() == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-startup:
|
||||
startup = nil
|
||||
r.tick(ctx)
|
||||
case <-r.ticks:
|
||||
r.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) tick(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
result, ran, err := r.job.Tick(r.now())
|
||||
if errors.Is(err, purge.ErrPurgeBusy) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
r.logger.Printf("scheduler: automatic purge failed: %v", err)
|
||||
return
|
||||
}
|
||||
if ran {
|
||||
r.logger.Printf("scheduler: automatic purge completed: groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/purge"
|
||||
|
||||
type GetPurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
}
|
||||
|
||||
func NewGetPurgeSettingsUseCase(repository purge.Repository) *GetPurgeSettingsUseCase {
|
||||
return &GetPurgeSettingsUseCase{repository}
|
||||
}
|
||||
|
||||
func (g *GetPurgeSettingsUseCase) Get() (purge.Settings, error) {
|
||||
return g.repository.Get()
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type MergeMediaResult struct {
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
type MergeMediaUseCase struct {
|
||||
@@ -57,7 +58,15 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
}
|
||||
}
|
||||
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
}
|
||||
|
||||
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
|
||||
ids := make([]uint32, 0, len(rewrites))
|
||||
for _, plan := range rewrites {
|
||||
ids = append(ids, plan.articleId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
)
|
||||
|
||||
const purgePreviewListLimit = 5
|
||||
|
||||
type PurgePreviewGroup struct {
|
||||
KeepID uint32
|
||||
Filename string
|
||||
URL string
|
||||
UsedIn int
|
||||
Merged int
|
||||
}
|
||||
|
||||
type PurgePreview struct {
|
||||
Groups int
|
||||
Copies int
|
||||
Articles int
|
||||
List []PurgePreviewGroup
|
||||
More int
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository, articleRepository}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return PurgePreview{}, err
|
||||
}
|
||||
|
||||
shown := min(len(plan.groups), purgePreviewListLimit)
|
||||
list := make([]PurgePreviewGroup, 0, shown)
|
||||
for _, group := range plan.groups[:shown] {
|
||||
list = append(list, PurgePreviewGroup{
|
||||
KeepID: group.kept.ID,
|
||||
Filename: group.kept.Filename,
|
||||
URL: group.kept.Path,
|
||||
UsedIn: group.keptUsage,
|
||||
Merged: len(group.mergedIds),
|
||||
})
|
||||
}
|
||||
|
||||
return PurgePreview{
|
||||
Groups: len(plan.groups),
|
||||
Copies: plan.copies,
|
||||
Articles: plan.articles,
|
||||
List: list,
|
||||
More: len(plan.groups) - shown,
|
||||
Fingerprint: plan.fingerprint,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeRunner interface {
|
||||
Run(fingerprint *string) (purge.RunResult, error)
|
||||
}
|
||||
|
||||
type PurgeSchedulerUseCase struct {
|
||||
repository purge.Repository
|
||||
runner PurgeRunner
|
||||
}
|
||||
|
||||
func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner) *PurgeSchedulerUseCase {
|
||||
return &PurgeSchedulerUseCase{repository, runner}
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
next := settings.NextPurgeAt()
|
||||
if next == nil || now.Before(*next) {
|
||||
return purge.RunResult{}, false, nil
|
||||
}
|
||||
result, err := u.runner.Run(nil)
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
merge *MergeMediaUseCase
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository, articleRepository},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (purge.RunResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return purge.RunResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
defer u.guard.Unlock()
|
||||
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
if fingerprint != nil && *fingerprint != plan.fingerprint {
|
||||
return purge.RunResult{}, purge.ErrPurgeConflict
|
||||
}
|
||||
|
||||
result := u.mergeGroups(plan)
|
||||
if err := u.recordRun(result); err != nil {
|
||||
u.logger.Printf("purge: could not record the run result: %v", err)
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
result := purge.RunResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.Merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
if err != nil {
|
||||
result.FailedGroups++
|
||||
continue
|
||||
}
|
||||
result.GroupsPurged++
|
||||
result.Merged += merged.Merged
|
||||
for _, id := range merged.UpdatedArticleIDs {
|
||||
updated[id] = true
|
||||
}
|
||||
}
|
||||
result.ArticlesUpdated = len(updated)
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error {
|
||||
return u.repository.SaveLastRun(u.now().UTC(), result.Merged, result.ArticlesUpdated)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdatePurgeSettingsCommand struct {
|
||||
AutoPurgeEnabled *bool
|
||||
AutoPurgeIntervalDays *int
|
||||
}
|
||||
|
||||
type UpdatePurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewUpdatePurgeSettingsUseCase(repository purge.Repository, now func() time.Time) *UpdatePurgeSettingsUseCase {
|
||||
return &UpdatePurgeSettingsUseCase{repository, now}
|
||||
}
|
||||
|
||||
func (u *UpdatePurgeSettingsUseCase) Update(command UpdatePurgeSettingsCommand) (purge.Settings, error) {
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
if err := purge.ValidateIntervalDays(*command.AutoPurgeIntervalDays); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
|
||||
if command.AutoPurgeEnabled != nil {
|
||||
if *command.AutoPurgeEnabled && !settings.AutoPurgeEnabled {
|
||||
enabledAt := u.now().UTC()
|
||||
settings.EnabledAt = &enabledAt
|
||||
}
|
||||
settings.AutoPurgeEnabled = *command.AutoPurgeEnabled
|
||||
}
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
settings.AutoPurgeIntervalDays = *command.AutoPurgeIntervalDays
|
||||
}
|
||||
|
||||
if err := u.repository.Save(settings); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type purgeGroup struct {
|
||||
kept image.Image
|
||||
keptUsage int
|
||||
mergedIds []uint32
|
||||
}
|
||||
|
||||
type purgePlan struct {
|
||||
groups []purgeGroup
|
||||
copies int
|
||||
articles int
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
duplicates, err := p.imageRepository.ListDuplicates()
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
articles, err := p.articleRepository.FindByFilters(article.Filters{})
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
groups := []purgeGroup{}
|
||||
copies := 0
|
||||
touched := map[uint32]bool{}
|
||||
for _, members := range groupByHash(duplicates) {
|
||||
group, planned := planGroup(members, articles)
|
||||
if !planned {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, group)
|
||||
copies += len(group.mergedIds)
|
||||
for _, id := range articlesUsingCopies(group, members, articles) {
|
||||
touched[id] = true
|
||||
}
|
||||
}
|
||||
slices.SortFunc(groups, func(a, b purgeGroup) int { return int(a.kept.ID) - int(b.kept.ID) })
|
||||
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups)}, nil
|
||||
}
|
||||
|
||||
func groupByHash(images []image.Image) [][]image.Image {
|
||||
byHash := map[string][]image.Image{}
|
||||
hashes := []string{}
|
||||
for _, img := range images {
|
||||
if img.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := byHash[img.SHA256]; !seen {
|
||||
hashes = append(hashes, img.SHA256)
|
||||
}
|
||||
byHash[img.SHA256] = append(byHash[img.SHA256], img)
|
||||
}
|
||||
groups := make([][]image.Image, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
groups = append(groups, byHash[hash])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func usageOf(img image.Image, articles []article.Article) int {
|
||||
storedName := image.StoredName(img.Path)
|
||||
used := 0
|
||||
for _, a := range articles {
|
||||
if strings.Contains(a.Body, storedName) {
|
||||
used++
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
|
||||
usage := make(map[uint32]int, len(members))
|
||||
anyUsed := false
|
||||
for _, member := range members {
|
||||
usage[member.ID] = usageOf(member, articles)
|
||||
anyUsed = anyUsed || usage[member.ID] > 0
|
||||
}
|
||||
if !anyUsed {
|
||||
return purgeGroup{}, false
|
||||
}
|
||||
|
||||
kept := members[0]
|
||||
for _, member := range members[1:] {
|
||||
if isBetterToKeep(member, kept, usage) {
|
||||
kept = member
|
||||
}
|
||||
}
|
||||
|
||||
mergedIds := []uint32{}
|
||||
for _, member := range members {
|
||||
if member.ID != kept.ID {
|
||||
mergedIds = append(mergedIds, member.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(mergedIds)
|
||||
return purgeGroup{kept: kept, keptUsage: usage[kept.ID], mergedIds: mergedIds}, true
|
||||
}
|
||||
|
||||
func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
|
||||
if usage[candidate.ID] != usage[current.ID] {
|
||||
return usage[candidate.ID] > usage[current.ID]
|
||||
}
|
||||
if !candidate.CreatedAt.Equal(current.CreatedAt) {
|
||||
return candidate.CreatedAt.Before(current.CreatedAt)
|
||||
}
|
||||
return candidate.ID < current.ID
|
||||
}
|
||||
|
||||
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
|
||||
mergedNames := []string{}
|
||||
for _, member := range members {
|
||||
if member.ID != group.kept.ID {
|
||||
mergedNames = append(mergedNames, image.StoredName(member.Path))
|
||||
}
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, a := range articles {
|
||||
for _, name := range mergedNames {
|
||||
if strings.Contains(a.Body, name) {
|
||||
ids = append(ids, a.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func fingerprintOf(groups []purgeGroup) string {
|
||||
var builder strings.Builder
|
||||
for _, group := range groups {
|
||||
builder.WriteString(strconv.FormatUint(uint64(group.kept.ID), 10))
|
||||
builder.WriteString(":")
|
||||
for position, id := range group.mergedIds {
|
||||
if position > 0 {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString(strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
builder.WriteString(";")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type mockImageRepo struct {
|
||||
deleteCalls []uint32
|
||||
createCalls int
|
||||
lastContent []byte
|
||||
onDelete func(id uint32)
|
||||
}
|
||||
|
||||
func newMockImageRepo() *mockImageRepo {
|
||||
@@ -85,6 +86,9 @@ func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
|
||||
|
||||
func (m *mockImageRepo) Delete(id uint32) error {
|
||||
m.deleteCalls = append(m.deleteCalls, id)
|
||||
if m.onDelete != nil {
|
||||
m.onDelete(id)
|
||||
}
|
||||
if err := m.fail.trigger("Delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+36
-22
@@ -8,7 +8,9 @@ import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -28,11 +30,14 @@ const (
|
||||
)
|
||||
|
||||
type mediaEnv struct {
|
||||
articles *mockArticleRepo
|
||||
images *mockImageRepo
|
||||
users *mockUserRepo
|
||||
token *jwtauth.JWTAuth
|
||||
router http.Handler
|
||||
articles *mockArticleRepo
|
||||
images *mockImageRepo
|
||||
users *mockUserRepo
|
||||
purgeSettings *mockPurgeSettingsRepo
|
||||
clock time.Time
|
||||
logs *bytes.Buffer
|
||||
token *jwtauth.JWTAuth
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
@@ -40,30 +45,39 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
validator.InitValidator()
|
||||
|
||||
env := &mediaEnv{
|
||||
articles: newMockArticleRepo(),
|
||||
images: newMockImageRepo(),
|
||||
users: newMockUserRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
articles: newMockArticleRepo(),
|
||||
images: newMockImageRepo(),
|
||||
users: newMockUserRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
purgeSettings: newMockPurgeSettingsRepo(),
|
||||
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
|
||||
logs: &bytes.Buffer{},
|
||||
}
|
||||
env.users.users[mediaVerifiedUserId] = user.User{ID: 1, Username: "ines", DisplayName: "Inès Duarte", IsVerified: true}
|
||||
env.users.users[mediaSecondUserId] = user.User{ID: 2, Username: "marta", IsVerified: true}
|
||||
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)
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
|
||||
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)),
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
purgeOwnerId uint32 = 10
|
||||
purgeAdminId uint32 = 11
|
||||
purgeEditorId uint32 = 12
|
||||
purgeUnverifiedId uint32 = 13
|
||||
fingerprintLength = 64
|
||||
internalError = "Internal server error\n"
|
||||
)
|
||||
|
||||
type mockPurgeSettingsRepo struct {
|
||||
mu sync.Mutex
|
||||
settings purge.Settings
|
||||
fail failSwitch
|
||||
getCalls int
|
||||
saveCalls int
|
||||
beforeRunWrite func()
|
||||
}
|
||||
|
||||
func newMockPurgeSettingsRepo() *mockPurgeSettingsRepo {
|
||||
return &mockPurgeSettingsRepo{settings: purge.DefaultSettings()}
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) snapshot() purge.Settings {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.settings
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) Get() (purge.Settings, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getCalls++
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
return m.settings, nil
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) Save(settings purge.Settings) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.saveCalls++
|
||||
if err := m.fail.trigger("Save"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.settings = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPurgeSettingsRepo) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
|
||||
m.mu.Lock()
|
||||
m.saveCalls++
|
||||
hook := m.beforeRunWrite
|
||||
m.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook()
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.fail.trigger("SaveLastRun"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.settings.LastPurgeAt = &at
|
||||
m.settings.LastPurgeMerged = merged
|
||||
m.settings.LastPurgeArticlesUpdated = articlesUpdated
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ purge.Repository = &mockPurgeSettingsRepo{}
|
||||
|
||||
func purgeTime(value string) *time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &parsed
|
||||
}
|
||||
|
||||
func newPurgeEnv(t *testing.T) *mediaEnv {
|
||||
t.Helper()
|
||||
env := newMediaEnv(t)
|
||||
env.users.users[purgeOwnerId] = user.User{ID: purgeOwnerId, Username: "owner", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[purgeAdminId] = user.User{ID: purgeAdminId, Username: "admin", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[purgeEditorId] = user.User{ID: purgeEditorId, Username: "editor", IsVerified: true, Role: user.Role("editor")}
|
||||
env.users.users[purgeUnverifiedId] = user.User{ID: purgeUnverifiedId, Username: "unverified", IsVerified: false, Role: user.RoleOwner}
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *mediaEnv) seedGroup(hash string, ids ...uint32) {
|
||||
for _, id := range ids {
|
||||
e.seedImage(id, "image-"+strconv.Itoa(int(id))+".jpg", 100, "image/jpeg", 10, 10, hash, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *mediaEnv) useImages(articleId uint32, ids ...uint32) {
|
||||
body := ""
|
||||
for _, id := range ids {
|
||||
body += `<img src="` + e.urlOf(id) + `">`
|
||||
}
|
||||
e.seedArticle(articleId, "Article "+strconv.Itoa(int(articleId)), true, body)
|
||||
}
|
||||
|
||||
func (e *mediaEnv) purgeSend(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return e.send(t, method, path, body, purgeOwnerId)
|
||||
}
|
||||
|
||||
func (e *mediaEnv) preview(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return decodeObject(t, rec.Body.String())
|
||||
}
|
||||
|
||||
func (e *mediaEnv) run(t *testing.T, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return e.purgeSend(t, http.MethodPost, "/v1/media/purge", body)
|
||||
}
|
||||
|
||||
func expectRaw(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if rec.Code != status || rec.Body.String() != body {
|
||||
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeRoutesRefuseCallersThatAreNotAllowed(t *testing.T) {
|
||||
examples := []struct {
|
||||
method string
|
||||
path string
|
||||
userId uint32
|
||||
description string
|
||||
status int
|
||||
message string
|
||||
}{
|
||||
{http.MethodGet, "/v1/media/purge-settings", 0, "no cookie", 401, ""},
|
||||
{http.MethodPatch, "/v1/media/purge-settings", 0, "no cookie", 401, ""},
|
||||
{http.MethodGet, "/v1/media/purge-preview", 0, "no cookie", 401, ""},
|
||||
{http.MethodPost, "/v1/media/purge", 0, "no cookie", 401, ""},
|
||||
{http.MethodGet, "/v1/media/purge-settings", mediaStaleUserId, "a stale token_version cookie", 401, apperr.UnauthorizedErrorMessage},
|
||||
{http.MethodGet, "/v1/media/purge-settings", purgeUnverifiedId, "cookie of an unverified owner", 403, apperr.VerificationRequiredErrorMessage},
|
||||
{http.MethodGet, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodPatch, "/v1/media/purge-settings", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodGet, "/v1/media/purge-preview", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodPost, "/v1/media/purge", purgeEditorId, "editor", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodGet, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodPatch, "/v1/media/purge-settings", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodGet, "/v1/media/purge-preview", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{http.MethodPost, "/v1/media/purge", mediaVerifiedUserId, "empty role", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
}
|
||||
|
||||
for _, example := range examples {
|
||||
t.Run(example.method+" "+example.path+" "+example.description, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
bodiesBefore := env.bodies()
|
||||
imagesBefore := env.imageIds()
|
||||
|
||||
rec := env.send(t, example.method, example.path, "", example.userId)
|
||||
|
||||
if example.message == "" {
|
||||
if rec.Code != example.status {
|
||||
t.Fatalf("expected status %d, got %d", example.status, rec.Code)
|
||||
}
|
||||
} else {
|
||||
expectRaw(t, rec, example.status, example.message+"\n")
|
||||
}
|
||||
if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected no purge setting access")
|
||||
}
|
||||
if !reflect.DeepEqual(env.bodies(), bodiesBefore) || !reflect.DeepEqual(env.imageIds(), imagesBefore) || len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no image or article to change")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeRoleGuardLetsOwnersAndAdminsThrough(t *testing.T) {
|
||||
for _, role := range []struct {
|
||||
name string
|
||||
userId uint32
|
||||
}{{"owner", purgeOwnerId}, {"admin", purgeAdminId}} {
|
||||
for _, path := range []string{"/v1/media/purge-settings", "/v1/media/purge-preview"} {
|
||||
t.Run(role.name+" GET "+path, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
rec := env.send(t, http.MethodGet, path, "", role.userId)
|
||||
|
||||
if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
|
||||
t.Errorf("expected the guard to pass, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSettingsBody = `{"auto_purge_enabled":false,"auto_purge_interval_days":30,"last_purge_at":null,"last_purge_merged":0,"last_purge_articles_updated":0,"enabled_at":null,"next_purge_at":null}`
|
||||
|
||||
func TestPurgeSettings(t *testing.T) {
|
||||
get := func(t *testing.T, env *mediaEnv) map[string]any {
|
||||
t.Helper()
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
return decodeObject(t, rec.Body.String())
|
||||
}
|
||||
|
||||
t.Run("Nothing was ever saved, so the defaults are served", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
|
||||
expectRaw(t, rec, 200, defaultSettingsBody)
|
||||
})
|
||||
|
||||
t.Run("Turning automatic purge on stamps enabled_at and computes next_purge_at", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if body["auto_purge_enabled"] != true || numberOf(body["auto_purge_interval_days"]) != 30 || body["enabled_at"] != "2026-10-05T09:00:00Z" || body["last_purge_at"] != nil || body["next_purge_at"] != "2026-11-04T09:00:00Z" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
if !reflect.DeepEqual(get(t, env), body) {
|
||||
t.Errorf("expected a following GET to return the same body")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("next_purge_at counts from the last purge when it is later than enabled_at", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 10, EnabledAt: purgeTime("2026-10-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-10-04T12:00:00Z")}
|
||||
|
||||
if got := get(t, env)["next_purge_at"]; got != "2026-10-14T12:00:00Z" {
|
||||
t.Errorf("unexpected next_purge_at %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("next_purge_at counts from enabled_at when it is later than the last purge", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 10, EnabledAt: purgeTime("2026-10-05T09:00:00Z"), LastPurgeAt: purgeTime("2026-09-01T00:00:00Z")}
|
||||
|
||||
if got := get(t, env)["next_purge_at"]; got != "2026-10-15T09:00:00Z" {
|
||||
t.Errorf("unexpected next_purge_at %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Turning automatic purge off and on again never yields a next_purge_at in the past", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: false, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-08-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-09-01T00:00:00Z")}
|
||||
|
||||
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`).Body.String())
|
||||
|
||||
if body["enabled_at"] != "2026-10-05T09:00:00Z" || body["last_purge_at"] != "2026-09-01T00:00:00Z" || body["next_purge_at"] != "2026-11-04T09:00:00Z" {
|
||||
t.Errorf("unexpected body %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Changing only the interval keeps enabled_at", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": 7}`)
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if rec.Code != 200 || numberOf(body["auto_purge_interval_days"]) != 7 || body["enabled_at"] != "2026-10-01T00:00:00Z" || body["next_purge_at"] != "2026-10-08T00:00:00Z" {
|
||||
t.Errorf("unexpected response %d %v", rec.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patching on while already on does not restamp enabled_at", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
|
||||
|
||||
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`).Body.String())
|
||||
|
||||
if body["enabled_at"] != "2026-10-01T00:00:00Z" {
|
||||
t.Errorf("unexpected enabled_at %v", body["enabled_at"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Turning automatic purge off nulls next_purge_at and keeps the history", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 30, EnabledAt: purgeTime("2026-10-01T00:00:00Z"), LastPurgeAt: purgeTime("2026-10-04T12:00:00Z"), LastPurgeMerged: 3, LastPurgeArticlesUpdated: 5}
|
||||
|
||||
body := decodeObject(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": false}`).Body.String())
|
||||
|
||||
if body["auto_purge_enabled"] != false || body["next_purge_at"] != nil || body["last_purge_at"] != "2026-10-04T12:00:00Z" || numberOf(body["last_purge_merged"]) != 3 || numberOf(body["last_purge_articles_updated"]) != 5 {
|
||||
t.Errorf("unexpected body %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
for _, days := range []int{1, 30, 365} {
|
||||
t.Run("The interval accepts "+strconv.Itoa(days), func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": `+strconv.Itoa(days)+`}`)
|
||||
|
||||
if rec.Code != 200 || int(numberOf(decodeObject(t, rec.Body.String())["auto_purge_interval_days"])) != days {
|
||||
t.Errorf("unexpected response %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
body string
|
||||
message string
|
||||
}{
|
||||
{`{"auto_purge_interval_days": 0}`, apperr.PurgeIntervalErrorMessage},
|
||||
{`{"auto_purge_interval_days": 366}`, apperr.PurgeIntervalErrorMessage},
|
||||
{`{"auto_purge_interval_days": -1}`, apperr.PurgeIntervalErrorMessage},
|
||||
{`{"auto_purge_interval_days": 1.5}`, apperr.BodyErrorMessage},
|
||||
{`{"auto_purge_interval_days": "7"}`, apperr.BodyErrorMessage},
|
||||
{`{"auto_purge_enabled": "yes"}`, apperr.BodyErrorMessage},
|
||||
{`not json`, apperr.BodyErrorMessage},
|
||||
}
|
||||
for _, example := range invalid {
|
||||
t.Run("An invalid patch is 400 and nothing changes "+example.body, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", example.body)
|
||||
|
||||
expectRaw(t, rec, 400, example.message+"\n")
|
||||
if env.purgeSettings.saveCalls != 0 || !reflect.DeepEqual(env.purgeSettings.settings, purge.DefaultSettings()) {
|
||||
t.Errorf("expected the stored settings to be unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An empty object changes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: purgeTime("2026-10-01T00:00:00Z")}
|
||||
before := get(t, env)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{}`)
|
||||
|
||||
if rec.Code != 200 || !reflect.DeepEqual(decodeObject(t, rec.Body.String()), before) {
|
||||
t.Errorf("unexpected response %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A repository failure is a generic 500", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.fail.failNext("Get")
|
||||
|
||||
expectRaw(t, env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", ""), 500, internalError)
|
||||
})
|
||||
|
||||
t.Run("A failing Save on PATCH is a generic 500", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.purgeSettings.fail.failNext("Save")
|
||||
|
||||
expectRaw(t, env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_enabled": true}`), 500, internalError)
|
||||
})
|
||||
|
||||
t.Run("The purge routes are not taken by the /{id} routes", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1)
|
||||
namesBefore := env.images.images[1].Filename
|
||||
|
||||
rec := env.purgeSend(t, http.MethodPatch, "/v1/media/purge-settings", `{"auto_purge_interval_days": 7}`)
|
||||
previewRec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
|
||||
|
||||
if rec.Code != 200 || env.purgeSettings.settings.AutoPurgeIntervalDays != 7 || env.images.images[1].Filename != namesBefore {
|
||||
t.Errorf("expected the settings to be updated without renaming an image")
|
||||
}
|
||||
if previewRec.Code != 200 || !strings.Contains(previewRec.Body.String(), `"fingerprint"`) {
|
||||
t.Errorf("expected the preview, got %d %s", previewRec.Code, previewRec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgePreview(t *testing.T) {
|
||||
t.Run("A group with a used copy is planned, merging every other copy into the most used", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedImage(1, "hero.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
|
||||
env.seedImage(2, "hero-copy.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
|
||||
env.seedImage(3, "hero-2.jpg", 1, "image/jpeg", 1, 1, hashA, 1)
|
||||
env.useImages(10, 1)
|
||||
env.useImages(11, 2)
|
||||
env.useImages(12, 2)
|
||||
env.useImages(13, 3)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
list := body["list"].([]any)
|
||||
first := list[0].(map[string]any)
|
||||
if rec.Code != 200 || numberOf(body["groups"]) != 1 || numberOf(body["copies"]) != 2 || numberOf(body["articles"]) != 2 || numberOf(body["more"]) != 0 || len(list) != 1 {
|
||||
t.Fatalf("unexpected preview %s", rec.Body.String())
|
||||
}
|
||||
if numberOf(first["keep_id"]) != 2 || first["filename"] != "hero-copy.jpg" || first["url"] != env.urlOf(2) || numberOf(first["used_in"]) != 2 || numberOf(first["merged"]) != 2 {
|
||||
t.Errorf("unexpected list entry %v", first)
|
||||
}
|
||||
if fingerprint, _ := body["fingerprint"].(string); len(fingerprint) != fingerprintLength {
|
||||
t.Errorf("unexpected fingerprint %v", body["fingerprint"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ties on usage keep the oldest copy, then the lowest id", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashB, 4, 5)
|
||||
env.seedGroup(hashC, 6, 7)
|
||||
same := mockImageEpoch
|
||||
for _, id := range []uint32{4, 5} {
|
||||
img := env.images.images[id]
|
||||
img.CreatedAt = same
|
||||
env.images.images[id] = img
|
||||
}
|
||||
older := env.images.images[7]
|
||||
older.CreatedAt = mockImageEpoch.Add(-time.Hour)
|
||||
env.images.images[7] = older
|
||||
env.useImages(20, 4)
|
||||
env.useImages(21, 5)
|
||||
env.useImages(22, 6)
|
||||
env.useImages(23, 7)
|
||||
|
||||
list := env.preview(t)["list"].([]any)
|
||||
|
||||
if numberOf(list[0].(map[string]any)["keep_id"]) != 4 || numberOf(list[1].(map[string]any)["keep_id"]) != 7 {
|
||||
t.Errorf("unexpected list %v", list)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A single used copy with unused siblings keeps the used one", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashD, 8, 9, 10)
|
||||
env.useImages(30, 10)
|
||||
|
||||
body := env.preview(t)
|
||||
|
||||
first := body["list"].([]any)[0].(map[string]any)
|
||||
if numberOf(first["keep_id"]) != 10 || numberOf(first["used_in"]) != 1 || numberOf(first["merged"]) != 2 || numberOf(body["articles"]) != 0 {
|
||||
t.Errorf("unexpected preview %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A group whose copies are all unused is skipped", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 11, 12)
|
||||
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if numberOf(body["groups"]) != 0 || numberOf(body["copies"]) != 0 || numberOf(body["articles"]) != 0 || numberOf(body["more"]) != 0 || !strings.Contains(rec.Body.String(), `"list":[]`) {
|
||||
t.Errorf("unexpected preview %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A library with no duplicates", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1)
|
||||
env.seedGroup(hashB, 2)
|
||||
env.seedGroup(hashC, 3)
|
||||
emptyFingerprint := decodeObject(t, newPurgeEnv(t).purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String())["fingerprint"]
|
||||
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if numberOf(body["groups"]) != 0 || !strings.Contains(rec.Body.String(), `"list":[]`) || body["fingerprint"] != emptyFingerprint {
|
||||
t.Errorf("unexpected preview %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Images without a hash are never grouped", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup("", 13, 14)
|
||||
env.useImages(40, 13, 14)
|
||||
|
||||
if got := numberOf(env.preview(t)["groups"]); got != 0 {
|
||||
t.Errorf("expected no group, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An article that uses copies of two groups counts once", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(50, 1)
|
||||
env.useImages(51, 3)
|
||||
env.useImages(52, 2, 4)
|
||||
|
||||
body := env.preview(t)
|
||||
|
||||
if numberOf(body["groups"]) != 2 || numberOf(body["copies"]) != 2 || numberOf(body["articles"]) != 1 {
|
||||
t.Errorf("unexpected preview %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An article that uses both the kept copy and a copy is rewritten", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(60, 1, 2)
|
||||
env.useImages(61, 1)
|
||||
|
||||
if got := numberOf(env.preview(t)["articles"]); got != 1 {
|
||||
t.Errorf("expected 1 article, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list shows at most 5 groups and reports the rest", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
for group := uint32(0); group < 7; group++ {
|
||||
hash := sha256Hex([]byte{byte(group)})
|
||||
first, second := group*2+1, group*2+2
|
||||
env.seedGroup(hash, first, second)
|
||||
env.useImages(100+first, first)
|
||||
}
|
||||
|
||||
body := env.preview(t)
|
||||
|
||||
list := body["list"].([]any)
|
||||
if numberOf(body["groups"]) != 7 || len(list) != 5 || numberOf(body["more"]) != 2 {
|
||||
t.Fatalf("unexpected preview %v", body)
|
||||
}
|
||||
for position, entry := range list {
|
||||
if numberOf(entry.(map[string]any)["keep_id"]) != uint32(position*2+1) {
|
||||
t.Errorf("expected ascending keep_id, got %v", list)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The preview changes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1, 2)
|
||||
bodiesBefore := env.bodies()
|
||||
|
||||
first := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String()
|
||||
second := env.purgeSend(t, http.MethodGet, "/v1/media/purge-preview", "").Body.String()
|
||||
|
||||
if first != second || len(env.images.deleteCalls) != 0 || !reflect.DeepEqual(env.bodies(), bodiesBefore) || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected the preview to change nothing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The fingerprint reacts to the plan and to nothing else", func(t *testing.T) {
|
||||
examples := []struct {
|
||||
change string
|
||||
apply func(env *mediaEnv)
|
||||
identical bool
|
||||
}{
|
||||
{"nothing changes", func(env *mediaEnv) {}, true},
|
||||
{"a new unused copy is uploaded to the group", func(env *mediaEnv) { env.seedGroup(hashA, 9) }, false},
|
||||
{"a copy is deleted from the group", func(env *mediaEnv) { delete(env.images.images, 3) }, false},
|
||||
{"article usage changes so that another copy becomes kept", func(env *mediaEnv) { env.useImages(12, 3); env.useImages(13, 3); env.useImages(14, 3) }, false},
|
||||
{"an unrelated image with a unique sha256 is uploaded", func(env *mediaEnv) { env.seedGroup(hashD, 20) }, true},
|
||||
{"an article body changes without changing which is kept", func(env *mediaEnv) { env.useImages(10, 2, 2) }, true},
|
||||
{"a group with no used copy gains an unused copy", func(env *mediaEnv) { env.seedGroup(hashB, 30, 31); env.seedGroup(hashB, 32) }, true},
|
||||
}
|
||||
for _, example := range examples {
|
||||
t.Run(example.change, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 2, 3)
|
||||
env.useImages(10, 2)
|
||||
env.useImages(11, 2)
|
||||
before := env.preview(t)["fingerprint"]
|
||||
|
||||
example.apply(env)
|
||||
|
||||
if (env.preview(t)["fingerprint"] == before) != example.identical {
|
||||
t.Errorf("unexpected fingerprint comparison, identical expected %v", example.identical)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) + `}`
|
||||
}
|
||||
|
||||
seedThreeGroups := func(env *mediaEnv) {
|
||||
env.seedGroup(hashA, 1, 2, 3)
|
||||
env.seedGroup(hashB, 4, 5)
|
||||
env.seedGroup(hashC, 6, 7)
|
||||
env.useImages(10, 1)
|
||||
env.useImages(11, 2)
|
||||
env.useImages(12, 2, 4)
|
||||
}
|
||||
|
||||
t.Run("A run merges every planned group and persists the result", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
seedThreeGroups(env)
|
||||
enabledAt := purgeTime("2026-10-01T00:00:00Z")
|
||||
env.purgeSettings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: enabledAt}
|
||||
|
||||
rec := env.run(t, `{}`)
|
||||
|
||||
expectRaw(t, rec, 200, counters(2, 2, 3, 1, 0))
|
||||
expectIds(t, env.imageIds(), []uint32{2, 4, 6, 7})
|
||||
if !strings.Contains(env.bodies()[10], env.images.images[2].Path) {
|
||||
t.Errorf("expected article 10 to use image 2")
|
||||
}
|
||||
stored := env.purgeSettings.settings
|
||||
if !stored.LastPurgeAt.Equal(env.clock) || stored.LastPurgeMerged != 3 || stored.LastPurgeArticlesUpdated != 1 || !stored.AutoPurgeEnabled || stored.AutoPurgeIntervalDays != 7 || !stored.EnabledAt.Equal(*enabledAt) {
|
||||
t.Errorf("unexpected stored settings %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An article rewritten by two groups is counted once in articles_updated", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(10, 1)
|
||||
env.useImages(11, 3)
|
||||
env.useImages(12, 2, 4)
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(2, 2, 2, 1, 0))
|
||||
})
|
||||
|
||||
t.Run("A single used copy with unused siblings is merged without rewriting any article", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashD, 8, 9, 10)
|
||||
env.useImages(30, 10)
|
||||
bodiesBefore := env.bodies()
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 2, 0, 0))
|
||||
expectIds(t, env.imageIds(), []uint32{10})
|
||||
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
|
||||
t.Errorf("expected no article body to change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A matching fingerprint is accepted", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
fingerprint := env.preview(t)["fingerprint"].(string)
|
||||
|
||||
expectRaw(t, env.run(t, `{"fingerprint": "`+fingerprint+`"}`), 200, counters(1, 1, 1, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("A stale fingerprint answers 409 and changes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
fingerprint := env.preview(t)["fingerprint"].(string)
|
||||
env.seedGroup(hashA, 9)
|
||||
bodiesBefore := env.bodies()
|
||||
imagesBefore := env.imageIds()
|
||||
|
||||
rec := env.run(t, `{"fingerprint": "`+fingerprint+`"}`)
|
||||
|
||||
expectRaw(t, rec, 409, apperr.PurgeConflictErrorMessage+"\n")
|
||||
if !reflect.DeepEqual(env.bodies(), bodiesBefore) || !reflect.DeepEqual(env.imageIds(), imagesBefore) || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected nothing to change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A fingerprint that matches no plan at all is a conflict, not a validation error", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
expectRaw(t, env.run(t, `{"fingerprint": "not-a-real-fingerprint"}`), 409, apperr.PurgeConflictErrorMessage+"\n")
|
||||
if env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected nothing saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A run without a fingerprint always runs the current plan", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
|
||||
expectRaw(t, env.run(t, ""), 200, counters(1, 1, 1, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("A library with nothing to purge answers zeros and is still recorded", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
|
||||
stored := env.purgeSettings.settings
|
||||
if stored.LastPurgeAt == nil || !stored.LastPurgeAt.Equal(env.clock) || stored.LastPurgeMerged != 0 || stored.LastPurgeArticlesUpdated != 0 {
|
||||
t.Errorf("unexpected stored settings %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The fingerprint of the empty plan on an empty plan runs as a no-op", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
fingerprint := env.preview(t)["fingerprint"].(string)
|
||||
|
||||
expectRaw(t, env.run(t, `{"fingerprint": "`+fingerprint+`"}`), 200, counters(0, 0, 0, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("A group that fails while rewriting bodies is rolled back and contributes no ids", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.seedGroup(hashC, 5, 6)
|
||||
env.useImages(20, 1, 2)
|
||||
env.useImages(21, 1)
|
||||
env.useImages(22, 3, 4)
|
||||
env.useImages(23, 3, 4)
|
||||
env.useImages(24, 3)
|
||||
env.useImages(25, 5)
|
||||
env.useImages(26, 5, 6)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{23: errRepositoryFailure}
|
||||
originalB := []string{env.bodies()[22], env.bodies()[23]}
|
||||
|
||||
rec := env.run(t, `{}`)
|
||||
|
||||
expectRaw(t, rec, 200, counters(3, 2, 2, 2, 1))
|
||||
if env.bodies()[22] != originalB[0] || env.bodies()[23] != originalB[1] {
|
||||
t.Errorf("expected group B bodies restored")
|
||||
}
|
||||
expectIds(t, env.imageIds(), []uint32{1, 3, 4, 5})
|
||||
stored := env.purgeSettings.settings
|
||||
if stored.LastPurgeMerged != 2 || stored.LastPurgeArticlesUpdated != 2 {
|
||||
t.Errorf("unexpected stored settings %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A group that fails after rewriting bodies is reported as failed and contributes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(20, 1, 2)
|
||||
env.useImages(21, 1)
|
||||
env.useImages(22, 3, 4)
|
||||
env.useImages(23, 3)
|
||||
env.images.failDeleteFor = map[uint32]error{2: errRepositoryFailure}
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(2, 1, 1, 1, 1))
|
||||
if strings.Contains(env.bodies()[20], env.urlOf(2)) {
|
||||
t.Errorf("expected article 20 to stay switched to the kept copy")
|
||||
}
|
||||
if got := numberOf(env.preview(t)["groups"]); got != 1 {
|
||||
t.Errorf("expected group A to be planned again, got %d groups", got)
|
||||
}
|
||||
env.images.failDeleteFor = nil
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("Every group failing is still a 200 with the counters", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(20, 1, 2)
|
||||
env.useImages(21, 1)
|
||||
env.useImages(22, 3, 4)
|
||||
env.useImages(23, 3)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{20: errRepositoryFailure, 22: errRepositoryFailure}
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(2, 0, 0, 0, 2))
|
||||
if env.purgeSettings.settings.LastPurgeAt == nil {
|
||||
t.Errorf("expected last_purge_at to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A second run while one is in progress is rejected as busy, not queued", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
env.images.onDelete = func(uint32) {
|
||||
once.Do(func() {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
}
|
||||
first := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { first <- env.run(t, `{}`) }()
|
||||
<-entered
|
||||
savesBefore := env.purgeSettings.saveCalls
|
||||
|
||||
withoutFingerprint := env.run(t, `{}`)
|
||||
withFingerprint := env.run(t, `{"fingerprint": "whatever"}`)
|
||||
|
||||
expectRaw(t, withoutFingerprint, 409, apperr.PurgeBusyErrorMessage+"\n")
|
||||
expectRaw(t, withFingerprint, 409, apperr.PurgeBusyErrorMessage+"\n")
|
||||
if env.purgeSettings.saveCalls != savesBefore {
|
||||
t.Errorf("expected the rejected runs to save nothing")
|
||||
}
|
||||
close(release)
|
||||
expectRaw(t, <-first, 200, counters(1, 1, 1, 0, 0))
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("A malformed body is 400", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
|
||||
expectRaw(t, env.run(t, "not json"), 400, apperr.BodyErrorMessage+"\n")
|
||||
if len(env.images.deleteCalls) != 0 || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected nothing to change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A repository failure while planning is a generic 500 and nothing changes", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.images.fail.failNext("ListDuplicates")
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 500, internalError)
|
||||
if env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected no last_purge_* saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing save of the run result is a logged 500 and the merges stay", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
env.purgeSettings.fail.failNext("SaveLastRun")
|
||||
|
||||
expectRaw(t, env.run(t, `{}`), 500, internalError)
|
||||
expectIds(t, env.imageIds(), []uint32{1})
|
||||
if !strings.Contains(env.logs.String(), errRepositoryFailure.Error()) {
|
||||
t.Errorf("expected the error in the injected logger, got %q", env.logs.String())
|
||||
}
|
||||
expectRaw(t, env.run(t, `{}`), 200, counters(0, 0, 0, 0, 0))
|
||||
})
|
||||
|
||||
t.Run("The preview and the run use the same plan", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
seedThreeGroups(env)
|
||||
preview := env.preview(t)
|
||||
|
||||
rec := env.run(t, `{"fingerprint": "`+preview["fingerprint"].(string)+`"}`)
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if rec.Code != 200 || body["groups_total"] != preview["groups"] || body["merged"] != preview["copies"] || body["articles_updated"] != preview["articles"] {
|
||||
t.Errorf("preview %v and run %v disagree", preview, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPurgeRoleGuardIsAnAllowlist(t *testing.T) {
|
||||
for _, role := range []string{"OWNER", "Admin", "superuser"} {
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/v1/media/purge-settings"},
|
||||
{http.MethodPatch, "/v1/media/purge-settings"},
|
||||
{http.MethodGet, "/v1/media/purge-preview"},
|
||||
{http.MethodPost, "/v1/media/purge"},
|
||||
} {
|
||||
t.Run(role+" "+route.method+" "+route.path, func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.users.users[20] = user.User{ID: 20, Username: "other", IsVerified: true, Role: user.Role(role)}
|
||||
|
||||
rec := env.send(t, route.method, route.path, "", 20)
|
||||
|
||||
expectRaw(t, rec, 403, apperr.OwnerOrAdminRequiredErrorMessage+"\n")
|
||||
if env.purgeSettings.getCalls != 0 || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected no purge setting access")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeLayering(t *testing.T) {
|
||||
list := func(t *testing.T, format string, pkg string) []string {
|
||||
t.Helper()
|
||||
output, err := exec.Command("go", "list", "-tags", "nofrontend", "-f", format, pkg).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go list failed: %v: %s", err, output)
|
||||
}
|
||||
return strings.Fields(string(output))
|
||||
}
|
||||
|
||||
t.Run("internal/domain/purge imports only the stdlib and other domain packages", func(t *testing.T) {
|
||||
for _, imported := range list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/domain/purge") {
|
||||
if strings.Contains(imported, ".") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("unexpected import %s", imported)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the use cases import no http, chi, gorm, persistence, mailer, models or api package", func(t *testing.T) {
|
||||
for _, imported := range list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/infrastructure/useCases") {
|
||||
for _, forbidden := range []string{"net/http", "go-chi", "gorm", "persistence", "mailer", "RenewCMS/api"} {
|
||||
if strings.Contains(imported, forbidden) {
|
||||
t.Errorf("useCases must not import %s", imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the scheduler imports only the stdlib and domain packages", func(t *testing.T) {
|
||||
imports := list(t, `{{join .Imports "\n"}}`, "RenewCMS/internal/infrastructure/scheduler")
|
||||
if len(imports) == 0 {
|
||||
t.Fatal("expected imports")
|
||||
}
|
||||
for _, imported := range imports {
|
||||
if strings.Contains(imported, ".") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("unexpected import %s", imported)
|
||||
}
|
||||
if strings.HasPrefix(imported, "RenewCMS/") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("scheduler must not import %s", imported)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSettingsRepositoryRoundTrip(t *testing.T) {
|
||||
newRepo := func(t *testing.T) *persistence.PurgeSettingsRepository {
|
||||
t.Helper()
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.PurgeSettings{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
return persistence.NewPurgeSettingsRepository(db)
|
||||
}
|
||||
|
||||
t.Run("Nothing saved yields the defaults", func(t *testing.T) {
|
||||
got, err := newRepo(t).Get()
|
||||
|
||||
if err != nil || !reflect.DeepEqual(got, domainPurge.DefaultSettings()) {
|
||||
t.Errorf("expected defaults, got %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Null and set timestamps survive a save", func(t *testing.T) {
|
||||
repo := newRepo(t)
|
||||
withNulls := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7}
|
||||
if err := repo.Save(withNulls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.Get()
|
||||
if err != nil || !reflect.DeepEqual(got, withNulls) {
|
||||
t.Errorf("expected %+v, got %+v, %v", withNulls, got, err)
|
||||
}
|
||||
|
||||
last := time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC)
|
||||
enabled := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
full := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 365, LastPurgeAt: &last, LastPurgeMerged: 3, LastPurgeArticlesUpdated: 1, EnabledAt: &enabled}
|
||||
if err := repo.Save(full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = repo.Get()
|
||||
if err != nil || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(last) || got.EnabledAt == nil || !got.EnabledAt.Equal(enabled) || got.LastPurgeMerged != 3 || got.LastPurgeArticlesUpdated != 1 || got.AutoPurgeIntervalDays != 365 {
|
||||
t.Errorf("unexpected round trip %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SaveLastRun writes only the last_purge_* columns", func(t *testing.T) {
|
||||
repo := newRepo(t)
|
||||
enabled := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
if err := repo.Save(domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 9, EnabledAt: &enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := repo.SaveLastRun(at, 4, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repo.Get()
|
||||
if err != nil || !got.AutoPurgeEnabled || got.AutoPurgeIntervalDays != 9 || got.EnabledAt == nil || !got.EnabledAt.Equal(enabled) || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(at) || got.LastPurgeMerged != 4 || got.LastPurgeArticlesUpdated != 2 {
|
||||
t.Errorf("unexpected settings %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SaveLastRun with no saved row keeps the defaults", func(t *testing.T) {
|
||||
repo := newRepo(t)
|
||||
at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := repo.SaveLastRun(at, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repo.Get()
|
||||
defaults := domainPurge.DefaultSettings()
|
||||
if err != nil || got.AutoPurgeEnabled != defaults.AutoPurgeEnabled || got.AutoPurgeIntervalDays != defaults.AutoPurgeIntervalDays || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(at) {
|
||||
t.Errorf("unexpected settings %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Repeated saves keep a single row", func(t *testing.T) {
|
||||
db := openInMemoryDb(t)
|
||||
if err := db.AutoMigrate(&models.PurgeSettings{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := persistence.NewPurgeSettingsRepository(db)
|
||||
for days := 1; days <= 3; days++ {
|
||||
if err := repo.Save(domainPurge.Settings{AutoPurgeIntervalDays: days}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&models.PurgeSettings{}).Count(&count)
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("expected a single row, got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
schedulerClock = "2026-10-05T09:00:00Z"
|
||||
schedulerWaitTimeout = 2 * time.Second
|
||||
schedulerQuietPeriod = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
type schedulerEnv struct {
|
||||
*mediaEnv
|
||||
runPurge *useCases.RunPurgeUseCase
|
||||
scheduler *useCases.PurgeSchedulerUseCase
|
||||
update *useCases.UpdatePurgeSettingsUseCase
|
||||
}
|
||||
|
||||
func newSchedulerEnv(t *testing.T) *schedulerEnv {
|
||||
t.Helper()
|
||||
env := newPurgeEnv(t)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0))
|
||||
return &schedulerEnv{
|
||||
mediaEnv: env,
|
||||
runPurge: runPurge,
|
||||
scheduler: useCases.NewPurgeSchedulerUseCase(env.purgeSettings, runPurge),
|
||||
update: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) configure(enabled bool, interval int, enabledAt string, lastPurgeAt string) {
|
||||
settings := purge.Settings{AutoPurgeEnabled: enabled, AutoPurgeIntervalDays: interval}
|
||||
if enabledAt != "" {
|
||||
settings.EnabledAt = purgeTime(enabledAt)
|
||||
}
|
||||
if lastPurgeAt != "" {
|
||||
settings.LastPurgeAt = purgeTime(lastPurgeAt)
|
||||
}
|
||||
e.purgeSettings.settings = settings
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) configureDue() {
|
||||
e.configure(true, 10, "2026-09-20T09:00:00Z", "")
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) seedPlannedGroup() {
|
||||
e.seedGroup(hashA, 1, 2)
|
||||
e.useImages(1, 1, 2)
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) {
|
||||
t.Helper()
|
||||
_, ran, err := e.scheduler.Tick(*purgeTime(at))
|
||||
return ran, err
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) expectTick(t *testing.T, at string, wantRan bool) {
|
||||
t.Helper()
|
||||
ran, err := e.tick(t, at)
|
||||
if err != nil || ran != wantRan {
|
||||
t.Fatalf("expected ran %v and no error at %s, got %v and %v", wantRan, at, ran, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *schedulerEnv) expectLastPurge(t *testing.T, at string, merged int, updated int) {
|
||||
t.Helper()
|
||||
settings := e.purgeSettings.settings
|
||||
if settings.LastPurgeAt == nil || !settings.LastPurgeAt.Equal(*purgeTime(at)) || settings.LastPurgeMerged != merged || settings.LastPurgeArticlesUpdated != updated {
|
||||
t.Fatalf("unexpected last purge %+v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerDecidesWhenARunIsDue(t *testing.T) {
|
||||
t.Run("Automatic purge off never runs, whatever the dates are", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(false, 1, "2020-01-01T00:00:00Z", "2020-01-02T00:00:00Z")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 || len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected nothing to run or be saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The defaults never run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("On, never ran, not due yet", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-09-20T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("On, never ran, due since enabled_at plus the interval", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-25T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 || len(env.images.deleteCalls) != 1 {
|
||||
t.Errorf("expected exactly one run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The anchor is the later of last_purge_at and enabled_at", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
enabledAt string
|
||||
lastPurgeAt string
|
||||
ran bool
|
||||
}{
|
||||
{"last purge plus interval is past", "2026-09-01T00:00:00Z", "2026-09-20T00:00:00Z", true},
|
||||
{"last purge plus interval is ahead", "2026-09-01T00:00:00Z", "2026-09-28T00:00:00Z", false},
|
||||
{"enabled_at is later and ahead", "2026-09-28T00:00:00Z", "2026-09-01T00:00:00Z", false},
|
||||
{"enabled_at is later and past", "2026-09-20T00:00:00Z", "2026-09-01T00:00:00Z", true},
|
||||
{"exactly now is due", "2026-09-25T09:00:00Z", "", true},
|
||||
{"one second before due", "2026-09-25T09:00:01Z", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, c.enabledAt, c.lastPurgeAt)
|
||||
|
||||
env.expectTick(t, schedulerClock, c.ran)
|
||||
|
||||
wantSaves := 0
|
||||
if c.ran {
|
||||
wantSaves = 1
|
||||
}
|
||||
if env.purgeSettings.saveCalls != wantSaves {
|
||||
t.Errorf("expected %d runs, got %d", wantSaves, env.purgeSettings.saveCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Due exactly at the boundary, not before", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-25T09:00:00Z", "")
|
||||
|
||||
env.expectTick(t, "2026-10-05T08:59:59Z", false)
|
||||
env.expectTick(t, "2026-10-05T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("A run advances the anchor, so the next tick is not due", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 {
|
||||
t.Errorf("expected one run")
|
||||
}
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
|
||||
t.Run("A due run that finds nothing to purge still moves the clock forward", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 0, 0)
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.expectTick(t, "2026-10-15T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("A run keeps auto_purge_enabled, the interval and enabled_at", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
before := env.purgeSettings.settings
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
after := env.purgeSettings.settings
|
||||
if !after.AutoPurgeEnabled || after.AutoPurgeIntervalDays != before.AutoPurgeIntervalDays || !after.EnabledAt.Equal(*before.EnabledAt) {
|
||||
t.Errorf("unexpected settings %+v", after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Enabled with no enabled_at is not due", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 1, "", "")
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.saveCalls != 0 || env.purgeSettings.settings.EnabledAt != nil {
|
||||
t.Errorf("expected nothing stamped or saved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerFollowsSettingsChanges(t *testing.T) {
|
||||
updateInterval := func(env *schedulerEnv, days int) {
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeIntervalDays: &days}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Shortening the interval makes a run due on the next tick", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-09-25T09:00:00Z", "")
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
updateInterval(env, 7)
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("Lengthening the interval postpones the run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 10, "2026-09-20T09:00:00Z", "")
|
||||
|
||||
updateInterval(env, 30)
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
|
||||
t.Run("Turning it off stops runs", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
disabled := false
|
||||
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &disabled}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
|
||||
t.Run("Turning it on resets the anchor to the moment of the switch", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(false, 30, "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z")
|
||||
enabled := true
|
||||
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &enabled}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.expectTick(t, "2026-11-04T09:00:00Z", true)
|
||||
})
|
||||
|
||||
t.Run("The scheduler reads the settings at every tick", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
env.configureDue()
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.configure(false, 10, "2026-09-20T09:00:00Z", "")
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
|
||||
if env.purgeSettings.getCalls < 3 {
|
||||
t.Errorf("expected at least three reads, got %d", env.purgeSettings.getCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
|
||||
t.Run("A due Tick returns what the run did", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
|
||||
want := purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1, ArticlesUpdated: 1}
|
||||
if err != nil || !ran || result != want {
|
||||
t.Errorf("expected %+v, got %+v, %v, %v", want, result, ran, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A Tick that is not due returns an empty result", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
|
||||
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
|
||||
|
||||
if err != nil || ran || result != (purge.RunResult{}) {
|
||||
t.Errorf("unexpected %+v, %v, %v", result, ran, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeRunKeepsSettingsChangedWhileItRuns(t *testing.T) {
|
||||
t.Run("A settings change made during a run survives, only last_purge_* are written by the run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
writing := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.purgeSettings.beforeRunWrite = func() {
|
||||
close(writing)
|
||||
<-release
|
||||
}
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := env.runPurge.Run(nil)
|
||||
runDone <- err
|
||||
}()
|
||||
waitFor(t, writing)
|
||||
disabled := false
|
||||
days := 5
|
||||
if _, err := env.update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeEnabled: &disabled, AutoPurgeIntervalDays: &days}); err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
|
||||
close(release)
|
||||
if err := waitFor(t, runDone); err != nil {
|
||||
t.Fatalf("unexpected run error %v", err)
|
||||
}
|
||||
|
||||
settings := env.purgeSettings.settings
|
||||
if settings.AutoPurgeEnabled || settings.AutoPurgeIntervalDays != 5 {
|
||||
t.Errorf("expected the change to survive, got %+v", settings)
|
||||
}
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerFailures(t *testing.T) {
|
||||
t.Run("Reading the settings fails", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.purgeSettings.fail.failNext("Get")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no run")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("RunPurge returns an error", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.images.fail.failNext("ListDuplicates")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if env.purgeSettings.settings.LastPurgeAt != nil || env.purgeSettings.saveCalls != 0 {
|
||||
t.Errorf("expected last_purge_at unchanged")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
})
|
||||
|
||||
t.Run("Saving the run result fails after the merges succeeded", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
env.purgeSettings.fail.failNext("SaveLastRun")
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected the repository error, got %v and %v", ran, err)
|
||||
}
|
||||
if len(env.images.deleteCalls) != 1 {
|
||||
t.Errorf("expected the merge to stay done")
|
||||
}
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
env.expectLastPurge(t, schedulerClock, 0, 0)
|
||||
})
|
||||
|
||||
t.Run("A run that partially fails still records what it merged", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.seedGroup(hashB, 3, 4)
|
||||
env.useImages(20, 1, 2)
|
||||
env.useImages(21, 3, 4)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{21: errRepositoryFailure}
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
env.articles.failUpdateBodyFor = nil
|
||||
if plan := env.preview(t); numberOf(plan["groups"]) != 1 {
|
||||
t.Errorf("expected group B to be planned again, got %v", plan)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A manual run is in progress", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.images.onDelete = func(uint32) {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
manualDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := env.runPurge.Run(nil)
|
||||
manualDone <- err
|
||||
}()
|
||||
waitFor(t, entered)
|
||||
|
||||
ran, err := env.tick(t, schedulerClock)
|
||||
|
||||
if ran || !errors.Is(err, purge.ErrPurgeBusy) {
|
||||
t.Fatalf("expected ErrPurgeBusy, got %v and %v", ran, err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-manualDone; err != nil {
|
||||
t.Fatalf("unexpected manual error %v", err)
|
||||
}
|
||||
env.expectTick(t, schedulerClock, false)
|
||||
})
|
||||
}
|
||||
|
||||
type schedulerJobFake struct {
|
||||
mu sync.Mutex
|
||||
calls []time.Time
|
||||
results []schedulerJobResult
|
||||
entered chan time.Time
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
type schedulerJobResult struct {
|
||||
result purge.RunResult
|
||||
ran bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newSchedulerJobFake(results ...schedulerJobResult) *schedulerJobFake {
|
||||
return &schedulerJobFake{results: results, entered: make(chan time.Time, 16)}
|
||||
}
|
||||
|
||||
func (f *schedulerJobFake) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, now)
|
||||
var result schedulerJobResult
|
||||
if len(f.results) > 0 {
|
||||
result = f.results[0]
|
||||
f.results = f.results[1:]
|
||||
}
|
||||
gate := f.gate
|
||||
f.mu.Unlock()
|
||||
f.entered <- now
|
||||
if gate != nil {
|
||||
<-gate
|
||||
}
|
||||
return result.result, result.ran, result.err
|
||||
}
|
||||
|
||||
func (f *schedulerJobFake) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
type schedulerClockFake struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *schedulerClockFake) set(value string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = *purgeTime(value)
|
||||
}
|
||||
|
||||
func (c *schedulerClockFake) read() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
type runnerHarness struct {
|
||||
job *schedulerJobFake
|
||||
clock *schedulerClockFake
|
||||
ticks chan time.Time
|
||||
startup chan time.Time
|
||||
logs *bytes.Buffer
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, job *schedulerJobFake) *runnerHarness {
|
||||
t.Helper()
|
||||
h := &runnerHarness{
|
||||
job: job,
|
||||
clock: &schedulerClockFake{},
|
||||
ticks: make(chan time.Time, 8),
|
||||
startup: make(chan time.Time, 1),
|
||||
logs: &bytes.Buffer{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
h.clock.set(schedulerClock)
|
||||
runner := scheduler.NewRunner(job, h.clock.read, h.ticks, h.startup, log.New(h.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.cancel = cancel
|
||||
go func() {
|
||||
defer close(h.done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(schedulerWaitTimeout):
|
||||
}
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *runnerHarness) expectStopped(t *testing.T) {
|
||||
t.Helper()
|
||||
waitFor(t, h.done)
|
||||
}
|
||||
|
||||
func waitFor[T any](t *testing.T, ch <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
return value
|
||||
case <-time.After(schedulerWaitTimeout):
|
||||
t.Fatalf("timed out waiting")
|
||||
panic("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerRunner(t *testing.T) {
|
||||
t.Run("A failing tick does not stop the runner", func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{err: errRepositoryFailure}, schedulerJobResult{ran: true})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 1 {
|
||||
t.Fatalf("expected no retry between ticks, got %d calls", job.callCount())
|
||||
}
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
if !strings.Contains(h.logs.String(), errRepositoryFailure.Error()) {
|
||||
t.Errorf("expected the error to be logged, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A busy skip is not logged as an error", func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{err: purge.ErrPurgeBusy})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
|
||||
if h.logs.Len() != 0 {
|
||||
t.Errorf("expected no log line, got %q", h.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A completed run logs one line with the counts", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result purge.RunResult
|
||||
want string
|
||||
}{
|
||||
{"a run that purged", purge.RunResult{GroupsTotal: 4, GroupsPurged: 3, Merged: 5, ArticlesUpdated: 2, FailedGroups: 1}, "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1\n"},
|
||||
{"an empty run", purge.RunResult{}, "scheduler: automatic purge completed: groups_purged=0 merged=0 articles_updated=0 failed_groups=0\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
job := newSchedulerJobFake(schedulerJobResult{result: c.result, ran: true})
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.ticks <- time.Time{}
|
||||
waitFor(t, job.entered)
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
|
||||
if h.logs.String() != c.want {
|
||||
t.Errorf("expected %q, got %q", c.want, h.logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("One Tick shortly after startup", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 0 {
|
||||
t.Fatalf("expected no Tick before the signal")
|
||||
}
|
||||
|
||||
h.startup <- time.Time{}
|
||||
|
||||
if got := waitFor(t, job.entered); !got.Equal(*purgeTime(schedulerClock)) {
|
||||
t.Errorf("expected the clock's time, got %s", got)
|
||||
}
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
if job.callCount() != 1 {
|
||||
t.Errorf("expected one Tick, got %d", job.callCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A due run is caught up at startup after downtime", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 30, "2026-08-01T00:00:00Z", "2026-08-26T09:00:00Z")
|
||||
env.seedPlannedGroup()
|
||||
clock := &schedulerClockFake{}
|
||||
clock.set(schedulerClock)
|
||||
ticks := make(chan time.Time, 8)
|
||||
startup := make(chan time.Time, 1)
|
||||
runner := scheduler.NewRunner(env.scheduler, clock.read, ticks, startup, log.New(env.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
|
||||
startup <- time.Time{}
|
||||
deadline := time.Now().Add(schedulerWaitTimeout)
|
||||
for lastPurgeAt := env.purgeSettings.snapshot().LastPurgeAt; lastPurgeAt == nil || lastPurgeAt.Before(*purgeTime(schedulerClock)); lastPurgeAt = env.purgeSettings.snapshot().LastPurgeAt {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the startup run did not happen")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
waitFor(t, done)
|
||||
|
||||
if env.purgeSettings.saveCalls != 1 {
|
||||
t.Errorf("expected exactly one run, got %d", env.purgeSettings.saveCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A Tick on each hourly tick", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
for _, at := range []string{"2026-10-05T10:00:00Z", "2026-10-05T11:00:00Z", "2026-10-05T12:00:00Z"} {
|
||||
h.clock.set(at)
|
||||
h.ticks <- time.Time{}
|
||||
if got := waitFor(t, job.entered); !got.Equal(*purgeTime(at)) {
|
||||
t.Errorf("expected %s, got %s", at, got)
|
||||
}
|
||||
}
|
||||
|
||||
if job.callCount() != 3 {
|
||||
t.Errorf("expected three Ticks, got %d", job.callCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancelling the context stops the runner", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.cancel()
|
||||
h.expectStopped(t)
|
||||
h.ticks <- time.Time{}
|
||||
time.Sleep(schedulerQuietPeriod)
|
||||
|
||||
if job.callCount() != 0 {
|
||||
t.Errorf("expected no Tick after cancel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cancelled before the startup signal", func(t *testing.T) {
|
||||
job := newSchedulerJobFake()
|
||||
h := startRunner(t, job)
|
||||
|
||||
h.cancel()
|
||||
h.startup <- time.Time{}
|
||||
h.expectStopped(t)
|
||||
|
||||
if job.callCount() != 0 {
|
||||
t.Errorf("expected no Tick")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Shutdown waits for a run in progress without a time limit", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
env.seedPlannedGroup()
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
env.images.onDelete = func(uint32) {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
clock := &schedulerClockFake{}
|
||||
clock.set(schedulerClock)
|
||||
ticks := make(chan time.Time, 1)
|
||||
runner := scheduler.NewRunner(env.scheduler, clock.read, ticks, make(chan time.Time), log.New(env.logs, "", 0))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runner.Run(ctx)
|
||||
}()
|
||||
ticks <- time.Time{}
|
||||
waitFor(t, entered)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("Run returned while a purge was in progress")
|
||||
case <-time.After(schedulerQuietPeriod):
|
||||
}
|
||||
close(release)
|
||||
waitFor(t, done)
|
||||
|
||||
env.expectLastPurge(t, schedulerClock, 1, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSchedulerResultIsServedBySettings(t *testing.T) {
|
||||
t.Run("A scheduled run updates the Last purge values served by the settings endpoint", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configure(true, 7, "2026-09-20T09:00:00Z", "")
|
||||
env.seedPlannedGroup()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if body["last_purge_at"] != schedulerClock || numberOf(body["last_purge_merged"]) != 1 || numberOf(body["last_purge_articles_updated"]) != 1 || body["next_purge_at"] != "2026-10-12T09:00:00Z" || body["auto_purge_enabled"] != true || numberOf(body["auto_purge_interval_days"]) != 7 || body["enabled_at"] != "2026-09-20T09:00:00Z" {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A scheduled run with nothing to purge shows zeros, not Never run", func(t *testing.T) {
|
||||
env := newSchedulerEnv(t)
|
||||
env.configureDue()
|
||||
|
||||
env.expectTick(t, schedulerClock, true)
|
||||
rec := env.purgeSend(t, http.MethodGet, "/v1/media/purge-settings", "")
|
||||
|
||||
body := decodeObject(t, rec.Body.String())
|
||||
if body["last_purge_at"] != schedulerClock || numberOf(body["last_purge_merged"]) != 0 || numberOf(body["last_purge_articles_updated"]) != 0 {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
|
||||
for _, days := range []int{0, 366} {
|
||||
repo := newMockPurgeSettingsRepo()
|
||||
update := useCases.NewUpdatePurgeSettingsUseCase(repo, time.Now)
|
||||
|
||||
_, err := update.Update(useCases.UpdatePurgeSettingsCommand{AutoPurgeIntervalDays: &days})
|
||||
|
||||
if !errors.Is(err, purge.ErrInvalidPurgeInterval) {
|
||||
t.Errorf("interval %d: expected ErrInvalidPurgeInterval, got %v", days, err)
|
||||
}
|
||||
if repo.saveCalls != 0 {
|
||||
t.Errorf("interval %d: expected Save not to be called", days)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
|
||||
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0))
|
||||
}
|
||||
|
||||
t.Run("a non-matching fingerprint is a conflict and writes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
wrong := "wrong"
|
||||
|
||||
_, err := newRun(env).Run(&wrong)
|
||||
|
||||
if !errors.Is(err, purge.ErrPurgeConflict) || errors.Is(err, purge.ErrPurgeBusy) {
|
||||
t.Errorf("expected only ErrPurgeConflict, got %v", err)
|
||||
}
|
||||
if env.purgeSettings.saveCalls != 0 || len(env.images.deleteCalls) != 0 {
|
||||
t.Errorf("expected no write")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a run while another holds the guard is busy and writes nothing", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
env.images.onDelete = func(uint32) {
|
||||
once.Do(func() {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
}
|
||||
run := newRun(env)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := run.Run(nil)
|
||||
done <- err
|
||||
}()
|
||||
<-entered
|
||||
savesBefore := env.purgeSettings.saveCalls
|
||||
|
||||
_, err := run.Run(nil)
|
||||
|
||||
if !errors.Is(err, purge.ErrPurgeBusy) || errors.Is(err, purge.ErrPurgeConflict) {
|
||||
t.Errorf("expected only ErrPurgeBusy, got %v", err)
|
||||
}
|
||||
if env.purgeSettings.saveCalls != savesBefore {
|
||||
t.Errorf("expected no write from the busy call")
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("expected the first run to finish, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a scheduler run without a fingerprint gets the result and the persistence", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(10, 1)
|
||||
|
||||
result, err := newRun(env).Run(nil)
|
||||
|
||||
if err != nil || result != (purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1}) {
|
||||
t.Errorf("unexpected result %+v, %v", result, err)
|
||||
}
|
||||
if env.purgeSettings.settings.LastPurgeAt == nil || env.purgeSettings.settings.LastPurgeMerged != 1 {
|
||||
t.Errorf("expected last_purge_* persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
|
||||
t.Run("groups are ordered by the kept image id whatever the order they were found in", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 6)
|
||||
env.seedGroup(hashB, 2, 5)
|
||||
env.seedGroup(hashC, 3, 4)
|
||||
env.useImages(10, 6)
|
||||
env.useImages(11, 5)
|
||||
env.useImages(12, 3)
|
||||
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
|
||||
|
||||
var kept []uint32
|
||||
for _, group := range preview.List {
|
||||
kept = append(kept, group.KeepID)
|
||||
}
|
||||
if err != nil || !reflect.DeepEqual(kept, []uint32{3, 5, 6}) {
|
||||
t.Errorf("expected kept ids [3 5 6], got %v, %v", kept, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the fingerprint hashes the kept id and the comma separated merged ids of each group", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2, 3)
|
||||
env.seedGroup(hashB, 4, 5)
|
||||
env.useImages(10, 1, 2, 3)
|
||||
env.useImages(11, 5)
|
||||
|
||||
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles).Preview()
|
||||
|
||||
sum := sha256.Sum256([]byte("1:2,3;5:4;"))
|
||||
if err != nil || preview.Fingerprint != hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("unexpected fingerprint %q, %v", preview.Fingerprint, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
t.Run("Existing merges report the same counts and now also the ids", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 1, 2)
|
||||
env.useImages(3, 1)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
|
||||
|
||||
result, err := merge.Merge(1, []uint32{2})
|
||||
|
||||
if err != nil || result.Merged != 1 || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{1, 2}) {
|
||||
t.Errorf("unexpected result %+v, %v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A rolled-back merge returns no ids", func(t *testing.T) {
|
||||
env := newPurgeEnv(t)
|
||||
env.seedGroup(hashA, 1, 2)
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 2)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
|
||||
|
||||
result, err := merge.Merge(1, []uint32{2})
|
||||
|
||||
if err == nil || result.Merged != 0 || result.ArticlesUpdated != 0 || len(result.UpdatedArticleIDs) != 0 {
|
||||
t.Errorf("unexpected result %+v, %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPurgeSettingsNextPurgeAt(t *testing.T) {
|
||||
t.Run("is null when off or when never enabled", func(t *testing.T) {
|
||||
if purge.DefaultSettings().NextPurgeAt() != nil {
|
||||
t.Errorf("expected nil")
|
||||
}
|
||||
if (purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 3}).NextPurgeAt() != nil {
|
||||
t.Errorf("expected nil without enabled_at")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/middleware"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -38,7 +39,7 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) {
|
||||
|
||||
for _, directive := range []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self';",
|
||||
"script-src 'self' 'wasm-unsafe-eval';",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
@@ -47,11 +48,34 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) {
|
||||
t.Errorf("expected CSP to contain %q, got %q", directive, csp)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csp, "script-src 'self' 'unsafe") {
|
||||
if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") {
|
||||
t.Errorf("script-src must not allow unsafe sources, got %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInlineScriptsHashesOnlyInlineScripts(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self'; object-src 'none'"
|
||||
html := []byte(`<html><head><script>var a=1</script><script type="module" src="/a.js"></script><script> </script></head></html>`)
|
||||
|
||||
got := frontend.AllowInlineScripts(policy, html)
|
||||
|
||||
if !strings.Contains(got, "script-src 'self' 'sha256-") || strings.Count(got, "sha256-") != 1 {
|
||||
t.Errorf("expected exactly one inline hash in script-src, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "; object-src 'none'") {
|
||||
t.Errorf("expected other directives untouched, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInlineScriptsLeavesPolicyWithoutInlineScripts(t *testing.T) {
|
||||
policy := "script-src 'self'"
|
||||
html := []byte(`<script type="module" src="/a.js"></script>`)
|
||||
|
||||
if got := frontend.AllowInlineScripts(policy, html); got != policy {
|
||||
t.Errorf("expected policy unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersHSTSOnlyWhenEnabled(t *testing.T) {
|
||||
if got := serveWithSecurityHeaders(false).Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("expected no HSTS header, got %q", got)
|
||||
|
||||
@@ -23,6 +23,7 @@ const props = withDefaults(
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
bleedOfflineBanner?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
offlineMessage?: string
|
||||
offlineIcon?: string
|
||||
@@ -36,6 +37,7 @@ const props = withDefaults(
|
||||
offlineIcon: undefined,
|
||||
fullBleed: false,
|
||||
compactPadding: false,
|
||||
bleedOfflineBanner: false,
|
||||
renderWhileChecking: false,
|
||||
},
|
||||
)
|
||||
@@ -301,7 +303,8 @@ async function onLogout() {
|
||||
<OfflineBanner
|
||||
:message="offlineMessage"
|
||||
:icon="offlineIcon"
|
||||
:bleed="compactPadding"
|
||||
:bleed="compactPadding || bleedOfflineBanner"
|
||||
:roomy="!compactPadding && bleedOfflineBanner"
|
||||
/>
|
||||
|
||||
<AppBoot v-if="showBoot" />
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
DEFAULT_INTERVAL_DAYS,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
lastPurgeLine,
|
||||
nextPurgeLine,
|
||||
parseIntervalDays,
|
||||
purgedToast,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
const READ_ONLY_COPY = 'Only owners and admins can change this.'
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
const router = useRouter()
|
||||
const online = useOnline()
|
||||
|
||||
const isAdmin = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const readOnly = computed(() => !isAdmin.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
const cleanup = useMediaCleanupSettings(() => {
|
||||
session.refresh()
|
||||
})
|
||||
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
|
||||
|
||||
const enabled = ref(false)
|
||||
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
|
||||
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
|
||||
const daysInvalid = ref(false)
|
||||
const daysFocused = ref(false)
|
||||
const purgeOpen = ref(false)
|
||||
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
|
||||
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
|
||||
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
|
||||
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
|
||||
const controlsDisabled = computed(() => readOnly.value || offline.value)
|
||||
const switchChecked = computed(() => !readOnly.value && enabled.value)
|
||||
const showEvery = computed(() => readOnly.value || enabled.value)
|
||||
const lastLine = computed(() =>
|
||||
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
|
||||
)
|
||||
const nextIso = computed(() =>
|
||||
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
|
||||
)
|
||||
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
|
||||
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const fieldError = computed(() => daysError.value || daysSaveError.value)
|
||||
|
||||
watch(settings, (next) => {
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
enabled.value = next.auto_purge_enabled
|
||||
committedDays.value = next.auto_purge_interval_days
|
||||
if (!daysInvalid.value && !daysFocused.value) {
|
||||
daysText.value = String(next.auto_purge_interval_days)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
isAdmin,
|
||||
(admin) => {
|
||||
if (admin) {
|
||||
cleanup.load()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function toggle(value: boolean) {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const previous = enabled.value
|
||||
enabled.value = value
|
||||
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
|
||||
if (!saved) {
|
||||
enabled.value = previous
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDays() {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const days = parseIntervalDays(daysText.value)
|
||||
if (days === null) {
|
||||
daysInvalid.value = true
|
||||
return
|
||||
}
|
||||
daysInvalid.value = false
|
||||
if (days === committedDays.value) {
|
||||
daysText.value = String(days)
|
||||
return
|
||||
}
|
||||
const previous = committedDays.value
|
||||
committedDays.value = days
|
||||
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
|
||||
if (!saved) {
|
||||
committedDays.value = previous
|
||||
daysText.value = String(previous)
|
||||
}
|
||||
}
|
||||
|
||||
function onDaysBlur() {
|
||||
daysFocused.value = false
|
||||
commitDays()
|
||||
}
|
||||
|
||||
function openPurge() {
|
||||
if (!offline.value) {
|
||||
purgeOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function closePurge() {
|
||||
purgeOpen.value = false
|
||||
await nextTick()
|
||||
purgeButton.value?.$el?.focus()
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
cleanup.refresh()
|
||||
}
|
||||
|
||||
function reviewDuplicates() {
|
||||
purgeOpen.value = false
|
||||
router.push(REVIEW_DUPLICATES_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="media-cleanup-h"
|
||||
data-testid="media-cleanup-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
|
||||
<USeparator />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
disabled
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="cleanup-save-status"
|
||||
class="flex h-6 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
{{ t('Saving…') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveStatus === 'saved'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-success"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="cleanup.load"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<UAlert
|
||||
v-if="readOnly"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-info"
|
||||
:description="t(READ_ONLY_COPY)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span id="auto-purge-label" class="font-medium text-highlighted">{{
|
||||
t('Automatic purge')
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t('Runs in the background. Off by default.')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="switchSaveError"
|
||||
role="alert"
|
||||
data-testid="cleanup-switch-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ switchSaveError }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
|
||||
<USwitch
|
||||
:model-value="switchChecked"
|
||||
:disabled="controlsDisabled"
|
||||
aria-labelledby="auto-purge-label"
|
||||
:ui="{ base: 'data-[disabled]:opacity-40' }"
|
||||
@update:model-value="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showEvery"
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label for="auto-purge-days" class="font-medium text-highlighted">{{
|
||||
t('Every')
|
||||
}}</label>
|
||||
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 md:items-end">
|
||||
<UInput
|
||||
id="auto-purge-days"
|
||||
v-model="daysText"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:disabled="controlsDisabled"
|
||||
:highlight="fieldError !== ''"
|
||||
:color="fieldError ? 'error' : 'neutral'"
|
||||
:aria-invalid="fieldError ? 'true' : undefined"
|
||||
class="w-full md:w-35"
|
||||
:ui="{
|
||||
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@focus="daysFocused = true"
|
||||
@blur="onDaysBlur"
|
||||
@keydown.enter="commitDays"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<span
|
||||
v-if="fieldError"
|
||||
role="alert"
|
||||
data-testid="cleanup-days-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ fieldError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-toned">
|
||||
<span data-testid="cleanup-last">{{ lastLine }}</span>
|
||||
<span v-if="nextIso" data-testid="cleanup-next">{{
|
||||
nextPurgeLine(nextIso)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!readOnly">
|
||||
<USeparator />
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex md:shrink-0">
|
||||
<UButton
|
||||
ref="purgeButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="openPurge"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="cleanup.refresh"
|
||||
@review="reviewDuplicates"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
showPurge?: boolean
|
||||
purgeDisabled?: boolean
|
||||
}>(),
|
||||
{ showPurge: false, purgeDisabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:q': [value: string]
|
||||
'update:filter': [value: MediaFilter]
|
||||
'update:sort': [value: MediaSort]
|
||||
'update:view': [value: MediaView]
|
||||
purge: []
|
||||
}>()
|
||||
|
||||
const searchText = ref(props.controls.q)
|
||||
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
|
||||
<div
|
||||
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
|
||||
>
|
||||
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<UButton
|
||||
v-if="showPurge"
|
||||
data-testid="purge-duplicates"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="purgeDisabled"
|
||||
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="emit('purge')"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
|
||||
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
|
||||
<UButton
|
||||
v-for="option in VIEW_OPTIONS"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const DEFAULT_MESSAGE =
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
|
||||
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
|
||||
|
||||
const BLEED_UI = {
|
||||
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
|
||||
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
|
||||
@@ -14,15 +16,18 @@ const BLEED_UI = {
|
||||
title: 'whitespace-normal font-normal text-wrap',
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
icon?: string
|
||||
bleed?: boolean
|
||||
roomy?: boolean
|
||||
}>(),
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
|
||||
)
|
||||
|
||||
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
@@ -46,6 +51,6 @@ onUnmounted(() => {
|
||||
color="neutral"
|
||||
:icon="icon"
|
||||
:title="translate(message)"
|
||||
:ui="bleed ? BLEED_UI : undefined"
|
||||
:ui="bleed ? bleedUi : undefined"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
PURGE_BUSY_COPY,
|
||||
PURGE_STALE_COPY,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
isForbidden,
|
||||
isHandledGlobally,
|
||||
keptLine,
|
||||
mergedCopies,
|
||||
moreGroupsLine,
|
||||
pluralGroups,
|
||||
previewSummary,
|
||||
thumbnailSource,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
|
||||
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SKELETON_ROWS = 3
|
||||
const HTTP_CONFLICT = 409
|
||||
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
|
||||
const PARTIAL_COPY =
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
|
||||
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||
const MODAL_CONTENT =
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
purged: [result: PurgeResult]
|
||||
partial: [result: PurgeResult]
|
||||
review: []
|
||||
}>()
|
||||
|
||||
const { getPreview, purge } = useMediaPurge()
|
||||
const session = useSessionStore()
|
||||
|
||||
const open = ref(true)
|
||||
const status = ref<Status>('loading')
|
||||
const notice = ref<Notice | null>(null)
|
||||
const preview = ref<PurgePreview | null>(null)
|
||||
const partialCounts = ref({ purged: 0, total: 0 })
|
||||
const refreshing = ref(false)
|
||||
const showSkeleton = ref(false)
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const purging = computed(() => status.value === 'purging')
|
||||
const busy = computed(() => purging.value || refreshing.value)
|
||||
const nothing = computed(() => status.value === 'nothing')
|
||||
const partial = computed(() => status.value === 'partial')
|
||||
const waitingForPreview = computed(() => status.value === 'loading')
|
||||
const canConfirm = computed(() => preview.value !== null && !busy.value)
|
||||
const confirmLabel = computed(() => {
|
||||
if (purging.value) {
|
||||
return t('Purging…')
|
||||
}
|
||||
return preview.value
|
||||
? t('Purge {0}', [pluralGroups(preview.value.groups)])
|
||||
: t('Purge duplicates')
|
||||
})
|
||||
const noticeText = computed(() => {
|
||||
if (notice.value === 'partial') {
|
||||
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
|
||||
}
|
||||
return {
|
||||
failed: t(FAILED_COPY),
|
||||
stale: t(PURGE_STALE_COPY),
|
||||
busy: t(PURGE_BUSY_COPY),
|
||||
none: '',
|
||||
}[notice.value ?? 'none']
|
||||
})
|
||||
const noticeIsError = computed(() => notice.value === 'failed')
|
||||
const ui = computed(() => ({
|
||||
content: MODAL_CONTENT,
|
||||
header: nothing.value
|
||||
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: nothing.value
|
||||
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
|
||||
: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}))
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function giveUpAfterAuthFailure(error: unknown) {
|
||||
if (isForbidden(error)) {
|
||||
session.refresh()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview(next: PurgePreview) {
|
||||
preview.value = next
|
||||
if (next.groups === 0) {
|
||||
status.value = 'nothing'
|
||||
notice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const first = await getPreview()
|
||||
applyPreview(first)
|
||||
if (first.groups > 0) {
|
||||
status.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
status.value = 'error'
|
||||
notice.value = 'failed'
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
applyPreview(await getPreview())
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
}
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function settle(result: PurgeResult) {
|
||||
if (result.failed_groups === 0) {
|
||||
emit('purged', result)
|
||||
close()
|
||||
return
|
||||
}
|
||||
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
|
||||
notice.value = 'partial'
|
||||
status.value = 'partial'
|
||||
emit('partial', result)
|
||||
await refreshPreview()
|
||||
}
|
||||
|
||||
async function fail(error: unknown) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
||||
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
|
||||
status.value = 'conflict'
|
||||
await refreshPreview()
|
||||
return
|
||||
}
|
||||
notice.value = 'failed'
|
||||
status.value = 'error'
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value || busy.value) {
|
||||
return
|
||||
}
|
||||
const { fingerprint } = preview.value
|
||||
status.value = 'purging'
|
||||
try {
|
||||
await settle(await purge(fingerprint))
|
||||
} catch (error) {
|
||||
await fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && busy.value) {
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (!busy.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onReview(event: MouseEvent) {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
emit('review')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadPreview()
|
||||
await nextTick()
|
||||
cancelButton.value?.$el?.focus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
|
||||
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
|
||||
:dismissible="!busy"
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
|
||||
:ui="ui"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template v-if="nothing" #title>
|
||||
<span class="flex flex-col items-center gap-3">
|
||||
<span
|
||||
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
|
||||
>
|
||||
<UIcon name="i-lucide-copy" class="size-5" />
|
||||
</span>
|
||||
{{ t('Nothing to purge') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="!nothing" #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="notice && noticeText"
|
||||
:color="noticeIsError ? 'error' : 'warning'"
|
||||
variant="soft"
|
||||
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
|
||||
:description="noticeText"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
|
||||
<template v-if="waitingForPreview && showSkeleton">
|
||||
<USkeleton
|
||||
data-testid="purge-skeleton"
|
||||
class="h-4 w-4/5 rounded-none bg-accented"
|
||||
/>
|
||||
<div class="border border-default">
|
||||
<div
|
||||
v-for="row in SKELETON_ROWS"
|
||||
:key="row"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="preview">
|
||||
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
|
||||
previewSummary(preview)
|
||||
}}</span>
|
||||
<ul data-testid="purge-groups" class="border border-default">
|
||||
<li
|
||||
v-for="group in preview.list"
|
||||
:key="group.keep_id"
|
||||
data-testid="purge-group"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="thumbnailSource(group.url, group.filename)"
|
||||
size="sm"
|
||||
class="h-7.5 w-10 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
group.filename
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
keptLine(group.used_in)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap text-toned">{{
|
||||
mergedCopies(group.merged)
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="preview.more > 0"
|
||||
data-testid="purge-more"
|
||||
class="border-t border-muted px-3 py-2 text-muted"
|
||||
>
|
||||
{{ moreGroupsLine(preview.more) }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="REVIEW_DUPLICATES_PATH"
|
||||
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
@click="onReview"
|
||||
>
|
||||
{{ t('Review in Duplicates') }}
|
||||
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
ref="cancelButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="[
|
||||
BUTTON_BASE,
|
||||
'text-highlighted disabled:text-dimmed disabled:opacity-100',
|
||||
]"
|
||||
:disabled="busy"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ nothing || partial ? t('Close') : t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="partial"
|
||||
color="error"
|
||||
icon="i-lucide-rotate-cw"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="!nothing"
|
||||
color="error"
|
||||
:loading="purging"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import {
|
||||
useMediaPurge,
|
||||
type PurgeSettings,
|
||||
type PurgeSettingsPatch,
|
||||
} from '@/composables/useMediaPurge'
|
||||
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
|
||||
|
||||
export type CleanupLoadState = 'loading' | 'ready' | 'error'
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved'
|
||||
export type SaveFailure = 'enabled' | 'days'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
export function useMediaCleanupSettings(onForbidden: () => void) {
|
||||
const { getSettings, updateSettings } = useMediaPurge()
|
||||
|
||||
const settings = ref<PurgeSettings | null>(null)
|
||||
const loadState = ref<CleanupLoadState>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
const saveFailure = ref<SaveFailure | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latestLoad = 0
|
||||
let pendingSaves = 0
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedTimer() {
|
||||
if (savedTimer !== null) {
|
||||
clearTimeout(savedTimer)
|
||||
savedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
latestLoad += 1
|
||||
const ticket = latestLoad
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const loaded = await getSettings()
|
||||
if (ticket === latestLoad) {
|
||||
settings.value = loaded
|
||||
loadState.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
}
|
||||
if (isHandledGlobally(error)) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
if (ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finishSaving(succeeded: boolean) {
|
||||
if (pendingSaves > 0) {
|
||||
return
|
||||
}
|
||||
if (!succeeded) {
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
saveStatus.value = 'saved'
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus.value = 'idle'
|
||||
}, SAVED_VISIBLE_MS)
|
||||
}
|
||||
|
||||
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
|
||||
pendingSaves += 1
|
||||
clearSavedTimer()
|
||||
saveStatus.value = 'saving'
|
||||
let succeeded = false
|
||||
try {
|
||||
settings.value = await updateSettings(patch)
|
||||
saveFailure.value = null
|
||||
succeeded = true
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
} else if (!isHandledGlobally(error)) {
|
||||
saveFailure.value = field
|
||||
}
|
||||
} finally {
|
||||
pendingSaves -= 1
|
||||
finishSaving(succeeded)
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
clearSavedTimer()
|
||||
})
|
||||
|
||||
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface PurgeSettings {
|
||||
auto_purge_enabled: boolean
|
||||
auto_purge_interval_days: number
|
||||
last_purge_at: string | null
|
||||
last_purge_merged: number
|
||||
last_purge_articles_updated: number
|
||||
enabled_at: string | null
|
||||
next_purge_at: string | null
|
||||
}
|
||||
|
||||
export type PurgeSettingsPatch =
|
||||
| { auto_purge_enabled: boolean }
|
||||
| { auto_purge_interval_days: number }
|
||||
|
||||
export interface PurgePreviewGroup {
|
||||
keep_id: number
|
||||
filename: string
|
||||
url: string
|
||||
used_in: number
|
||||
merged: number
|
||||
}
|
||||
|
||||
export interface PurgePreview {
|
||||
groups: number
|
||||
copies: number
|
||||
articles: number
|
||||
list: PurgePreviewGroup[]
|
||||
more: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface PurgeResult {
|
||||
groups_total: number
|
||||
groups_purged: number
|
||||
merged: number
|
||||
articles_updated: number
|
||||
failed_groups: number
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
const PURGE_PATH = '/v1/media/purge'
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useMediaPurge() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getSettings() {
|
||||
return request<PurgeSettings>(SETTINGS_PATH)
|
||||
}
|
||||
|
||||
function updateSettings(patch: PurgeSettingsPatch) {
|
||||
return request<PurgeSettings>(SETTINGS_PATH, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
function getPreview() {
|
||||
return request<PurgePreview>(PREVIEW_PATH)
|
||||
}
|
||||
|
||||
async function purge(fingerprint: string) {
|
||||
const result = await request<PurgeResult>(PURGE_PATH, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ fingerprint }),
|
||||
})
|
||||
bumpMediaRevision()
|
||||
return result
|
||||
}
|
||||
|
||||
return { getSettings, updateSettings, getPreview, purge }
|
||||
}
|
||||
@@ -955,6 +955,55 @@ export const messages = {
|
||||
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
|
||||
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
|
||||
Meaning: 'Signification',
|
||||
'Media cleanup': 'Nettoyage des médias',
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
|
||||
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
|
||||
'Automatic purge': 'Purge automatique',
|
||||
'Runs in the background. Off by default.': 'S’exécute en arrière-plan. Désactivée par défaut.',
|
||||
Every: 'Tous les',
|
||||
'Between 1 and 365 days.': 'Entre 1 et 365 jours.',
|
||||
days: 'jours',
|
||||
'Never run': 'Jamais exécutée',
|
||||
'Last purge: {0} · {1} merged · {2} updated':
|
||||
'Dernière purge : {0} · {1} fusionnées · {2} mis à jour',
|
||||
'Next purge: {0}': 'Prochaine purge : {0}',
|
||||
"Couldn't save. Try again in a moment.": 'Impossible d’enregistrer. Réessayez dans un instant.',
|
||||
'Enter a whole number of days between 1 and 365.':
|
||||
'Saisissez un nombre entier de jours entre 1 et 365.',
|
||||
'Only owners and admins can change this.':
|
||||
'Seuls les propriétaires et les administrateurs peuvent modifier ceci.',
|
||||
'Purge now': 'Purger maintenant',
|
||||
'Merge every group of duplicates that has an image in use, right away.':
|
||||
'Fusionnez tout de suite chaque groupe de doublons dont une image est utilisée.',
|
||||
'Purge duplicates': 'Purger les doublons',
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection.":
|
||||
'Vous êtes hors ligne. Modifier la purge automatique et purger les doublons nécessitent une connexion.',
|
||||
'Purge duplicates?': 'Purger les doublons ?',
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone.":
|
||||
'Les articles qui utilisent un doublon passeront à la copie la plus utilisée de cette image, et les autres copies seront supprimées. Cette action est irréversible.',
|
||||
'1 group': '1 groupe',
|
||||
'{0} groups': '{0} groupes',
|
||||
'{0} · {1} will be deleted · {2} will be updated':
|
||||
'{0} · {1} seront supprimées · {2} seront mis à jour',
|
||||
'Kept · used in {0}': 'Conservée · utilisée dans {0}',
|
||||
'{0} merged': '{0} fusionnées',
|
||||
'and 1 more group': 'et 1 autre groupe',
|
||||
'and {0} more groups': 'et {0} autres groupes',
|
||||
'Review in Duplicates': 'Voir dans Doublons',
|
||||
'Purge {0}': 'Purger {0}',
|
||||
'Purging…': 'Purge…',
|
||||
Close: 'Fermer',
|
||||
'Nothing to purge': 'Rien à purger',
|
||||
'No duplicate is used in an article, so there is nothing to merge.':
|
||||
'Aucun doublon n’est utilisé dans un article, il n’y a donc rien à fusionner.',
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.":
|
||||
'Impossible de purger les doublons. Rien n’a été modifié. Réessayez dans un instant.',
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish.":
|
||||
'Certains groupes n’ont pas pu être fusionnés. {0} groupes sur {1} ont été purgés. Réessayez pour terminer.',
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.':
|
||||
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
|
||||
'A purge is already running.': 'Une purge est déjà en cours.',
|
||||
'Duplicates purged': 'Doublons purgés',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,107 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
export const MIN_INTERVAL_DAYS = 1
|
||||
export const MAX_INTERVAL_DAYS = 365
|
||||
export const DEFAULT_INTERVAL_DAYS = 30
|
||||
export const PURGE_TOAST_DURATION_MS = 5000
|
||||
export const REVIEW_DUPLICATES_PATH = '/media?filter=duplicates'
|
||||
export const PURGE_STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const PURGE_BUSY_COPY = 'A purge is already running.'
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const SVG_EXTENSION = /\.svg$/i
|
||||
const PNG_EXTENSION = /\.png$/i
|
||||
|
||||
export function isForbidden(error: unknown) {
|
||||
return error instanceof ApiError && error.status === HTTP_FORBIDDEN
|
||||
}
|
||||
|
||||
export function isHandledGlobally(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === HTTP_UNAUTHORIZED || isForbidden(error))
|
||||
}
|
||||
|
||||
export function parseIntervalDays(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (!WHOLE_NUMBER.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
const days = Number.parseInt(trimmed, 10)
|
||||
return days >= MIN_INTERVAL_DAYS && days <= MAX_INTERVAL_DAYS ? days : null
|
||||
}
|
||||
|
||||
export function pluralCopies(count: number) {
|
||||
return t(count === 1 ? '1 copy' : '{0} copies', [count])
|
||||
}
|
||||
|
||||
export function pluralGroups(count: number) {
|
||||
return t(count === 1 ? '1 group' : '{0} groups', [count])
|
||||
}
|
||||
|
||||
export function mergedCopies(count: number) {
|
||||
return t('{0} merged', [pluralCopies(count)])
|
||||
}
|
||||
|
||||
export function lastPurgeLine(settings: PurgeSettings | null) {
|
||||
if (!settings?.last_purge_at) {
|
||||
return t('Never run')
|
||||
}
|
||||
return t('Last purge: {0} · {1} merged · {2} updated', [
|
||||
formatMediaDate(settings.last_purge_at),
|
||||
pluralCopies(settings.last_purge_merged ?? 0),
|
||||
pluralArticles(settings.last_purge_articles_updated ?? 0),
|
||||
])
|
||||
}
|
||||
|
||||
export function nextPurgeLine(iso: string) {
|
||||
return t('Next purge: {0}', [formatMediaDate(iso)])
|
||||
}
|
||||
|
||||
export function previewSummary(counts: { groups: number; copies: number; articles: number }) {
|
||||
return t('{0} · {1} will be deleted · {2} will be updated', [
|
||||
pluralGroups(counts.groups),
|
||||
pluralCopies(counts.copies),
|
||||
pluralArticles(counts.articles),
|
||||
])
|
||||
}
|
||||
|
||||
export function keptLine(usedIn: number) {
|
||||
return t('Kept · used in {0}', [pluralArticles(usedIn)])
|
||||
}
|
||||
|
||||
export function moreGroupsLine(more: number) {
|
||||
return t(more === 1 ? 'and 1 more group' : 'and {0} more groups', [more])
|
||||
}
|
||||
|
||||
export function thumbnailSource(url: string, filename: string) {
|
||||
let contentType = ''
|
||||
if (SVG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/svg+xml'
|
||||
} else if (PNG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/png'
|
||||
}
|
||||
return { url, content_type: contentType, sha256: '' }
|
||||
}
|
||||
|
||||
export function purgedToast(result: { merged: number; articles_updated: number }) {
|
||||
return {
|
||||
id: 'media-purged',
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Duplicates purged')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} merged. {1} updated.', [
|
||||
pluralCopies(result.merged),
|
||||
pluralArticles(result.articles_updated),
|
||||
])
|
||||
},
|
||||
duration: PURGE_TOAST_DURATION_MS,
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'virtual:lucide-subset' {
|
||||
import type { IconifyJSON } from '@iconify/types'
|
||||
const subset: IconifyJSON
|
||||
export default subset
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ui from '@nuxt/ui/vue-plugin'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import '@/assets/main.css'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
@@ -21,6 +21,7 @@ import MediaTable from '@/components/MediaTable.vue'
|
||||
import MediaToolbar from '@/components/MediaToolbar.vue'
|
||||
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
|
||||
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useMediaSelection } from '@/composables/useMediaSelection'
|
||||
import { useMediaUpload } from '@/composables/useMediaUpload'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { joinNames } from '@/lib/mediaFormat'
|
||||
@@ -41,7 +43,9 @@ import {
|
||||
toRouteQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const TOAST_DURATION_MS = 5000
|
||||
@@ -71,6 +75,7 @@ const OFFLINE_COPY =
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -91,12 +96,14 @@ const deleteOpen = ref(false)
|
||||
const bulkDeleteOpen = ref(false)
|
||||
const bulkTargets = ref<MediaItem[]>([])
|
||||
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
|
||||
const purgeOpen = ref(false)
|
||||
const dragDepth = ref(0)
|
||||
const announcementSource = ref<() => string>(() => '')
|
||||
const announcement = computed(() => announcementSource.value())
|
||||
const usage = ref<MediaUsageItem[]>([])
|
||||
const copies = ref<MediaItem[]>([])
|
||||
let opener: HTMLElement | null = null
|
||||
let purgeChangedList = false
|
||||
let skipFocusRestore = false
|
||||
let pendingSelectId: number | null = null
|
||||
let bulkDeletedTotal = 0
|
||||
@@ -138,6 +145,12 @@ const filterLabel = computed(
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const canPurge = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const showPurge = computed(
|
||||
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
@@ -150,6 +163,7 @@ const dialogOpen = computed(
|
||||
renameOpen.value ||
|
||||
deleteOpen.value ||
|
||||
bulkDeleteOpen.value ||
|
||||
purgeOpen.value ||
|
||||
mergeTarget.value !== null,
|
||||
)
|
||||
const hasSelection = computed(() => selection.count.value > 0)
|
||||
@@ -724,6 +738,33 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
|
||||
mergeTarget.value = { group, keep }
|
||||
}
|
||||
|
||||
function openPurge(event?: Event) {
|
||||
if (offline.value) {
|
||||
return
|
||||
}
|
||||
opener = (event?.currentTarget as HTMLElement | null) ?? null
|
||||
purgeChangedList = false
|
||||
purgeOpen.value = true
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function onPurgePartial() {
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function closePurge() {
|
||||
purgeOpen.value = false
|
||||
if (purgeChangedList) {
|
||||
purgeChangedList = false
|
||||
refreshInBackground()
|
||||
}
|
||||
restoreFocusToOpener(null)
|
||||
}
|
||||
|
||||
function mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
|
||||
const articlesText =
|
||||
@@ -873,10 +914,13 @@ async function mergeAction() {
|
||||
<MediaToolbar
|
||||
:controls="controls"
|
||||
:counts="counts"
|
||||
:show-purge="showPurge"
|
||||
:purge-disabled="offline"
|
||||
@update:q="updateControls({ q: $event })"
|
||||
@update:filter="updateControls({ filter: $event })"
|
||||
@update:sort="updateControls({ sort: $event })"
|
||||
@update:view="updateControls({ view: $event })"
|
||||
@purge="openPurge"
|
||||
/>
|
||||
|
||||
<MediaUploadPanel
|
||||
@@ -1022,6 +1066,14 @@ async function mergeAction() {
|
||||
@close="bulkDeleteOpen = false"
|
||||
/>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="onPurgePartial"
|
||||
@review="purgeOpen = false"
|
||||
/>
|
||||
|
||||
<MergeMediaDialog
|
||||
v-if="mergeTarget"
|
||||
:keep="mergeTarget.keep"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
@@ -19,6 +20,8 @@ import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
const RESTART_FAILED_COPY =
|
||||
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
|
||||
const RESTART_WARNING =
|
||||
@@ -372,7 +375,11 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell
|
||||
bleed-offline-banner
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<RestartOverlay
|
||||
v-if="update.restartRun"
|
||||
:kind="update.restartRun.kind"
|
||||
@@ -449,6 +456,8 @@ onBeforeUnmount(() => {
|
||||
@update="onUpdate"
|
||||
@restart="onRestart"
|
||||
/>
|
||||
|
||||
<MediaCleanupCard />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
callsTo,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
settle,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DUPLICATES = '/media?filter=duplicates'
|
||||
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
|
||||
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
function purgeButton() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
|
||||
}
|
||||
|
||||
async function openFromMedia(handlers: Handlers = {}) {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
|
||||
purgeButton()!.focus()
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates toolbar button', () => {
|
||||
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
|
||||
|
||||
expect(purgeButton()).not.toBeNull()
|
||||
expect(textOf(purgeButton())).toBe('Purge duplicates')
|
||||
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the filter is All', '/media', 'owner', duplicatesList()],
|
||||
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
|
||||
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
|
||||
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
|
||||
])('is hidden when %s', async (_name, url, role, list) => {
|
||||
await mountPage({ list }, url, undefined, { role })
|
||||
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton()!.disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain("You're offline.")
|
||||
expect(dialog()).toBeNull()
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
|
||||
|
||||
const sort = document.body.querySelector('[aria-label="Sort"]')!
|
||||
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
|
||||
const button = purgeButton()!
|
||||
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.className).toContain('h-11')
|
||||
expect(button.className).toContain('w-full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The purge dialog from Media', () => {
|
||||
it('opens in the loading state and sends the preview request', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openFromMedia({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
|
||||
expect(textOf(dialog())).toContain('Purge duplicates?')
|
||||
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('confirms with the preview fingerprint and shows the same summary', async () => {
|
||||
await openFromMedia()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'4 groups · 9 copies will be deleted · 12 articles will be updated',
|
||||
)
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
|
||||
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
|
||||
})
|
||||
|
||||
it('shows the error notice and keeps the dialog open on a failure', async () => {
|
||||
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
|
||||
})
|
||||
|
||||
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
|
||||
let purged = false
|
||||
await openFromMedia({
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
|
||||
all: 12,
|
||||
unused: 2,
|
||||
duplicates: purged ? 0 : 2,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
purge: () => {
|
||||
purged = true
|
||||
return purgeResult()
|
||||
},
|
||||
})
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('reloads the list when Close follows a partial result', async () => {
|
||||
await openFromMedia({
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Close', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
})
|
||||
|
||||
it('only closes the dialog when Review in Duplicates is followed', async () => {
|
||||
await openFromMedia()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
|
||||
dialog()!.querySelector('a')!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Merge into this one stays', () => {
|
||||
it('still sends the merge request with the ids', async () => {
|
||||
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
|
||||
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
|
||||
|
||||
const first = findButton('Merge into this one')!
|
||||
first.click()
|
||||
await flushPromises()
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,529 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import {
|
||||
BUSY_COPY,
|
||||
FAILED_COPY,
|
||||
PREVIEW_PATH,
|
||||
PURGE_PATH,
|
||||
SETTINGS_PATH,
|
||||
STALE_COPY,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
deferredValue,
|
||||
dialogEl,
|
||||
emptyPreview,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
groupRows,
|
||||
isBefore,
|
||||
lastLine,
|
||||
manyPreview,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
openDialog,
|
||||
previewOf,
|
||||
pressEscape,
|
||||
purgeButton,
|
||||
purgeResult,
|
||||
singlePreview,
|
||||
textOf,
|
||||
unmountSettings,
|
||||
type PurgeMocks,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
|
||||
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
|
||||
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
|
||||
|
||||
async function opened(mocks: PurgeMocks = {}) {
|
||||
await mountSettings({ settings: () => enabledSettings(), ...mocks })
|
||||
return openDialog()
|
||||
}
|
||||
|
||||
function buttonLabels() {
|
||||
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
|
||||
}
|
||||
|
||||
function confirmButton(label: string) {
|
||||
return findButton(label, dialogEl()!)!
|
||||
}
|
||||
|
||||
function noticeText() {
|
||||
return textOf(dialogEl()!.querySelector('[role="alert"]'))
|
||||
}
|
||||
|
||||
function afterRefresh(settings = enabledSettings()) {
|
||||
let reads = 0
|
||||
return () => (reads++ === 0 ? enabledSettings() : settings)
|
||||
}
|
||||
|
||||
function purgedSettings(overrides = {}) {
|
||||
return enabledSettings({
|
||||
last_purge_at: '2026-10-05T09:00:00Z',
|
||||
last_purge_merged: 9,
|
||||
last_purge_articles_updated: 12,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Purge dialog: opening', () => {
|
||||
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Purge duplicates?')
|
||||
expect(textOf(box)).toContain(DESCRIPTION)
|
||||
expect(document.activeElement).toBe(findButton('Cancel', box))
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
|
||||
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
|
||||
})
|
||||
|
||||
it('is not openable while offline', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: the preview', () => {
|
||||
it('shows the summary, the rows, the link and both buttons', async () => {
|
||||
await opened()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
const rows = groupRows()
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
|
||||
),
|
||||
).toEqual([
|
||||
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
|
||||
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
|
||||
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
|
||||
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
|
||||
])
|
||||
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
|
||||
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
|
||||
const link = box.querySelector('a')!
|
||||
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
|
||||
expect(textOf(link)).toBe('Review in Duplicates')
|
||||
expect(link.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
|
||||
[
|
||||
singlePreview(2, 1, 2),
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 2 articles',
|
||||
'1 copy merged',
|
||||
],
|
||||
[
|
||||
singlePreview(1, 1, 1),
|
||||
'1 group · 1 copy will be deleted · 1 article will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 1 article',
|
||||
'1 copy merged',
|
||||
],
|
||||
])(
|
||||
'pluralises the summary, the confirm and the rows',
|
||||
async (preview, summary, confirm, kept, merged) => {
|
||||
await opened({ preview: () => preview })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
|
||||
expect(buttonLabels()).toEqual(['Cancel', confirm])
|
||||
const row = textOf(groupRows()[0])
|
||||
expect(row).toContain(kept)
|
||||
expect(row).toContain(merged)
|
||||
},
|
||||
)
|
||||
|
||||
it('stops at 5 rows and says how many groups are left', async () => {
|
||||
await opened({ preview: manyPreview })
|
||||
|
||||
expect(groupRows()).toHaveLength(5)
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 3 more groups',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
|
||||
})
|
||||
|
||||
it('says "and 1 more group" for one', async () => {
|
||||
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 1 more group',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Nothing to purge with a single Close button', async () => {
|
||||
await opened({ preview: emptyPreview })
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Nothing to purge')
|
||||
expect(textOf(box)).toContain(
|
||||
'No duplicate is used in an article, so there is nothing to merge.',
|
||||
)
|
||||
expect(box.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
expect(box.querySelector('a')).toBeNull()
|
||||
|
||||
findButton('Close', box)!.click()
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
|
||||
await opened({ preview })
|
||||
|
||||
expect(noticeText()).toBe(FAILED_COPY)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
|
||||
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
confirmButton('Purge duplicates').click()
|
||||
await flushPromises()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: leaving', () => {
|
||||
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
findButton('Cancel', dialogEl()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
pressEscape(dialogEl())
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with a click on the overlay and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
const overlay = document.body.querySelector('[data-slot="overlay"]')!
|
||||
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('follows the review link to the Duplicates tab', async () => {
|
||||
await opened()
|
||||
|
||||
dialogEl()!.querySelector('a')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: purging', () => {
|
||||
it('sends exactly the fingerprint of the preview, once', async () => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
|
||||
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
|
||||
})
|
||||
|
||||
it('cannot be dismissed while the request runs', async () => {
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ purge: () => pending.promise })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const confirm = confirmButton('Purging…')
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
|
||||
pressEscape(dialogEl())
|
||||
document.body
|
||||
.querySelector('[data-slot="overlay"]')
|
||||
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
|
||||
pending.resolve(purgeResult())
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
|
||||
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('shows no Next purge when automatic purge is off', async () => {
|
||||
await opened({
|
||||
settings: afterRefresh(
|
||||
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(nextLine()).toBeNull()
|
||||
})
|
||||
|
||||
it('pluralises the toast', async () => {
|
||||
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits purged with the counts and then closes', async () => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn(async (path: string) =>
|
||||
path === PREVIEW_PATH ? previewOf() : purgeResult(),
|
||||
),
|
||||
})
|
||||
const wrapper = mount(PurgeDuplicatesDialog, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'keeps the dialog open after %s and retries with the same fingerprint',
|
||||
async (_name, purge) => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
const alert = box.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toBe(FAILED_COPY)
|
||||
expect(alert.querySelector('svg')).not.toBeNull()
|
||||
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
expect(groupRows()).toHaveLength(4)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
|
||||
},
|
||||
)
|
||||
|
||||
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
settings: afterRefresh(purgedSettings()),
|
||||
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
|
||||
purge: () =>
|
||||
purgeResult({
|
||||
groups_total: 4,
|
||||
groups_purged: 3,
|
||||
merged: 8,
|
||||
articles_updated: 10,
|
||||
failed_groups: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(
|
||||
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
|
||||
)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Close', 'Retry'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
|
||||
confirmButton('Retry').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
|
||||
})
|
||||
|
||||
it('shows Nothing to purge when a partial result leaves no group', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(dialogEl())).toContain('Nothing to purge')
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
})
|
||||
|
||||
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
const refreshed = previewOf({
|
||||
groups: 3,
|
||||
copies: 7,
|
||||
articles: 10,
|
||||
list: previewOf().list.slice(0, 3),
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
|
||||
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(STALE_COPY)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'3 groups · 7 copies will be deleted · 10 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 3 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
|
||||
})
|
||||
|
||||
it('shows the server text when a purge is already running', async () => {
|
||||
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(noticeText()).toBe(BUSY_COPY)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
INVALID_DAYS_COPY,
|
||||
OFFLINE_COPY,
|
||||
PREVIEW_PATH,
|
||||
SAVE_FAILED_COPY,
|
||||
SETTINGS_PATH,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
cardEl,
|
||||
commitByBlur,
|
||||
commitByEnter,
|
||||
daysError,
|
||||
daysInput,
|
||||
deferredValue,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
lastLine,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
purgeButton,
|
||||
purgeKit,
|
||||
purgeSettings,
|
||||
saveStatus,
|
||||
switchEl,
|
||||
switchError,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountSettings,
|
||||
type SettingsCall,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
|
||||
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
|
||||
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
|
||||
}
|
||||
|
||||
function savedInterval(call: SettingsCall) {
|
||||
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
|
||||
return enabledSettings({ auto_purge_interval_days: days })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Media cleanup card: default and on', () => {
|
||||
it('Default: off, never run', async () => {
|
||||
await mountSettings({ settings: () => purgeSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
|
||||
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
|
||||
expect(textOf(card)).toContain(DESCRIPTION)
|
||||
expect(textOf(card)).toContain('Automatic purge')
|
||||
expect(textOf(card)).toContain('Runs in the background. Off by default.')
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Every')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(card.querySelector('[role="separator"]')).not.toBeNull()
|
||||
expect(textOf(card)).toContain('Purge now')
|
||||
expect(textOf(card)).toContain(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
expect(findButton('Save', card)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('On: 30 days, last run', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(card)).toContain('Every')
|
||||
expect(textOf(card)).toContain('Between 1 and 365 days.')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(card)).toContain('days')
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 5, 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'],
|
||||
[1, 1, 'Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated'],
|
||||
[0, 0, 'Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated'],
|
||||
])(
|
||||
'pluralises the Last purge line for %i merged and %i articles',
|
||||
async (merged, articles, line) => {
|
||||
await mountSettings({
|
||||
settings: () =>
|
||||
enabledSettings({
|
||||
last_purge_merged: merged,
|
||||
last_purge_articles_updated: articles,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(lastLine()).toBe(line)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the switch', () => {
|
||||
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const patch = deferredValue<unknown>()
|
||||
await mountSettings({
|
||||
settings: patching(
|
||||
() => patch.promise,
|
||||
() => purgeSettings(),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(call![0]).toBe(SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
|
||||
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
|
||||
expect(saveStatus()).toBe('Saving…')
|
||||
expect(
|
||||
cardEl()
|
||||
.querySelector('[data-testid="cleanup-save-status"]')!
|
||||
.getAttribute('aria-live'),
|
||||
).toBe('polite')
|
||||
expect(switchEl().disabled).toBe(false)
|
||||
|
||||
patch.resolve(
|
||||
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
|
||||
await mountSettings({
|
||||
settings: patching(() =>
|
||||
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the days field', () => {
|
||||
it.each([
|
||||
['45', 'blur', 45],
|
||||
['45', 'Enter', 45],
|
||||
['1', 'Enter', 1],
|
||||
['365', 'blur', 365],
|
||||
])('saves %s once when it %s commits', async (typed, commit, sent) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
|
||||
|
||||
const calls = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it('sends one request when Enter is followed by the blur', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends nothing for an unchanged value', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
input.focus()
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it.each(['', '0', '366', '400', '1.5'])(
|
||||
'rejects "%s" without sending anything and keeps the text',
|
||||
async (typed) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(input.value).toBe(typed)
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
expect(daysError()!.querySelector('svg')).not.toBeNull()
|
||||
expect(saveStatus()).toBe('')
|
||||
},
|
||||
)
|
||||
|
||||
it('also validates on Enter', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
|
||||
await typeInto(daysInput()!, '366')
|
||||
await commitByEnter(daysInput()!)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
})
|
||||
|
||||
it('clears the validation error once the value is valid and sends it', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '400')
|
||||
await commitByBlur(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
await typeInto(input, '60')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(input.getAttribute('aria-invalid')).toBeNull()
|
||||
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
|
||||
auto_purge_interval_days: 60,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: save failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
|
||||
await mountSettings({ settings: patching(failure) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(input.value).toBe('30')
|
||||
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('removes the message on the next successful save', async () => {
|
||||
let failing = true
|
||||
await mountSettings({
|
||||
settings: patching((call) =>
|
||||
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
|
||||
),
|
||||
})
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
})
|
||||
|
||||
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
|
||||
const refresh = vi.spyOn(useSessionStore(), 'refresh')
|
||||
purgeKit.meRole = 'editor'
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
expect(switchError()).toBeNull()
|
||||
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: read-only', () => {
|
||||
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(textOf(card.querySelector('[role="status"]'))).toBe(
|
||||
'Only owners and admins can change this.',
|
||||
)
|
||||
expect(card.querySelector('[role="separator"]')).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Purge now')
|
||||
expect(findButton('Purge duplicates', card)).toBeUndefined()
|
||||
|
||||
switchEl().click()
|
||||
await commitByBlur(daysInput()!)
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats a missing role the same way', async () => {
|
||||
await mountSettings({}, { role: undefined })
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: loading and failure to load', () => {
|
||||
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await mountSettings({ settings: () => pending.promise })
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(cardEl().getAttribute('aria-busy')).toBe('true')
|
||||
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
pending.resolve(purgeSettings())
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
|
||||
let failing = true
|
||||
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
|
||||
|
||||
const alert = cardEl().querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
|
||||
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
findButton('Try again', cardEl())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: offline and 401', () => {
|
||||
it('shows the banner and disables the controls, sending nothing', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
expect(document.body.textContent).toContain(OFFLINE_COPY)
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
switchEl().click()
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('goes to the login page on a 401 and shows no copy of its own', async () => {
|
||||
await mountSettings(
|
||||
{ settings: () => Promise.reject(new ApiError(401)) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
redirect: '/settings',
|
||||
reason: 'expired',
|
||||
})
|
||||
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no save failure for a 401 on a change', async () => {
|
||||
await mountSettings(
|
||||
{ settings: patching(() => Promise.reject(new ApiError(401))) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
updateSucceeded,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
|
||||
}
|
||||
const handler = handlers[path]
|
||||
if (!handler) {
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
}
|
||||
return handler()
|
||||
@@ -262,6 +267,9 @@ function silenceStatusReads() {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.reject(new NetworkError())
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
}
|
||||
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
|
||||
if (path === STATUS_PATH) {
|
||||
return answers()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
siteUp = healthy
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
startable,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === DOWNLOAD_PATH) {
|
||||
return downloadHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === CHECK_PATH) {
|
||||
return checkHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
|
||||
if (path === SETTINGS_PATH) {
|
||||
return settings()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -236,6 +236,8 @@ export interface Handlers {
|
||||
hash?: (path: string) => unknown
|
||||
remove?: (id: number) => unknown
|
||||
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export function libraryHandlers(
|
||||
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?')) {
|
||||
return (handlers.list ?? (() => listBody(fixture())))(path)
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/media/purge-preview') {
|
||||
return (handlers.preview ?? (() => undefined))()
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/media/purge') {
|
||||
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
|
||||
if (method === 'GET' && usage) {
|
||||
return (handlers.usage ?? (() => []))(Number(usage[1]))
|
||||
@@ -316,12 +324,18 @@ export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean } = {},
|
||||
options: { realApi?: boolean; role?: string } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
if (options.realApi) {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
|
||||
export const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
export const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
export const PURGE_PATH = '/v1/media/purge'
|
||||
export const STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const BUSY_COPY = 'A purge is already running.'
|
||||
export const FAILED_COPY =
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
export const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
|
||||
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
|
||||
return {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
|
||||
return purgeSettings({
|
||||
auto_purge_enabled: true,
|
||||
last_purge_at: '2026-06-14T09:00:00Z',
|
||||
last_purge_merged: 3,
|
||||
last_purge_articles_updated: 5,
|
||||
enabled_at: '2026-06-14T09:00:00Z',
|
||||
next_purge_at: '2026-07-14T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
keep_id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
url: '/media/floorboards.jpg',
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
url: '/media/kitchen-after.webp',
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
url: '/media/lime-plaster.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
|
||||
return {
|
||||
groups: 4,
|
||||
copies: 9,
|
||||
articles: 12,
|
||||
list: GROUPS.slice(0, 4),
|
||||
more: 0,
|
||||
fingerprint: 'ab12',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyPreview() {
|
||||
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
|
||||
}
|
||||
|
||||
export function emptyPreview() {
|
||||
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
|
||||
}
|
||||
|
||||
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
|
||||
return previewOf({
|
||||
groups: 1,
|
||||
copies,
|
||||
articles,
|
||||
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
|
||||
fingerprint: 'one',
|
||||
})
|
||||
}
|
||||
|
||||
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
|
||||
return {
|
||||
groups_total: 4,
|
||||
groups_purged: 4,
|
||||
merged: 9,
|
||||
articles_updated: 12,
|
||||
failed_groups: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsCall {
|
||||
method: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export interface PurgeMocks {
|
||||
settings?: (call: SettingsCall) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export const purgeKit = {
|
||||
requestMock: vi.fn(),
|
||||
wrapper: null as VueWrapper | null,
|
||||
meRole: 'owner' as string | undefined,
|
||||
getMe: vi.fn(),
|
||||
}
|
||||
|
||||
export function installPurgeApi(mocks: PurgeMocks) {
|
||||
return (path: string, options?: RequestInit) => {
|
||||
const method = options?.method ?? 'GET'
|
||||
if (path === SETTINGS_PATH) {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : undefined
|
||||
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
|
||||
}
|
||||
if (path === PREVIEW_PATH && method === 'GET') {
|
||||
return (mocks.preview ?? (() => previewOf()))()
|
||||
}
|
||||
if (path === PURGE_PATH && method === 'POST') {
|
||||
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
|
||||
return vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const reply = (status: number, body?: unknown) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
})
|
||||
try {
|
||||
const body = await request(path, init)
|
||||
return reply(body === undefined ? 204 : 200, body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return reply(error.status)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function mountSettings(
|
||||
mocks: PurgeMocks = {},
|
||||
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
purgeKit.meRole = options.role
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.role as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = installPurgeApi(mocks)
|
||||
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return { version: 'v2.2.0-beta' }
|
||||
}
|
||||
if (path === '/v1/system/update') {
|
||||
return new Promise(() => undefined)
|
||||
}
|
||||
return dispatch(path, init)
|
||||
})
|
||||
purgeKit.getMe = vi.fn(async () => ({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: purgeKit.meRole,
|
||||
}))
|
||||
if (options.realApi) {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
|
||||
vi.mocked(useApi).mockImplementation(actual.useApi)
|
||||
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
|
||||
} else {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: purgeKit.getMe,
|
||||
request: purgeKit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push('/settings')
|
||||
purgeKit.wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return purgeKit.wrapper
|
||||
}
|
||||
|
||||
export async function unmountSettings() {
|
||||
purgeKit.wrapper?.unmount()
|
||||
purgeKit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function cardEl() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function findButton(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function purgeButton() {
|
||||
return findButton('Purge duplicates', cardEl())!
|
||||
}
|
||||
|
||||
export function switchEl() {
|
||||
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
|
||||
}
|
||||
|
||||
export function daysInput() {
|
||||
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
|
||||
}
|
||||
|
||||
export function daysError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
|
||||
}
|
||||
|
||||
export function switchError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
|
||||
}
|
||||
|
||||
export function saveStatus() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
|
||||
}
|
||||
|
||||
export function lastLine() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
|
||||
}
|
||||
|
||||
export function nextLine() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-next"]')
|
||||
}
|
||||
|
||||
export function dialogEl() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function callsTo(method: string, path: string) {
|
||||
return purgeKit.requestMock.mock.calls.filter(
|
||||
([called, options]) => called === path && (options?.method ?? 'GET') === method,
|
||||
)
|
||||
}
|
||||
|
||||
export function bodyOf(call: unknown[]) {
|
||||
return JSON.parse(String((call[1] as RequestInit).body))
|
||||
}
|
||||
|
||||
export async function typeInto(input: HTMLInputElement, value: string) {
|
||||
input.focus()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByBlur(input: HTMLInputElement) {
|
||||
input.blur()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByEnter(input: HTMLInputElement) {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function goOffline() {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function pressEscape(target: Element | null) {
|
||||
;(target ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function openDialog() {
|
||||
const opener = purgeButton()
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
return opener
|
||||
}
|
||||
|
||||
export function isBefore(first: Element, second: Element) {
|
||||
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
|
||||
}
|
||||
|
||||
export function groupRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
|
||||
}
|
||||
|
||||
export function deferredValue<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
|
||||
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
|
||||
export const DEFAULT_PURGE_SETTINGS = {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vite/**/*.ts",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
|
||||
+164
-69
@@ -11,11 +11,80 @@ import { visualStates, type ApiMock, type VisualState } from './states'
|
||||
type Viewport = 'desktop' | 'mobile'
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface Source {
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
alias: Record<string, string>
|
||||
}
|
||||
|
||||
interface Screen {
|
||||
name: string
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
sources: Source[]
|
||||
}
|
||||
|
||||
const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
settings: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Settings',
|
||||
states: [
|
||||
'Default: off, never run',
|
||||
'On: 30 days, last run',
|
||||
'Saving',
|
||||
'Saved',
|
||||
'Save failed',
|
||||
'Validation error',
|
||||
'Read-only (non-admin)',
|
||||
'Loading skeleton',
|
||||
'Offline',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Dialog',
|
||||
states: [
|
||||
'Loading preview',
|
||||
'Confirm',
|
||||
'Confirm: many groups',
|
||||
'In progress',
|
||||
'Nothing to purge',
|
||||
'Error',
|
||||
'Partial failure',
|
||||
'Conflict (409)',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Results',
|
||||
states: ['Success', 'Success, automatic purge on'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
media: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Media',
|
||||
states: [
|
||||
'Duplicates tab with the button',
|
||||
'Dialog from Media',
|
||||
'Member: button hidden',
|
||||
'Offline: button disabled',
|
||||
],
|
||||
alias: { 'Offline: button disabled': 'Offline' },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const boardAlias: Record<string, Record<string, string>> = {
|
||||
media: { 'Duplicates: purge all': 'Purge duplicates: confirm' },
|
||||
}
|
||||
|
||||
const webRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -95,25 +164,42 @@ async function readScreen(name: string): Promise<Screen> {
|
||||
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
||||
}
|
||||
const statesStart = markdown.search(/^## States$/m)
|
||||
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||
const nextSection = afterStates.slice(1).search(/^## /m)
|
||||
const statesMarkdown = nextSection === -1 ? afterStates : afterStates.slice(0, nextSection + 1)
|
||||
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
|
||||
match[1]!.trim(),
|
||||
)
|
||||
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
|
||||
return { name, board: reference[1]!, group: reference[2]!, states }
|
||||
const primary: Source = {
|
||||
board: reference[1]!,
|
||||
group: reference[2]!,
|
||||
states,
|
||||
alias: boardAlias[name] ?? {},
|
||||
}
|
||||
return {
|
||||
name,
|
||||
board: primary.board,
|
||||
group: primary.group,
|
||||
states,
|
||||
sources: [primary, ...(extraSources[name] ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
function pickStates(screen: Screen, wanted?: string) {
|
||||
function pickSources(screen: Screen, wanted?: string) {
|
||||
if (!wanted) {
|
||||
return screen.states
|
||||
return screen.sources
|
||||
}
|
||||
const match = screen.states.find(
|
||||
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
|
||||
const matches = (state: string) =>
|
||||
state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted)
|
||||
const picked = screen.sources
|
||||
.map((source) => ({ ...source, states: source.states.filter(matches) }))
|
||||
.filter((source) => source.states.length > 0)
|
||||
if (picked.length === 0) {
|
||||
const known = screen.sources.flatMap((source) => source.states)
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${known.join(', ')}`)
|
||||
}
|
||||
return [match]
|
||||
return picked
|
||||
}
|
||||
|
||||
async function serveDesign() {
|
||||
@@ -444,7 +530,7 @@ async function diff(browser: Browser, designPath: string, appPath: string, diffP
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const screen = await readScreen(args.screen)
|
||||
const states = pickStates(screen, args.state)
|
||||
const sources = pickSources(screen, args.state)
|
||||
const setups = visualStates[screen.name] ?? {}
|
||||
|
||||
const design = await serveDesign()
|
||||
@@ -458,67 +544,76 @@ async function main() {
|
||||
try {
|
||||
for (const viewport of args.viewports) {
|
||||
for (const theme of args.themes) {
|
||||
const board = await openBoard(browser, design.origin, screen, theme)
|
||||
for (const state of states) {
|
||||
const skip = setups[state]?.skip
|
||||
if (skip === 'pending') {
|
||||
pending++
|
||||
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
|
||||
continue
|
||||
}
|
||||
if (skip) {
|
||||
skipped++
|
||||
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
|
||||
continue
|
||||
}
|
||||
const directory = join(outRoot, screen.name, slug(state))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const suffix = `${viewport}-${theme}.png`
|
||||
const designPath = join(directory, `design-${suffix}`)
|
||||
await captureDesign(board, screen, state, viewport, designPath)
|
||||
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
failures++
|
||||
lines.push(
|
||||
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
for (const source of sources) {
|
||||
const sourceScreen = { ...screen, board: source.board, group: source.group }
|
||||
const board = await openBoard(browser, design.origin, sourceScreen, theme)
|
||||
for (const state of source.states) {
|
||||
const skip = setups[state]?.skip
|
||||
if (skip === 'pending') {
|
||||
pending++
|
||||
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
|
||||
continue
|
||||
}
|
||||
if (skip) {
|
||||
skipped++
|
||||
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
|
||||
continue
|
||||
}
|
||||
const directory = join(outRoot, screen.name, slug(state))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const suffix = `${viewport}-${theme}.png`
|
||||
const designPath = join(directory, `design-${suffix}`)
|
||||
await captureDesign(
|
||||
board,
|
||||
sourceScreen,
|
||||
source.alias[state] ?? state,
|
||||
viewport,
|
||||
designPath,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const appPath = join(directory, `app-${suffix}`)
|
||||
const diffPath = join(directory, `diff-${suffix}`)
|
||||
const unmocked = await captureApp(
|
||||
browser,
|
||||
app.origin,
|
||||
setup,
|
||||
viewport,
|
||||
theme,
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const percent = result.ratio * 100
|
||||
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||
const problems = [
|
||||
...(percent > budget
|
||||
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||
: []),
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
if (problems.length) {
|
||||
failures++
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
failures++
|
||||
lines.push(
|
||||
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const appPath = join(directory, `app-${suffix}`)
|
||||
const diffPath = join(directory, `diff-${suffix}`)
|
||||
const unmocked = await captureApp(
|
||||
browser,
|
||||
app.origin,
|
||||
setup,
|
||||
viewport,
|
||||
theme,
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const percent = result.ratio * 100
|
||||
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||
const problems = [
|
||||
...(percent > budget
|
||||
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||
: []),
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
if (problems.length) {
|
||||
failures++
|
||||
}
|
||||
const exception = setup.exception
|
||||
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||
: ''
|
||||
lines.push(
|
||||
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||
)
|
||||
}
|
||||
const exception = setup.exception
|
||||
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||
: ''
|
||||
lines.push(
|
||||
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||
)
|
||||
await board.context().close()
|
||||
}
|
||||
await board.context().close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -535,7 +630,7 @@ async function main() {
|
||||
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
|
||||
const report = [
|
||||
`# Visual check: ${screen.name}`,
|
||||
`Board: ${screen.board}, group "${screen.group}"`,
|
||||
`Boards: ${sources.map((source) => `${source.board} / ${source.group}`).join('; ')}`,
|
||||
'',
|
||||
verdict,
|
||||
'',
|
||||
|
||||
+356
-1
@@ -2246,6 +2246,336 @@ const apiKeysStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const purgeSettingsPath = 'GET /v1/media/purge-settings'
|
||||
const purgePatchPath = 'PATCH /v1/media/purge-settings'
|
||||
const purgePreviewPath = 'GET /v1/media/purge-preview'
|
||||
const purgeRunPath = 'POST /v1/media/purge'
|
||||
const purgeStaleCopy =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
|
||||
function purgeSettingsBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const purgeLastRun = {
|
||||
last_purge_at: '2026-06-14T09:00:00Z',
|
||||
last_purge_merged: 3,
|
||||
last_purge_articles_updated: 5,
|
||||
}
|
||||
|
||||
const purgeOnBody = purgeSettingsBody({
|
||||
...purgeLastRun,
|
||||
auto_purge_enabled: true,
|
||||
enabled_at: '2026-06-14T09:00:00Z',
|
||||
next_purge_at: '2026-07-14T09:00:00Z',
|
||||
})
|
||||
|
||||
const purgeDoneLast = {
|
||||
last_purge_at: '2026-10-05T09:00:00Z',
|
||||
last_purge_merged: 9,
|
||||
last_purge_articles_updated: 12,
|
||||
}
|
||||
|
||||
const purgeGroups = [
|
||||
{
|
||||
keep_id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
url: '/media/floorboards.jpg',
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
url: '/media/kitchen-after.webp',
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
url: '/media/lime-plaster.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
]
|
||||
|
||||
function purgePreviewBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
groups: 4,
|
||||
copies: 9,
|
||||
articles: 12,
|
||||
list: purgeGroups.slice(0, 4),
|
||||
more: 0,
|
||||
fingerprint: 'ab12',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const purgePreviewMany = purgePreviewBody({
|
||||
groups: 8,
|
||||
copies: 15,
|
||||
articles: 24,
|
||||
list: purgeGroups,
|
||||
more: 3,
|
||||
})
|
||||
const purgePreviewOne = purgePreviewBody({
|
||||
groups: 1,
|
||||
copies: 1,
|
||||
articles: 2,
|
||||
list: [purgeGroups[1]],
|
||||
fingerprint: 'one',
|
||||
})
|
||||
const purgePreviewThree = purgePreviewBody({
|
||||
groups: 3,
|
||||
copies: 7,
|
||||
articles: 10,
|
||||
list: [purgeGroups[0], purgeGroups[2], purgeGroups[3]],
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
const purgePreviewNone = purgePreviewBody({
|
||||
groups: 0,
|
||||
copies: 0,
|
||||
articles: 0,
|
||||
list: [],
|
||||
fingerprint: 'none',
|
||||
})
|
||||
|
||||
const purgeRunPartial = {
|
||||
groups_total: 4,
|
||||
groups_purged: 3,
|
||||
merged: 8,
|
||||
articles_updated: 10,
|
||||
failed_groups: 1,
|
||||
}
|
||||
const purgeRunDone = {
|
||||
groups_total: 4,
|
||||
groups_purged: 4,
|
||||
merged: 9,
|
||||
articles_updated: 12,
|
||||
failed_groups: 0,
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string): ApiMock {
|
||||
return { status: 200, body: { ...(verifiedSession.body as object), role } }
|
||||
}
|
||||
|
||||
function cleanupApi(
|
||||
settings: ApiMock,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
role = 'owner',
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': sessionWithRole(role),
|
||||
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
|
||||
'GET /v1/system/update': { status: 200, hang: true },
|
||||
[purgeSettingsPath]: settings,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function settingsBody(body: unknown): ApiMock {
|
||||
return { status: 200, body }
|
||||
}
|
||||
|
||||
function cleanupState(
|
||||
api: Record<string, ApiMock>,
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
): VisualState {
|
||||
return { path: '/settings', api, steps }
|
||||
}
|
||||
|
||||
async function openCleanupDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function confirmCleanupDialog(label: string, wait?: string) {
|
||||
return async (page: Page) => {
|
||||
await openCleanupDialog(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: label, exact: true })
|
||||
.click()
|
||||
if (wait) {
|
||||
await page.getByText(wait, { exact: true }).first().waitFor()
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function typeDays(page: Page, value: string) {
|
||||
const field = page.locator('#auto-purge-days')
|
||||
await field.fill(value)
|
||||
await field.press('Enter')
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function afterFirst(first: unknown, later: unknown): ApiMock {
|
||||
return { status: 200, bodyFor: (_url, call) => (call === 1 ? first : later) }
|
||||
}
|
||||
|
||||
function cleanupDialogApi(overrides: Record<string, ApiMock> = {}) {
|
||||
return cleanupApi(settingsBody(purgeSettingsBody()), {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
async function holdToast(page: Page) {
|
||||
await page.getByText('Duplicates purged', { exact: true }).hover()
|
||||
}
|
||||
|
||||
function successState(settings: ApiMock) {
|
||||
return cleanupState(
|
||||
cleanupApi(settings, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
[purgeRunPath]: settingsBody(purgeRunDone),
|
||||
}),
|
||||
async (page) => {
|
||||
await confirmCleanupDialog('Purge 4 groups', 'Duplicates purged')(page)
|
||||
await holdToast(page)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const cleanupStates: Record<string, VisualState> = {
|
||||
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
|
||||
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
|
||||
Saving: cleanupState(
|
||||
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
|
||||
[purgePatchPath]: { status: 200, hang: true },
|
||||
}),
|
||||
async (page) => {
|
||||
await page.getByRole('switch').click()
|
||||
await page.locator('#auto-purge-days').focus()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
Saved: cleanupState(
|
||||
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
|
||||
[purgePatchPath]: settingsBody(purgeOnBody),
|
||||
}),
|
||||
async (page) => {
|
||||
await page.getByRole('switch').click()
|
||||
await page.getByText('Saved', { exact: true }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Save failed': cleanupState(
|
||||
cleanupApi(settingsBody(purgeOnBody), { [purgePatchPath]: { status: 500 } }),
|
||||
async (page) => {
|
||||
await typeDays(page, '45')
|
||||
await page.getByText("Couldn't save. Try again in a moment.").waitFor()
|
||||
},
|
||||
),
|
||||
'Validation error': cleanupState(cleanupApi(settingsBody(purgeOnBody)), async (page) => {
|
||||
await typeDays(page, '400')
|
||||
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
|
||||
}),
|
||||
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
|
||||
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
|
||||
'Loading preview': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
Confirm: cleanupState(cleanupDialogApi(), openCleanupDialog),
|
||||
'Confirm: many groups': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewMany } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
'In progress': cleanupState(
|
||||
cleanupDialogApi({ [purgeRunPath]: { status: 200, hang: true } }),
|
||||
confirmCleanupDialog('Purge 4 groups'),
|
||||
),
|
||||
'Nothing to purge': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewNone } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
Error: cleanupState(
|
||||
cleanupDialogApi({ [purgeRunPath]: { status: 500 } }),
|
||||
confirmCleanupDialog(
|
||||
'Purge 4 groups',
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.",
|
||||
),
|
||||
),
|
||||
'Partial failure': cleanupState(
|
||||
cleanupDialogApi({
|
||||
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewOne),
|
||||
[purgeRunPath]: settingsBody(purgeRunPartial),
|
||||
}),
|
||||
confirmCleanupDialog(
|
||||
'Purge 4 groups',
|
||||
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
|
||||
),
|
||||
),
|
||||
'Conflict (409)': cleanupState(
|
||||
cleanupDialogApi({
|
||||
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewThree),
|
||||
[purgeRunPath]: { status: 409, body: purgeStaleCopy },
|
||||
}),
|
||||
confirmCleanupDialog('Purge 4 groups', purgeStaleCopy),
|
||||
),
|
||||
Success: successState(afterFirst(purgeSettingsBody(), purgeSettingsBody(purgeDoneLast))),
|
||||
'Success, automatic purge on': successState(
|
||||
afterFirst(
|
||||
purgeOnBody,
|
||||
purgeSettingsBody({
|
||||
...purgeDoneLast,
|
||||
auto_purge_enabled: true,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
async function openMediaPurgeDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const mediaPurgeStates: Record<string, VisualState> = {
|
||||
'Duplicates: purge all': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates tab with the button': mediaState(
|
||||
mediaDuplicatesApi(),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Dialog from Media': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Member: button hidden': mediaState(
|
||||
mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2391,6 +2721,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||
},
|
||||
settings: {
|
||||
...cleanupStates,
|
||||
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Checking: {
|
||||
path: '/settings',
|
||||
@@ -3364,6 +3695,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
media: mediaStates,
|
||||
media: { ...mediaStates, ...mediaPurgeStates },
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
|
||||
const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }'
|
||||
|
||||
function withoutCleanupCard(state: VisualState): VisualState {
|
||||
if (state.skip) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } },
|
||||
steps: async (page) => {
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await state.steps?.(page)
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, state] of Object.entries(visualStates.settings!)) {
|
||||
if (!(name in cleanupStates)) {
|
||||
visualStates.settings![name] = withoutCleanupCard(state)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
import { renewUi } from './src/theme/ui.config'
|
||||
import { lucideSubset } from './vite/lucideSubset'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -21,6 +22,10 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
lucideSubset([
|
||||
fileURLToPath(new URL('./src', import.meta.url)),
|
||||
fileURLToPath(new URL('./node_modules/@nuxt/ui/dist', import.meta.url)),
|
||||
]),
|
||||
ui({
|
||||
ui: renewUi,
|
||||
colorMode: true,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { icons } from '@iconify-json/lucide'
|
||||
import { getIcons } from '@iconify/utils'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const virtualId = 'virtual:lucide-subset'
|
||||
const resolvedId = `\0${virtualId}`
|
||||
const iconPattern = /i-lucide-([a-z0-9]+(?:-[a-z0-9]+)*)/g
|
||||
const sourceExtensions = /\.(vue|ts|js|mjs)$/
|
||||
|
||||
function walk(dir: string, found: Set<string>) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry)
|
||||
const stat = statSync(full)
|
||||
if (stat.isDirectory()) {
|
||||
if (entry !== '__tests__') walk(full, found)
|
||||
continue
|
||||
}
|
||||
if (!sourceExtensions.test(entry)) continue
|
||||
for (const match of readFileSync(full, 'utf8').matchAll(iconPattern)) {
|
||||
found.add(match[1]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function lucideSubset(roots: string[]): Plugin {
|
||||
return {
|
||||
name: 'lucide-subset',
|
||||
resolveId(id) {
|
||||
return id === virtualId ? resolvedId : null
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedId) return null
|
||||
const found = new Set<string>()
|
||||
for (const root of roots) walk(root, found)
|
||||
const subset = getIcons(icons, [...found].sort())
|
||||
return `export default ${JSON.stringify(subset)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user