feat(API): media library

This commit is contained in:
fsylvain committed 2026-09-30 18:35:28 +02:00
1 parent 95ae043d3e
commit 0d99544579
65 files changed
+3992 -1530

No files matched your search

+18 -6
View File
@@ -45,7 +45,11 @@ type UseCases struct {
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
} }
@@ -63,7 +67,7 @@ func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
} }
func getDb(dbName string) *gorm.DB { func getDb(dbName string, uploadDir string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error()) panic("Unable to create necessary subdirectories: " + err.Error())
} }
@@ -72,7 +76,7 @@ func getDb(dbName string) *gorm.DB {
panic("Unable to open the database: " + err.Error()) panic("Unable to open the database: " + err.Error())
} }
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil { if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}); err != nil {
panic("Failed to migrate database: " + err.Error()) panic("Failed to migrate database: " + err.Error())
} }
@@ -80,6 +84,10 @@ func getDb(dbName string) *gorm.DB {
panic("Failed to backfill user roles: " + err.Error()) panic("Failed to backfill user roles: " + err.Error())
} }
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db return db
} }
@@ -102,7 +110,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
@@ -120,13 +128,17 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo), 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),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
} }
} }
func InitContainer(settings config.Settings) (*UseCases, func() error) { func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile) db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings) repos := getRepositories(db, settings)
return getUseCases(repos, settings), closeDb(db) return getUseCases(repos, settings), closeDb(db)
} }
+10 -57
View File
@@ -2,8 +2,9 @@ package image
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/upload"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -16,16 +17,12 @@ import (
type Handler struct { type Handler struct {
CreateUseCase *useCases.CreateImageUseCase CreateUseCase *useCases.CreateImageUseCase
GetUseCase *useCases.GetArticleUseCase GetUseCase *useCases.GetArticleUseCase
UpdateUseCase *useCases.UpdateArticleUseCase
DeleteUseCase *useCases.DeleteImageUseCase
} }
func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase, deleteUseCase *useCases.DeleteImageUseCase) *Handler { func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase) *Handler {
return &Handler{ return &Handler{
CreateUseCase: create, CreateUseCase: create,
GetUseCase: get, GetUseCase: get,
UpdateUseCase: update,
DeleteUseCase: deleteUseCase,
} }
} }
@@ -41,7 +38,7 @@ func writeArticleError(w http.ResponseWriter, err error) {
writeInternalServerError(w) writeInternalServerError(w)
} }
func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) { func (h *Handler) PostLegacyArticleImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil { if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
@@ -53,30 +50,16 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
return return
} }
file, fileHeader, err := r.FormFile("file") userId, _ := claims.UserId(r)
if err != nil { input, closeFile, ok := upload.ReadImageInput(w, r, userId)
http.Error(w, err.Error(), http.StatusBadRequest) if !ok {
return return
} }
defer file.Close() defer closeFile()
newImage, err := h.CreateUseCase.CreateImage(domainImage.ImageInput{ newImage, err := h.CreateUseCase.CreateImage(input)
Content: file,
Filename: fileHeader.Filename,
Size: fileHeader.Size,
ContentType: fileHeader.Header.Get("Content-Type"),
})
if err != nil { if err != nil {
if errors.Is(err, domainImage.ErrImageTypeNotAllowed) || errors.Is(err, domainImage.ErrImageTooLarge) { upload.WriteCreateError(w, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeInternalServerError(w)
return
}
if err := h.UpdateUseCase.AddImage(uint32(articleId), newImage.ID); err != nil {
writeArticleError(w, err)
return return
} }
@@ -89,33 +72,3 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_, _ = w.Write(responseJson) _, _ = w.Write(responseJson)
} }
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
imageId, err := strconv.ParseUint(chi.URLParam(r, "imageId"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil {
writeArticleError(w, err)
return
}
if err := h.DeleteUseCase.DeleteImage(uint32(imageId)); err != nil {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
+317
View File
@@ -0,0 +1,317 @@
package media
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/upload"
validator "RenewCMS/api/pkg/validator"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type Handler struct {
CreateUseCase *useCases.CreateImageUseCase
ListUseCase *useCases.ListMediaUseCase
RenameUseCase *useCases.RenameMediaUseCase
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
}
type RenameRequest struct {
Filename string `json:"filename" validate:"required"`
}
type MergeRequest struct {
KeepID uint32 `json:"keep_id" validate:"required,gt=0"`
MergeIDs []uint32 `json:"merge_ids" validate:"required,min=1,dive,gt=0"`
}
type mediaItemResponse struct {
ID uint32 `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Width *int `json:"width"`
Height *int `json:"height"`
SHA256 string `json:"sha256"`
UploadedBy string `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
UsedIn int `json:"used_in"`
Duplicate bool `json:"duplicate"`
}
type countsResponse struct {
All int `json:"all"`
Unused int `json:"unused"`
Duplicates int `json:"duplicates"`
}
type listResponse struct {
Items []mediaItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"`
Counts countsResponse `json:"counts"`
}
type usageResponse struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Published bool `json:"published"`
}
type mergeResponse struct {
ArticlesUpdated int `json:"articles_updated"`
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 {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
RenameUseCase: rename,
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
}
}
func writeInternalServerError(w http.ResponseWriter) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
writeInternalServerError(w)
return
}
w.WriteHeader(status)
_, _ = w.Write(body)
}
func writeImageError(w http.ResponseWriter, err error) {
if errors.Is(err, domainImage.ErrImageNotFound) {
http.Error(w, apperr.ImageNotFoundErrorMessage, http.StatusNotFound)
return
}
writeInternalServerError(w)
}
func parseImageId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return uint32(id), true
}
func dimension(value int) *int {
if value == 0 {
return nil
}
return &value
}
func toResponse(item useCases.MediaItem) mediaItemResponse {
return mediaItemResponse{
ID: item.Image.ID,
Filename: item.Image.Filename,
URL: item.Image.Path,
Size: item.Image.Size,
ContentType: item.Image.ContentType,
Width: dimension(item.Image.Width),
Height: dimension(item.Image.Height),
SHA256: item.Image.SHA256,
UploadedBy: item.UploadedBy,
CreatedAt: item.Image.CreatedAt,
UsedIn: item.UsedIn,
Duplicate: item.Duplicate,
}
}
func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
values, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query := domainImage.NewListQuery()
query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash"))
query.Cursor = values.Get("cursor")
if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter"))
}
if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort"))
}
if values.Has("limit") {
limit, err := strconv.Atoi(values.Get("limit"))
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Limit = limit
}
return query, nil
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := parseListQuery(r)
if err != nil {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.List(query)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidQuery) {
http.Error(w, apperr.MediaQueryErrorMessage, http.StatusBadRequest)
return
}
writeInternalServerError(w)
return
}
items := make([]mediaItemResponse, 0, len(page.Items))
for _, item := range page.Items {
items = append(items, toResponse(item))
}
response := listResponse{
Items: items,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
}
if page.NextCursor != "" {
response.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
userId, _ := claims.UserId(r)
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
if !ok {
return
}
defer closeFile()
created, err := h.CreateUseCase.CreateImage(input)
if err != nil {
upload.WriteCreateError(w, err)
return
}
h.writeItem(w, http.StatusCreated, created)
}
func (h *Handler) writeItem(w http.ResponseWriter, status int, img domainImage.Image) {
item, err := h.ListUseCase.Describe(img)
if err != nil {
writeInternalServerError(w)
return
}
writeJSON(w, status, toResponse(item))
}
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
var request RenameRequest
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.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
renamed, err := h.RenameUseCase.Rename(id, request.Filename)
if err != nil {
if errors.Is(err, domainImage.ErrInvalidFilename) {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
return
}
writeImageError(w, err)
return
}
h.writeItem(w, http.StatusOK, renamed)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(id); err != nil {
writeImageError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Usage(w http.ResponseWriter, r *http.Request) {
id, ok := parseImageId(w, r)
if !ok {
return
}
articles, err := h.UsageUseCase.Usage(id)
if err != nil {
writeImageError(w, err)
return
}
response := make([]usageResponse, 0, len(articles))
for _, a := range articles {
response = append(response, usageResponse{ID: a.ID, Title: a.Title, Published: a.IsOnline})
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
var request MergeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
return
}
result, err := h.MergeUseCase.Merge(request.KeepID, request.MergeIDs)
if err != nil {
writeMergeError(w, err)
return
}
writeJSON(w, http.StatusOK, mergeResponse{ArticlesUpdated: result.ArticlesUpdated, Merged: result.Merged})
}
func writeMergeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domainImage.ErrInvalidMergeRequest):
http.Error(w, apperr.MediaMergeRequestErrorMessage, http.StatusBadRequest)
case errors.Is(err, domainImage.ErrMergeCopyMissing), errors.Is(err, domainImage.ErrMergeHashMismatch):
http.Error(w, apperr.MediaMergeConflictErrorMessage, http.StatusConflict)
default:
writeImageError(w, err)
}
}
+4
View File
@@ -25,3 +25,7 @@ const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired." const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this." const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another." const UsernameTakenErrorMessage = "That username is taken. Try another."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
+49
View File
@@ -0,0 +1,49 @@
package upload
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image"
"errors"
"net/http"
)
const multipartOverheadAllowance = 1 << 20
const fileField = "file"
func ReadImageInput(w http.ResponseWriter, r *http.Request, uploadedById uint32) (image.ImageInput, func(), bool) {
r.Body = http.MaxBytesReader(w, r.Body, image.MaxImageSize+multipartOverheadAllowance)
file, header, err := r.FormFile(fileField)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, image.ErrImageTooLarge.Error(), http.StatusRequestEntityTooLarge)
return image.ImageInput{}, nil, false
}
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return image.ImageInput{}, nil, false
}
input := image.ImageInput{
Content: file,
Filename: header.Filename,
Size: header.Size,
ContentType: header.Header.Get("Content-Type"),
UploadedByID: uploadedById,
}
return input, func() { _ = file.Close() }, true
}
func WriteCreateError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, image.ErrImageTypeNotAllowed):
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
case errors.Is(err, image.ErrImageTooLarge):
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
case errors.Is(err, image.ErrImageUnreadable):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+22 -3
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/image" "RenewCMS/api/handlers/image"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
@@ -35,7 +36,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase, container.DeleteImageUseCase) imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
@@ -50,8 +51,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle) r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage) r.Post("/{id}/image", imageHandler.PostLegacyArticleImage)
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
}) })
return r return r
} }
@@ -132,6 +132,24 @@ func NewSettingsRouter(container *UseCases) http.Handler {
return r return r
} }
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
r.Patch("/{id}", mediaHandler.Rename)
r.Delete("/{id}", mediaHandler.Delete)
r.Get("/{id}/usage", mediaHandler.Usage)
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
@@ -146,6 +164,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container)) r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r return r
+7
View File
@@ -26,8 +26,15 @@ Nothing under `internal/` imports `api/`.
| Package | Role | | Package | Role |
|---|---| |---|---|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). | | `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
| `api/handlers/media` | The media library under `/v1/media` (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
| `api/handlers/image` | `POST /v1/article/{id}/image`, a temporary alias that creates a library image and attaches nothing. `PostLegacyArticleImage` goes away when the editor moves to `/v1/media`. |
| `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. | | `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. |
### 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.
## Frontend ↔ backend ## Frontend ↔ backend
``` ```
@@ -1,84 +0,0 @@
Feature: Deleting an article also deletes its images
As an authenticated RenewCMS user managing the CMS
I want deleting an article to also delete every image attached to it
So that removing an article doesn't leave orphaned image rows and files behind,
and so that an id that doesn't exist still answers 404 the same way every other
article write does
Background:
Given internal/domain/article.Article carries Images []*image.Image, populated by
ArticleRepository.Get via Preload("Images")
And internal/domain/image.Repository already exposes Delete(id uint32) error, which
on the real ImageRepository also removes the uploaded file from UPLOAD_DIR
And before this slice, DeleteArticleUseCase.DeleteArticle only called
articleRepository.Delete(id) and never touched image.Repository, so an article's
images were never deleted along with it
And this slice changes NewDeleteArticleUseCase to also take an image.Repository, and
changes DeleteArticleUseCase.DeleteArticle(id) to look up the article, delete every
image in its Images slice through image.Repository.Delete, and only then delete the
article itself through article.Repository.Delete
And this slice rewires api/dependecyInjection.go so
useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo) supplies both
repositories
And DELETE /v1/article/{id} keeps sitting behind the jwtauth Verifier and
Authenticator group in api/router.go, unchanged from
features/write_not_found_errors.feature
And DELETE /v1/article/{id} keeps answering 204 with an empty body on success and 404
with body apperr.ArticleNotFoundErrorMessage for an id absent from the article
repository, backed by errors.Is(err, article.ErrArticleNotFound), unchanged from
features/write_not_found_errors.feature
And no image HTTP endpoint is mounted in api/router.go as of this slice, so cascading
image deletion is only observable through the image repository's state, not through
a separate HTTP call
And the use case deletes images in the order they appear in the article's Images
slice, and aborts on the first image.Repository.Delete error, leaving the article
and any not-yet-deleted images untouched, and answering the same generic 500 the
DeleteArticle handler already falls back to on any non-not-found error
Scenario: Deleting an article with images removes the article and every one of its images
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 whose Images slice holds
image id 10 and image id 11
And the image repository contains images with id 10 and id 11
When the client sends DELETE /v1/article/1 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 1
And the image repository no longer contains an image with id 10 or id 11
Scenario: Deleting an article with no images still succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 2 whose Images slice is empty
When the client sends DELETE /v1/article/2 with that cookie
Then the response status is 204
And the article repository no longer contains an article with id 2
And no call reaches the image repository's Delete method
Scenario: Deleting an unknown article still answers 404 and deletes no images
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
When the client sends DELETE /v1/article/9999 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And no call reaches the image repository's Delete method
Scenario: Deleting an article still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 whose Images slice holds
image id 10
When the client sends DELETE /v1/article/1 without a cookie
Then the response status is 401
And the article repository still contains the article with id 1
And the image repository still contains the image with id 10
Scenario: A failure deleting one of an article's images aborts the whole delete
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 3 whose Images slice holds
image id 20 followed by image id 21
And the image repository contains images with id 20 and id 21
And the image repository's Delete fails for image id 20
When the client sends DELETE /v1/article/3 with that cookie
Then the response status is 500
And the response body is the text "Failed to delete article"
And the article repository still contains the article with id 3
And the image repository still contains the image with id 21
@@ -1,67 +0,0 @@
# QA procedure: delete_article_cascades_images
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo`
(`test/image_test.go`) as-is. Extend `mockArticleRepo` seeding so a seeded article's
`Images []*image.Image` can hold pointers into the same `mockImageRepo.images` map used
by the test, so deleting through the article side and asserting through the image side
observe the same state.
3. Add a call counter (or reuse the existing `failSwitch`/call-tracking pattern already in
`test/`) on `mockImageRepo.Delete` so a scenario can assert it was never called, and reuse
`mockImageRepo.fail` (the existing `failSwitch`) to make `Delete` fail for a specific
image id.
4. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase`, and the new two-argument
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`. Build `article.NewHandler(...)`
with the same constructor shape it already has (the use case's internal dependency count
changes, not the handler's).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s current shape:
`GET /` under `jwtauth.Verifier` only; `GET /{id}`, `POST /`, `DELETE /{id}`,
`PATCH /{id}`, `PATCH /{id}/publish`, `PATCH /{id}/unpublish` under `jwtauth.Verifier` +
`jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1: `token.Encode(map[string]any{"user_id": uint32(1)})`,
set as the `jwt` cookie on the relevant `httptest.NewRequest`.
## HTTP checks
8. Seed `imageRepo.images[10]` and `imageRepo.images[11]`, then seed
`articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update",
Images: []*image.Image{imageRepo.images[10], imageRepo.images[11]}}` (pointers into the
same map). Send `DELETE /v1/article/1` with the valid cookie -> status `204`; assert
`articleRepo.articles` no longer has key `1`; assert `imageRepo.images` no longer has
keys `10` or `11`.
9. Seed `articleRepo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel"}`
with a nil/empty `Images` slice. Send `DELETE /v1/article/2` with the valid cookie ->
status `204`; assert `articleRepo.articles` no longer has key `2`; assert the image-delete
call counter is `0`.
10. Empty `articleRepo`, empty `imageRepo`. Send `DELETE /v1/article/9999` with the valid
cookie -> status `404`, body `"Article not found\n"`; assert the image-delete call
counter is `0`.
11. Seed `imageRepo.images[10]` and `articleRepo.articles[1]` with `Images` holding image id
10, per step 8's pattern. Send `DELETE /v1/article/1` with **no** cookie -> status `401`;
assert `articleRepo.articles[1]` is still present and `imageRepo.images[10]` is still
present.
12. Seed `imageRepo.images[20]` and `imageRepo.images[21]`, then seed
`articleRepo.articles[3] = article.Article{ID: 3, Title: "Kitchen Progress Photos",
Images: []*image.Image{imageRepo.images[20], imageRepo.images[21]}}` (image 20 before
image 21 in the slice). Arm `imageRepo.fail` so `Delete` returns an error only when
called with id `20` (leave id `21` unarmed). Send `DELETE /v1/article/3` with the valid
cookie -> status `500`, body `"Failed to delete article\n"`; assert
`articleRepo.articles[3]` is still present; assert `imageRepo.images[21]` is still
present (never reached because the delete aborted on image `20` first).
## Manual verification (not exercised by the Go test)
- Real cascading delete against a real SQLite file and real `UPLOAD_DIR`: confirm that after
`DELETE /v1/article/{id}` on a real running instance, the uploaded image files under
`UPLOAD_DIR` for that article are gone from disk and the `images` table no longer holds
those rows, and that no orphaned rows are left behind in the `article_images` join table.
Not exercised by `task test` (fakes only); requires `task dev` or a built `task build`
binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article deletion flow (if/when one exists)
reflects that any images shown for that article are gone too. No such UI exists yet as of
this slice.
- No SMTP involvement.
@@ -0,0 +1,39 @@
Feature: Deleting an article never deletes images
As an authenticated RenewCMS user
I want deleting an article to leave the media library alone
So that an image used by other articles, or waiting in the library, is never lost
Background:
Given images belong to the library and internal/domain/article.Article carries no Images
And DeleteArticleUseCase depends only on article.Repository and calls its Delete
And api/dependecyInjection.go builds useCases.NewDeleteArticleUseCase(repos.ArticleRepo)
And DELETE /v1/article/{id} stays behind the same jwtauth group, answering 204 with an
empty body, 404 with apperr.ArticleNotFoundErrorMessage for errors.Is(err,
article.ErrArticleNotFound), and the generic 500 "Failed to delete article" otherwise
Scenario: Deleting an article leaves every image it used
Given a valid jwt cookie for user 1
And article 1 whose body contains the urls of images 10 and 11
And the image repository contains images 10 and 11
When the client sends DELETE /v1/article/1
Then the response status is 204
And the article repository no longer contains article 1
And the image repository still contains images 10 and 11 and Delete was never called
Scenario: Deleting an unknown article is 404 and touches no image
Given a valid jwt cookie for user 1 and no article 9999
When the client sends DELETE /v1/article/9999
Then the response status is 404 and the body is "Article not found"
And the image repository is untouched
Scenario: Deleting still requires authentication
Given no jwt cookie
When the client sends DELETE /v1/article/1
Then the response status is 401 and article 1 and image 10 still exist
Scenario: An article repository failure is 500 and everything stays
Given the article repository's Delete fails
When the client sends DELETE /v1/article/1 with a valid cookie
Then the response status is 500 and the body is "Failed to delete article"
And the image repository is untouched
@@ -0,0 +1,12 @@
# QA procedure: delete_article_keeps_images
Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first.
## HTTP checks
1. `DELETE /v1/article/1` with a valid cookie -> 204, `articleRepo` no longer holds article 1, `imageRepo.images` still holds 10 and 11 and `deleteCalls` is empty.
2. `DELETE /v1/article/9999` -> 404, body `"Article not found\n"`, image repo untouched.
3. `DELETE /v1/article/1` without cookie -> 401, article and images still present.
4. Article fake's `Delete` armed to fail -> 500, body `"Failed to delete article\n"`, image repo untouched.
## Manual verification
With a real SQLite file and `UPLOAD_DIR`, delete an article and confirm the image files and rows remain and still load. No SMTP.
+2 -2
View File
@@ -17,8 +17,8 @@ Feature: Allowed image types and the size limit are a domain rule
the matching sentinel and calling the repository not at all when either check fails the matching sentinel and calling the repository not at all when either check fails
And ImageRepository.Create no longer rejects an unrecognized content type itself; it And ImageRepository.Create no longer rejects an unrecognized content type itself; it
only maps an already-allowed content type to a file extension only maps an already-allowed content type to a file extension
And no image HTTP endpoint is mounted in api/router.go as of this slice, so this And the HTTP mapping of these sentinels is 413, 415 and 400, specified in
behavior is observable only through CreateImageUseCase features/media_library.feature
Scenario Outline: CreateImage accepts every allowed content type within the size limit Scenario Outline: CreateImage accepts every allowed content type within the size limit
Given an empty image repository fake Given an empty image repository fake
+391
View File
@@ -0,0 +1,391 @@
Feature: The media library holds every uploaded image as a library-level asset
As a verified RenewCMS user
I want to upload, browse, rename, delete, merge and inspect images that belong to the
library and not to any article
So that one image can be used in many articles, deleting an article never deletes an image,
and duplicates can be merged without leaving a broken image behind
Background:
Given internal/domain/image.Image carries ID, Path, Filename, Size, ContentType, Width,
Height (0 for SVG), SHA256, UploadedByID, CreatedAt and UpdatedAt, and no article id
And image.ImageInput gains Width, Height, SHA256 and UploadedByID
And internal/domain/image defines the sentinels ErrImageUnreadable, ErrInvalidFilename,
ErrMergeCopyMissing and ErrMergeHashMismatch, plus the existing ErrImageNotFound,
ErrImageTypeNotAllowed and ErrImageTooLarge, all asserted with errors.Is
And image.Repository exposes Create, Get, Delete, List(query), FindByHash, Rename,
ListByHash groups and Counts, and article.Repository exposes
FindByBodyContaining(filename) and the existing UpdateBody, and no longer AddImage
And CreateImageUseCase reads the bytes fully, rejects a type outside image/png, image/jpeg,
image/webp and image/svg+xml with ErrImageTypeNotAllowed, rejects more than 10485760
bytes actually read with ErrImageTooLarge, computes SHA-256 hex and, for raster images,
width and height from the bytes, and rejects bytes that cannot be decoded (or an SVG
without an svg root) with ErrImageUnreadable, all before image.Repository.Create is called
And an image's "used in" is the count of articles, online or draft, whose body contains
the image's url, read from article bodies each time
And /v1/media is mounted in api/router.go behind jwtauth.Verifier, jwtauth.Authenticator,
middleware.RequireCurrentToken and middleware.RequireVerifiedUser
And every media item is JSON with snake_case fields:
{"id","filename","url","size","content_type","width","height","sha256","uploaded_by",
"created_at","used_in","duplicate"}, where url is the stored path
"/static/uploadedImages/<uuid><ext>", width and height are null for SVG, uploaded_by is
the uploader's display name (username when empty, "" when unknown) and duplicate is true
when another image has the same sha256
And request bodies are validated with validate tags and validator.Validate.Struct, and
errors are plain text from http.Error
Scenario Outline: Every media route refuses callers that are not logged in and verified
When the client sends <method> <path> <credentials>
Then the response status is <status>
And no image or article is read or changed
Examples:
| method | path | credentials | status |
| GET | /v1/media/ | no cookie | 401 |
| POST | /v1/media/ | no cookie | 401 |
| PATCH | /v1/media/1 | no cookie | 401 |
| DELETE | /v1/media/1 | no cookie | 401 |
| POST | /v1/media/merge | no cookie | 401 |
| GET | /v1/media/1/usage | no cookie | 401 |
| GET | /v1/media/ | a stale token_version cookie | 401 |
| GET | /v1/media/ | cookie of an unverified user | 403 |
| DELETE | /v1/media/1 | cookie of an unverified user | 403 |
Rule: Listing
Background:
Given user 1 has display name "Inès Duarte" and user 2 has no display name and username "marta"
And the library holds, uploaded in this order (oldest first):
| id | filename | size | content_type | width | height | sha256 | uploaded by |
| 1 | north-wall.jpg | 4089446 | image/jpeg | 3600 | 2400 | A | 1 |
| 2 | floorboards.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 1 |
| 3 | floorboards-copy.jpg | 2516582 | image/jpeg | 3600 | 2400 | B | 2 |
| 4 | window-frame.jpg | 812345 | image/jpeg | 1200 | 800 | C | 2 |
| 5 | logo.svg | 1204 | image/svg+xml | 0 | 0 | D | 1 |
And article 1 "Kitchen Renovation" is online and its body contains the urls of images 1 and 2
And article 2 "Backyard Notes" is a draft and its body contains the url of image 1
And article 3 "Empty" has a body with no image url
Scenario: The default list is newest first with used_in, duplicate and tab counts
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And the "items" ids are 5, 4, 3, 2, 1 in that order
And item 1 has used_in 2, duplicate false and uploaded_by "Inès Duarte"
And items 2 and 3 have duplicate true, and item 3 has uploaded_by "marta"
And item 5 has width null, height null and used_in 0
And "next_cursor" is null
And "counts" is {"all": 5, "unused": 3, "duplicates": 1}
Scenario: An empty library answers an empty list, not null
Given the library holds no image
When the client sends GET /v1/media/ with a valid cookie
Then the response status is 200
And "items" is an empty array and "counts" is {"all": 0, "unused": 0, "duplicates": 0}
Scenario Outline: sort orders the list
When the client sends GET /v1/media/?sort=<sort> with a valid cookie
Then the "items" ids are <ids> in that order
Examples:
| sort | ids |
| newest | 5, 4, 3, 2, 1 |
| oldest | 1, 2, 3, 4, 5 |
| name | 2, 3, 5, 1, 4 |
| largest | 1, 2, 3, 4, 5 |
Scenario Outline: filter narrows the list and the counts stay library-wide
When the client sends GET /v1/media/?filter=<filter> with a valid cookie
Then the "items" ids, as a set, are <ids>
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| filter | ids |
| all | 1,2,3,4,5 |
| unused | 3, 4, 5 |
| duplicates | 2, 3 |
Scenario Outline: q matches the filename case-insensitively
When the client sends GET /v1/media/?q=<q> with a valid cookie
Then the "items" ids are <ids> in that order
And "counts" is still {"all": 5, "unused": 3, "duplicates": 1}
Examples:
| q | ids |
| FLOORBOARD | 3, 2 |
| wall | 1 |
| zzz | |
Scenario: q, filter and sort combine
When the client sends GET /v1/media/?q=floorboards&filter=unused&sort=oldest with a valid cookie
Then the "items" ids are 3
Scenario: hash returns only the images with that content
When the client sends GET /v1/media/?hash=<sha256 of image 2's bytes> with a valid cookie
Then the "items" ids, as a set, are 2, 3
Scenario: hash of unknown content returns no item
When the client sends GET /v1/media/?hash=<64 hex zeros> with a valid cookie
Then the response status is 200 and "items" is empty
Scenario: limit and cursor page through the list
When the client sends GET /v1/media/?limit=2 with a valid cookie
Then the "items" ids are 5, 4 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 3, 2 and "next_cursor" is a non-empty string
When the client sends GET /v1/media/?limit=2&cursor=<that next_cursor>
Then the "items" ids are 1 and "next_cursor" is null
Scenario Outline: A malformed query answers 400
When the client sends GET /v1/media/?<query> with a valid cookie
Then the response status is 400
And the response body is the text apperr.MediaQueryErrorMessage
Examples:
| query |
| filter=bogus |
| sort=bogus |
| limit=0 |
| limit=101 |
| limit=abc |
| cursor=%%% |
| hash=notahash |
Rule: Uploading
Scenario Outline: An allowed image is always stored, with its metadata and hash
Given a valid cookie for user 1 whose display name is "Inès Duarte"
When the client sends POST /v1/media/ with multipart field "file" named <name>, content type <content_type> and real <content_type> bytes
Then the response status is 201
And the body is a media item whose filename is <name>, content_type is <content_type>, size is the byte count, width and height are <width> and <height>, sha256 is the SHA-256 hex of the bytes, uploaded_by is "Inès Duarte", used_in is 0 and duplicate is false
And the item's url matches "/static/uploadedImages/<uuid><ext>"
And the image repository holds one new image with UploadedByID 1
Examples:
| name | content_type | width | height |
| north-wall.png | image/png | 3 | 2 |
| IMG_2208.jpg | image/jpeg | 3 | 2 |
| hero.webp | image/webp | 1 | 1 |
| logo.svg | image/svg+xml | null | null |
Scenario: Uploading the same bytes twice stores both and marks both as duplicates
Given the library holds image 1 with the same bytes
When the client sends POST /v1/media/ with those bytes named "north-wall-again.png"
Then the response status is 201 and the item has duplicate true
And the library now holds two images with the same sha256 and different urls
And "counts".duplicates is 1 when listed
Scenario: A filename without extension gets the canonical one, a path is stripped
When the client uploads real PNG bytes named "C:\\photos\\north wall" with content type "image/png"
Then the item filename is "north wall.png"
Scenario: A file exactly at 10485760 bytes is accepted, one byte more is 413
When the client uploads a valid PNG padded to 10485760 bytes
Then the response status is 201
When the client uploads a valid PNG padded to 10485761 bytes
Then the response status is 413
And the response body is the text "the file exceeds the maximum upload size"
And the image repository's Create was called once in total
Scenario: A disallowed content type is 415
When the client uploads "notes.pdf" with content type "application/pdf"
Then the response status is 415
And the response body is the text "the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
Scenario Outline: Bytes that cannot be read as the declared type are 400
When the client uploads <name> with content type <content_type> and the bytes "not an image"
Then the response status is 400
And the response body is the text "the image could not be read"
And the image repository's Create is never called
Examples:
| name | content_type |
| broken.png | image/png |
| broken.jpg | image/jpeg |
| broken.webp | image/webp |
| broken.svg | image/svg+xml |
Scenario: A request without the "file" part is 400
When the client sends POST /v1/media/ with a multipart body that has no "file" field
Then the response status is 400
And the image repository's Create is never called
Scenario: A repository failure is a generic 500
Given the image repository's next Create fails
When the client uploads a valid PNG
Then the response status is 500 and the body is "Internal server error"
Rule: Renaming
Background:
Given image 1 is "north-wall.jpg" with url "/static/uploadedImages/aaa.jpeg" and article 1 body contains that url
Scenario: Rename changes the display name, keeps the extension and the url
When the client sends PATCH /v1/media/1 with {"filename": "north-wall-01"}
Then the response status is 200
And the body is the media item with filename "north-wall-01.jpg" and url "/static/uploadedImages/aaa.jpeg"
And article 1's body is unchanged
And a following GET /v1/media/?q=north-wall-01 returns image 1
Scenario: Surrounding whitespace is trimmed and unicode letters are allowed
When the client sends PATCH /v1/media/1 with {"filename": " Été_2026-a "}
Then the response status is 200 and the filename is "Été_2026-a.jpg"
Scenario Outline: An invalid new name is 400 and nothing changes
When the client sends PATCH /v1/media/1 with <body>
Then the response status is 400
And the response body is the text "Enter a filename." for every row except "not json"
And the image's filename is still "north-wall.jpg"
Examples:
| body |
| {"filename": ""} |
| {"filename": " "} |
| {"filename": "***"} |
| {"filename": "a.b"} |
| {"filename": "a b"} |
| {"filename": "<101 letters>"} |
| {} |
| not json |
Scenario: Renaming an unknown image is 404
When the client sends PATCH /v1/media/9999 with {"filename": "x"}
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends PATCH /v1/media/abc with {"filename": "x"}
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Rule: Deleting
Scenario: An image in use can be deleted and article bodies are left alone
Given image 1 is used in article 1's body
When the client sends DELETE /v1/media/1 with a valid cookie
Then the response status is 204 and the body is empty
And image 1 is gone from the repository and its file from UPLOAD_DIR
And article 1's body is byte-for-byte unchanged
Scenario: A file already missing from disk still deletes the row
Given image 4's file is not in UPLOAD_DIR
When the client sends DELETE /v1/media/4
Then the response status is 204 and image 4 is gone from the repository
Scenario: Deleting an unknown image is 404
When the client sends DELETE /v1/media/9999
Then the response status is 404 and the body is "Image not found"
Scenario: A malformed id is 400
When the client sends DELETE /v1/media/abc
Then the response status is 400 and the body is apperr.IdUint32ErrorMessage
Scenario: A repository failure is a generic 500 and the image stays
Given the image repository's Delete fails
When the client sends DELETE /v1/media/1
Then the response status is 500 and image 1 is still in the repository
Rule: Usage
Scenario: Usage lists the articles whose body contains the url
Given article 1 "Kitchen Renovation" is online and article 2 "Backyard Notes" is a draft, both containing image 1's url, and article 3 does not
When the client sends GET /v1/media/1/usage with a valid cookie
Then the response status is 200
And the body is [{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]
Scenario: An unused image has an empty array, not null
When the client sends GET /v1/media/4/usage
Then the response status is 200 and the body is []
Scenario: An absolute url still counts as used
Given article 3's body contains "https://cms.example.com/static/uploadedImages/aaa.jpeg" for image 1
Then GET /v1/media/1/usage includes article 3
Scenario Outline: Unknown or malformed ids
When the client sends GET /v1/media/<id>/usage
Then the response status is <status> and the body is <body>
Examples:
| id | status | body |
| 9999 | 404 | Image not found |
| abc | 400 | apperr.IdUint32ErrorMessage |
Rule: Merging
Background:
Given images 2, 3 and 6 share sha256 B, image 1 has sha256 A
And article 1 body contains the urls of images 2 and 3, article 2 body contains image 3's url twice, article 3 body contains image 2's url only, and article 4 has none of them
Scenario: Merge switches bodies to the kept url and deletes the copies
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 6]}
Then the response status is 200
And the body is {"articles_updated": 2, "merged": 2}
And article 1's body has image 3's url replaced by image 2's url
And article 2's body has both occurrences of image 3's url replaced by image 2's url
And article 3 and article 4 are unchanged
And images 3 and 6 are gone from the repository and from UPLOAD_DIR, and image 2 remains
Scenario: Merging copies that no article uses updates zero articles
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [6]}
Then the response status is 200 and the body is {"articles_updated": 0, "merged": 1}
Scenario: A copy that is gone answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 9999]}
Then the response status is 409 and the body is apperr.MediaMergeConflictErrorMessage
And no article body changed and images 2 and 3 still exist
Scenario: A copy that does not share the hash answers 409 and changes nothing
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3, 1]}
Then the response status is 409
And no article body changed and images 1, 2 and 3 still exist
Scenario: A kept image that is gone answers 404
When the client sends POST /v1/media/merge with {"keep_id": 9999, "merge_ids": [3]}
Then the response status is 404 and the body is "Image not found"
And nothing changed
Scenario Outline: An invalid merge request is 400 and nothing changes
When the client sends POST /v1/media/merge with <body>
Then the response status is 400 and the body is apperr.MediaMergeRequestErrorMessage
Examples:
| body |
| {"keep_id": 2, "merge_ids": []} |
| {"keep_id": 2} |
| {"merge_ids": [3]} |
| {"keep_id": 2, "merge_ids": [2, 3]} |
| {"keep_id": 2, "merge_ids": [3, 3]} |
| {"keep_id": 0, "merge_ids": [3]} |
| not json |
Scenario: A failure rewriting a body restores the bodies already rewritten
Given the article repository's UpdateBody fails for article 2
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's body is back to its original text, article 2 is unchanged
And images 2 and 3 still exist
Scenario: A failure deleting a copy after the rewrite is 500 and the bodies stay switched
Given the image repository's Delete fails for image 3
When the client sends POST /v1/media/merge with {"keep_id": 2, "merge_ids": [3]}
Then the response status is 500
And article 1's and article 2's bodies use image 2's url and image 3 still exists
@manual
Rule: Startup backfill and migration (persistence, verified by hand, see the qa.md)
Scenario: Existing rows get metadata and hash from the files on disk
Given the images table has rows created before this slice, with a path and no filename, size, content_type, width, height or sha256, and their files are in UPLOAD_DIR
When the server boots
Then each row gets filename equal to the stored file name, size, content_type, width and height (0 for SVG) and sha256 read from its file, and UploadedByID stays 0
And the old article_id column and the article_images table are kept and not written
Scenario: A row whose file is missing does not stop the boot
Given a row whose file is not in UPLOAD_DIR
When the server boots
Then the boot completes, the row is left without metadata, and the server serves requests
Scenario: The backfill is idempotent
When the server boots a second time
Then no row is changed again
Scenario: A row without metadata still lists
Then GET /v1/media/ returns it with size 0, width null, height null, sha256 "" and duplicate false
+39
View File
@@ -0,0 +1,39 @@
# QA procedure: media_library
Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake type names must be unique in the package.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Rewrite `mockImageRepo` (`test/image_test.go`) to the new `image.Repository`: Create (stores the metadata from the ImageInput and derives a `/static/uploadedImages/<n>.<ext>` path), Get, Delete (records calls, honours `failDeleteFor`), List(query), FindByHash, Rename, ListByHash groups, Counts. Add fields for seeding `Filename`, `Size`, `ContentType`, `Width`, `Height`, `SHA256`, `UploadedByID`, `CreatedAt`.
3. Extend `mockArticleRepo` (`test/article_test.go`) with `FindByBodyContaining(filename)` (substring over `Body`) and a per-id failure switch for `UpdateBody`. Remove `AddImage` and the article `Images` field from the fake and from every test that uses them.
4. Use the existing user fake so `uploaded_by` resolves: user 1 display name "Inès Duarte", user 2 username "marta" with empty display name.
5. Wire the real use cases over the fakes: create, list, rename, delete, merge and usage media use cases, `NewCreateImageUseCase(imageRepo)`, `NewGetUserUseCase(userRepo)`. Build `media.NewHandler(...)` (`api/handlers/media`) and `image.NewHandler(...)` for the alias.
6. Build `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount on a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier` at the top, then in the media group `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, with the six routes under `/v1/media`.
7. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), ...})` as in the other HTTP tests. Build a stale-token cookie with a `token_version` lower than the user's, and use an unverified user for the 403 case.
8. Build multipart bodies with `mime/multipart.Writer`, using `CreatePart` with an explicit part `Content-Type` header. Generate real bytes in the test:
- PNG (3x2) with `image/png`;
- JPEG (3x2) with `image/jpeg`;
- a tiny valid WEBP fixture (1x1) as a byte constant;
- SVG `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>`.
Compute expected sha256 in the test with `crypto/sha256`. For size limits, pad valid PNG bytes to 10485760 and 10485761 bytes.
9. Seed the Listing background exactly as in the feature (5 images, 3 articles, 2 users), with `created_at` strictly increasing by id.
## HTTP checks
10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called.
11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`.
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
15. Usage: assert the exact JSON arrays (raw body compared as JSON), the empty `[]`, the absolute-url article, 404 and 400.
16. Merge: assert 200 `{articles_updated, merged}`, resulting bodies (both occurrences replaced), unchanged bodies for the other articles, and only image 2 left. Assert the 409 rows and 404 row, with no body change and no `Delete` calls (`deleteCalls` empty). Assert every 400 row with `apperr.MediaMergeRequestErrorMessage`. Assert the two failure rows: `UpdateBody` failing for article 2 leaves article 1 restored and no `Delete` call; `Delete` failing for image 3 leaves bodies switched and returns 500.
17. Unit-level asserts (same file, no HTTP) with `errors.Is` on the sentinels `ErrImageUnreadable`, `ErrMergeCopyMissing`, `ErrMergeHashMismatch`, `ErrInvalidFilename`.
18. Layering guard: `go list -deps` or a grep-style test that `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package.
## Manual verification (not exercised by `task test`)
- Real upload, rename, delete, merge against a scratch SQLite file and `UPLOAD_DIR` via `task dev`: the files land on disk, `url` loads through `/static/uploadedImages/`, deleting an in-use image leaves the article body text untouched, and a merge removes the copy files.
- Delete when the file was already removed from `UPLOAD_DIR` still answers 204.
- Startup backfill: copy an old database (rows with only `path`), boot, and check that filename, size, content_type, width, height and sha256 are filled; check idempotency on a second boot; check that a row whose file is missing does not stop the boot; check that the `article_images` table and `article_id` column are still present and not written.
- `UploadedByID` 0 rows display an empty `uploaded_by`.
- WEBP dimensions with a real WEBP file (the fixture is minimal).
- No SMTP involvement. No real rate limit (out of scope for this slice).
-99
View File
@@ -1,99 +0,0 @@
Feature: Removing an image from an article
As an authenticated RenewCMS user managing the CMS
I want to delete an already-uploaded image by its id
So that a removed image's file and row are actually gone, and a delete on
an unknown article or an unknown image answers 404 the same way every
other article write does
Background:
Given internal/infrastructure/useCases.DeleteImageUseCase.DeleteImage(imageId)
already exists and calls image.Repository.Delete(imageId), which returns
image.ErrImageNotFound for an unknown id and, on the real ImageRepository,
also removes the uploaded file from UPLOAD_DIR, per
features/delete_article_cascades_images.feature
And this slice changes POST /v1/article/{id}/image's success response
(api/handlers/image.Handler.PostImage, features/upload_article_image.feature)
to also return the created image's numeric id, so the 201 body becomes
{"id": ..., "location": ...}; this lets a client address the image later
through this slice's DELETE route
And this slice mounts DELETE /v1/article/{articleId}/image/{imageId} in
api/router.go, in the same jwtauth Verifier + Authenticator group as
POST .../image
And this slice adds a DeleteImage method to api/handlers/image.Handler,
grows NewHandler's signature to also take DeleteImageUseCase, and wires
it in api/router.go
And DeleteImage parses articleId and imageId from the URL, answering 400
with apperr.IdUint32ErrorMessage if either isn't a valid uint32
And DeleteImage first calls GetArticleUseCase.GetArticle(articleId);
errors.Is(err, article.ErrArticleNotFound) answers 404 with
apperr.ArticleNotFoundErrorMessage; this only confirms the article id
in the URL names a real article, and does not verify that the image id
belongs to that article's Images slice (a shallow existence check on
both ids, not an ownership check)
And DeleteImage then calls DeleteImageUseCase.DeleteImage(imageId);
errors.Is(err, image.ErrImageNotFound) answers 404 with the new
apperr.ImageNotFoundErrorMessage = "Image not found"; any other error
falls back to the existing generic 500
And on success DeleteImage answers 204 with an empty body, the same shape
as DELETE /v1/article/{id}
Scenario: Removing an image from an article it belongs to succeeds
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the response body is empty
And the image repository no longer contains an image with id 10
Scenario: Removing an image from an unknown article answers 404 and never touches the image repository
Given a valid jwt cookie for a user with id 1
And the article repository has no article with id 9999
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/9999/image/10 with that cookie
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Delete is never called
And the image repository still contains the image with id 10
Scenario: Removing an unknown image from an existing article answers 404
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update"
And the image repository has no image with id 9999
When the client sends DELETE /v1/article/1/image/9999 with that cookie
Then the response status is 404
And the response body is the text "Image not found"
Scenario: Removing an image id that belongs to a different article still succeeds, because the check is shallow
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice is empty
And the article repository contains a second article with id 2 whose
Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 with that cookie
Then the response status is 204
And the image repository no longer contains an image with id 10
Scenario: Removing an image still requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled
"Backyard Renovation Update" whose Images slice holds image id 10
And the image repository contains an image with id 10
When the client sends DELETE /v1/article/1/image/10 without a cookie
Then the response status is 401
And the image repository still contains the image with id 10
Scenario Outline: A malformed id in the URL answers 400 before either repository is touched
Given a valid jwt cookie for a user with id 1
When the client sends DELETE /v1/article/<article_id>/image/<image_id> with that cookie
Then the response status is 400
And the response body is the text "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
Examples:
| article_id | image_id |
| abc | 10 |
| 1 | abc |
-56
View File
@@ -1,56 +0,0 @@
# QA procedure: remove_article_image
Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` and `mockImageRepo` as-is.
3. Wire `useCases.NewDeleteImageUseCase(imageRepo)` (already used by
`delete_article_cascades_images.qa.md`'s suite) alongside the existing
use cases.
4. Build `image.NewHandler(...)` with its grown constructor including the
delete use case, matching whatever parameter order the coder settles on.
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount `DELETE /{id}/image/{imageId}` alongside the existing article
routes under the authenticated group, mirroring `api/router.go` after
this slice.
7. Mint a valid jwt cookie for user id 1 as in `upload_article_image.qa.md`.
## HTTP checks
8. Seed `articleRepo.articles[1]` with `Images: []*image.Image{{ID: 10}}`
and `imageRepo.images[10] = image.Image{ID: 10}`. Send
`DELETE /v1/article/1/image/10` with the valid cookie. Assert status
204, empty body; assert `imageRepo.images` no longer has key 10.
9. Empty `articleRepo`; seed `imageRepo.images[10]`. Send
`DELETE /v1/article/9999/image/10`. Assert 404, body
`"Article not found\n"`; assert `imageRepo.images` still has key 10
(Delete never called — track via a call-count field on the fake, the
same way the existing fakes already track `Create`).
10. Seed `articleRepo.articles[1]`; empty `imageRepo`. Send
`DELETE /v1/article/1/image/9999`. Assert 404, body
`"Image not found\n"`.
11. Seed `articleRepo.articles[1]` (empty Images) and `articleRepo.articles[2]`
with `Images: []*image.Image{{ID: 10}}`; seed `imageRepo.images[10]`.
Send `DELETE /v1/article/1/image/10`. Assert 204; assert
`imageRepo.images` no longer has key 10 — asserts the shallow-check
contract from the feature's fourth scenario.
12. Seed as in step 8. Send `DELETE /v1/article/1/image/10` with **no**
cookie. Assert 401; assert `imageRepo.images` still has key 10.
13. Examples-driven: send `DELETE /v1/article/abc/image/10` and
`DELETE /v1/article/1/image/abc` with the valid cookie. Assert 400,
body `"The server expects the ID to be in the format of an unsigned
32-bit integer (uint32).\n"` for each.
## Also touched by this slice: upload_article_image's 201 body
14. Extend `upload_article_image.qa.md`'s existing 201-body assertion to
also decode and check the `id` field equals the newly created image's
`ID` in `imageRepo.images`.
## Manual verification (not exercised by the Go test)
- Real DELETE against a real SQLite file and real UPLOAD_DIR: confirm the
file is actually removed from disk and the `article_images` join row is
gone, the same way `upload_article_image.qa.md`'s manual item covers the
POST side.
- SPA-visible behaviour: covered separately by
`features/editor_image_removal.qa.md`.
- No SMTP involvement.
+38 -90
View File
@@ -1,99 +1,47 @@
Feature: Uploading an image to an article Feature: POST /v1/article/{id}/image is a thin alias that creates a library image
As an authenticated RenewCMS user managing the CMS As the current editor, until the editor slice moves it to /v1/media
I want to attach an image file to an existing article and get back the I want to keep uploading through the article route and get {id, location} back
image's public location So that the editor keeps working while images are library assets
So that an unknown article answers 404 the same way every other article
write does, without ever creating an orphaned image, and a disallowed
content type is rejected with 400 before it becomes part of the
article, without a maintainer needing to trust the client to send a
sane file
Background: Background:
Given internal/infrastructure/useCases.CreateImageUseCase.CreateImage Given the route stays in the article router's jwtauth group with RequireCurrentToken and
already checks the uploaded content type against the allowed list RequireVerifiedUser, and is marked for removal in the editor slice
(image/png, image/jpeg, image/webp, image/svg+xml) and the size And the handler calls GetArticleUseCase first, so an unknown article is 404
against the 10485760-byte maximum before calling image.Repository.Create, apperr.ArticleNotFoundErrorMessage and nothing is created
returning image.ErrImageTypeNotAllowed or image.ErrImageTooLarge and And it then calls the same CreateImageUseCase as POST /v1/media/, with UploadedByID from the
calling the repository not at all when either check fails jwt user_id claim, attaches nothing to the article, and answers 201 {"id", "location"}
And internal/infrastructure/persistence's ArticleRepository.AddImage where location is the image's url
already returns article.ErrArticleNotFound when the article id doesn't And type, size and unreadable errors map as on /v1/media/: 415, 413, 400
match any row, and image.ErrImageNotFound when the article exists but And DELETE /v1/article/{id}/image/{imageId} is not mounted
the image id doesn't, both asserted with errors.Is
And before this slice, api/handlers/image.Handler.PostImage existed and
was wired to CreateImageUseCase and UpdateArticleUseCase.AddImage, but
no route called it, and every error it got back from either use case
fell through to a blanket 500
And this slice mounts POST /v1/article/{id}/image in api/router.go, in
the same jwtauth Verifier + Authenticator group as the other article
write routes, and changes the upload flow so that the article's
existence is confirmed before the image is created, so that an
unknown article id never leaves an orphaned image behind
And this slice changes PostImage's error handling so that errors.Is(err,
image.ErrImageTypeNotAllowed) or errors.Is(err, image.ErrImageTooLarge)
answers 400 with the sentinel's own message, errors.Is(err,
article.ErrArticleNotFound) answers 404 with body
apperr.ArticleNotFoundErrorMessage, and any other error still falls
back to a generic 500
And PostImage reads the uploaded file from the multipart form field
"file"
And on success PostImage answers 201 with a JSON body
{"id": ..., "location": ...} holding the created image's numeric id
and public path, e.g. "/static/uploadedImages/<stored-filename>"
And this slice (features/remove_article_image.feature) adds the "id"
field to that body; the id lets a client later address the image
through DELETE /v1/article/{id}/image/{imageId}
Scenario: Uploading an allowed image type to an existing article succeeds Scenario: Uploading through the alias creates a library image
Given a valid jwt cookie for a user with id 1 Given a valid jwt cookie for user 1 and article 1 "Backyard Renovation Update"
And the article repository contains an article with id 1 titled When the client sends POST /v1/article/1/image with a real PNG named "before.png"
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 201 Then the response status is 201
And the response body is JSON with a "location" field whose value is And the body has "id" equal to the new image's id and "location" equal to its url
"/static/uploadedImages/before.png" And GET /v1/media/ lists the image with uploaded_by of user 1
And the response body's "id" field equals the new image's id in the And article 1 is unchanged
image repository
And the image repository contains a new image whose Path is
"/static/uploadedImages/before.png"
And the article repository's article with id 1 has that new image's id
in its Images slice
Scenario: Uploading to an unknown article answers 404 and creates no image Scenario: An unknown article is 404 and creates no image
Given a valid jwt cookie for a user with id 1 When the client sends POST /v1/article/9999/image with a valid PNG
And the article repository has no article with id 9999 Then the response status is 404 and the body is "Article not found"
When the client sends POST /v1/article/9999/image with that cookie and a
multipart form field "file" holding a file named "before.png" with
content type "image/png"
Then the response status is 404
And the response body is the text "Article not found"
And the image repository's Create is never called And the image repository's Create is never called
And no image exists in the image repository
Scenario: Uploading a disallowed content type answers 400 before the article is touched Scenario Outline: Rejected files use the media status codes
Given a valid jwt cookie for a user with id 1 When the client sends POST /v1/article/1/image with <file>
And the article repository contains an article with id 1 titled Then the response status is <status> and Create is never called
"Backyard Renovation Update"
When the client sends POST /v1/article/1/image with that cookie and a
multipart form field "file" holding a file named "notes.pdf" with
content type "application/pdf"
Then the response status is 400
And the response body is the text
"the file must be a PNG, JPEG, WEBP, or SVG image"
And the image repository's Create is never called
And the article repository's article with id 1 still has an empty
Images slice
Scenario: Uploading an image still requires authentication Examples:
Given no jwt cookie is sent | file | status |
And the article repository contains an article with id 1 titled | "notes.pdf" as application/pdf | 415 |
"Backyard Renovation Update" | a valid PNG padded to 10485761 bytes | 413 |
When the client sends POST /v1/article/1/image without a cookie and a | "broken.png" as image/png with text bytes | 400 |
multipart form field "file" holding a file named "before.png" with
content type "image/png" Scenario: The alias still requires authentication
Then the response status is 401 When the client sends POST /v1/article/1/image with no cookie
And the image repository's Create is never called Then the response status is 401 and Create is never called
And the article repository's article with id 1 still has an empty
Images slice Scenario: The removed delete route is gone
When the client sends DELETE /v1/article/1/image/10 with a valid cookie
Then the response status is 405 and image 10 still exists
+12 -64
View File
@@ -4,70 +4,18 @@ Package: `test` (black-box, extending `article_test.go` / `image_test.go`).
## Setup shared by every case ## Setup shared by every case
1. Call `validator.InitValidator()` once. 1. Call `validator.InitValidator()` once.
2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo` 2. Wire `image.NewHandler(createImageUseCase, getArticleUseCase)` (no update or delete use cases) and mount only `POST /{id}/image` in the article group, behind `Verifier`, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`.
(`test/image_test.go`) as-is; both already implement `AddImage` / 3. Use real image bytes generated in the test, as in `media_library.qa.md` step 8, and multipart bodies with an explicit part `Content-Type`.
`Create` returning `article.ErrArticleNotFound` / `image.ErrImageNotFound`
for an unknown id, per `features/write_not_found_errors.feature`.
3. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewUpdateArticleUseCase(articleRepo)`,
`useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`,
`useCases.NewCreateImageUseCase(imageRepo)`.
4. Build `article.NewHandler(...)` with its existing constructor shape, and
`image.NewHandler(createImageUseCase, updateArticleUseCase)`
(`api/handlers/image`).
5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s
shape after this slice: `GET /` under `jwtauth.Verifier` only;
`GET /{id}`, `POST /`, `DELETE /{id}`, `PATCH /{id}`,
`PATCH /{id}/publish`, `PATCH /{id}/unpublish` and the new
`POST /{id}/image` (routed to `imageHandler.PostImage`) under
`jwtauth.Verifier` + `jwtauth.Authenticator`.
7. Mint a valid jwt cookie for user id 1:
`token.Encode(map[string]any{"user_id": uint32(1)})`, set as the `jwt`
cookie on the relevant `httptest.NewRequest`.
8. Build multipart request bodies with `mime/multipart.Writer`, field name
`"file"`, setting the part's `Content-Type` header explicitly (Go's
multipart writer defaults to sniffing otherwise, so set it via
`CreatePart` with an explicit MIME header rather than
`CreateFormFile`), and set the request's `Content-Type` header to the
writer's `FormDataContentType()`.
## HTTP checks ## HTTP checks
9. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard 4. Valid PNG on article 1 -> 201 `{id, location}` matching the created image, and `articleRepo.articles[1]` has an unchanged body.
Renovation Update"}`. Send `POST /v1/article/1/image` with the valid 5. Unknown article 9999 -> 404 `"Article not found\n"`, `Create` never called.
cookie and a multipart `"file"` part named `"before.png"`, 6. Rejected files: `application/pdf` -> 415, PNG padded to 10485761 bytes -> 413, text bytes as `image/png` -> 400; `Create` never called.
content type `"image/png"`, some non-empty byte content. Assert status 7. No cookie -> 401, `Create` never called.
`201`; decode the JSON body and assert `location` equals 8. `DELETE /v1/article/1/image/10` on this router -> 405, image 10 still exists.
`"/static/uploadedImages/before.png"`; assert `imageRepo.images` gained
exactly one entry whose `Path` matches that location; assert
`articleRepo.articles[1].Images` now contains that image's id.
10. Empty `articleRepo`, empty `imageRepo`. Send `POST /v1/article/9999/image`
with the valid cookie and the same `"before.png"` / `"image/png"` part.
Assert status `404`, body `"Article not found\n"`; assert
`imageRepo.images` is still empty (the article's existence is confirmed
before any image is created, so nothing is left orphaned).
11. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard
Renovation Update"}` with an empty `Images` slice. Send
`POST /v1/article/1/image` with the valid cookie and a multipart
`"file"` part named `"notes.pdf"`, content type `"application/pdf"`.
Assert status `400`, body
`"the file must be a PNG, JPEG, WEBP, or SVG image\n"`; assert
`imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
12. Seed the same article id 1 as step 11. Send `POST /v1/article/1/image`
with **no** cookie and the `"before.png"` / `"image/png"` part. Assert
status `401`; assert `imageRepo.images` is still empty; assert
`articleRepo.articles[1].Images` is still empty.
## Manual verification (not exercised by the Go test) ## Cleanup
- Real upload against a real SQLite file and real `UPLOAD_DIR`: confirm that Delete `test/remove_article_image_http_test.go` and update `test/upload_article_image_http_test.go` to the cases above.
after `POST /v1/article/{id}/image` on a real running instance, the file
actually lands on disk under `UPLOAD_DIR`, the `images` row's `path` ## Manual verification
resolves to a URL the SPA can load, and the `article_images` join table With a real instance, `POST /v1/article/{id}/image` writes a file to `UPLOAD_DIR` and a row visible in `GET /v1/media/`, and no article association is written.
gets the new row. Not exercised by `task test` (fakes only); requires
`task dev` or a built `task build` binary with a scratch DB/upload dir.
- SPA-visible behaviour: confirm the back-office article editor (if/when an
image-upload UI exists) shows the uploaded image at that location. No such
UI exists yet as of this slice.
- No SMTP involvement.
+3 -2
View File
@@ -12,6 +12,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.44.0
golang.org/x/image v0.46.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@@ -41,8 +42,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.0 // indirect modernc.org/libc v1.67.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+12 -10
View File
@@ -82,17 +82,19 @@ golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+6 -8
View File
@@ -1,7 +1,6 @@
package article package article
import ( import (
domain "RenewCMS/internal/domain/image"
"errors" "errors"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -27,11 +26,10 @@ func ValidateTitle(title string) error {
} }
type Article struct { type Article struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Images []*domain.Image `json:"images"` IsOnline bool `json:"is_online"`
IsOnline bool `json:"is_online"` CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
@@ -13,5 +13,5 @@ type Repository interface {
UpdateBody(id uint32, body string) (Article, error) UpdateBody(id uint32, body string) (Article, error)
UpdateIsOnline(id uint32, isOnline bool) (Article, error) UpdateIsOnline(id uint32, isOnline bool) (Article, error)
Delete(id uint32) error Delete(id uint32) error
AddImage(postId uint32, imageId uint32) error FindByBodyContaining(filename string) ([]Article, error)
} }
+166 -9
View File
@@ -3,16 +3,33 @@ package image
import ( import (
"errors" "errors"
"io" "io"
"path"
"regexp"
"sort" "sort"
"strings"
"time" "time"
"unicode/utf8"
) )
var ErrImageNotFound = errors.New("image not found") var ErrImageNotFound = errors.New("image not found")
var ErrImageTypeNotAllowed = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image") var ErrImageTypeNotAllowed = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
var ErrImageTooLarge = errors.New("the file exceeds the maximum upload size") var ErrImageTooLarge = errors.New("the file exceeds the maximum upload size")
var ErrImageUnreadable = errors.New("the image could not be read")
var ErrInvalidFilename = errors.New("invalid filename")
var ErrInvalidQuery = errors.New("invalid media query")
var ErrInvalidMergeRequest = errors.New("invalid merge request")
var ErrMergeCopyMissing = errors.New("a merged copy no longer exists")
var ErrMergeHashMismatch = errors.New("a merged copy does not share the kept image's content")
const MaxImageSize int64 = 10485760 const MaxImageSize int64 = 10485760
const (
MinFilenameRunes = 1
MaxFilenameRunes = 100
DefaultListLimit = 24
MaxListLimit = 100
)
var allowedContentTypes = map[string]bool{ var allowedContentTypes = map[string]bool{
"image/png": true, "image/png": true,
"image/jpeg": true, "image/jpeg": true,
@@ -20,6 +37,16 @@ var allowedContentTypes = map[string]bool{
"image/svg+xml": true, "image/svg+xml": true,
} }
var canonicalExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
var filenameStemPattern = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func IsAllowedContentType(contentType string) bool { func IsAllowedContentType(contentType string) bool {
return allowedContentTypes[contentType] return allowedContentTypes[contentType]
} }
@@ -33,17 +60,147 @@ func AllowedContentTypes() []string {
return types return types
} }
func NormalizeUploadFilename(original string, contentType string) string {
name := original[strings.LastIndexAny(original, `/\`)+1:]
name = strings.TrimSpace(name)
if name == "" || name == "." {
name = "image"
}
if path.Ext(name) == "" {
name += canonicalExtensions[contentType]
}
return name
}
func ExtensionOf(filename string) string {
return path.Ext(filename)
}
func StemOf(filename string) string {
return strings.TrimSuffix(filename, path.Ext(filename))
}
func ValidateFilenameStem(stem string) (string, error) {
trimmed := strings.TrimSpace(stem)
runeCount := utf8.RuneCountInString(trimmed)
if runeCount < MinFilenameRunes || runeCount > MaxFilenameRunes {
return "", ErrInvalidFilename
}
if !filenameStemPattern.MatchString(trimmed) {
return "", ErrInvalidFilename
}
return trimmed, nil
}
func StoredName(imagePath string) string {
return path.Base(imagePath)
}
type Sort string
const (
SortNewest Sort = "newest"
SortOldest Sort = "oldest"
SortName Sort = "name"
SortLargest Sort = "largest"
)
type Filter string
const (
FilterAll Filter = "all"
FilterUnused Filter = "unused"
FilterDuplicates Filter = "duplicates"
)
type ListQuery struct {
Search string
Hash string
Filter Filter
Sort Sort
Limit int
Cursor string
}
func NewListQuery() ListQuery {
return ListQuery{Filter: FilterAll, Sort: SortNewest, Limit: DefaultListLimit}
}
func (q ListQuery) Validate() error {
switch q.Filter {
case FilterAll, FilterUnused, FilterDuplicates:
default:
return ErrInvalidQuery
}
switch q.Sort {
case SortNewest, SortOldest, SortName, SortLargest:
default:
return ErrInvalidQuery
}
if q.Limit < 1 || q.Limit > MaxListLimit {
return ErrInvalidQuery
}
if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) {
return ErrInvalidQuery
}
return nil
}
func SortImages(images []Image, order Sort) {
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i], images[j]
switch order {
case SortOldest:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.Before(b.CreatedAt)
}
return a.ID < b.ID
case SortName:
nameA, nameB := strings.ToLower(StemOf(a.Filename)), strings.ToLower(StemOf(b.Filename))
if nameA != nameB {
return nameA < nameB
}
return a.ID < b.ID
case SortLargest:
if a.Size != b.Size {
return a.Size > b.Size
}
return a.ID < b.ID
default:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID > b.ID
}
})
}
type Counts struct {
All int
DuplicateGroups int
}
type ImageInput struct { type ImageInput struct {
Content io.Reader Content io.Reader
Filename string Filename string
Size int64 Size int64
ContentType string ContentType string
Width int
Height int
SHA256 string
UploadedByID uint32
} }
type Image struct { type Image struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Path string `json:"path"` Path string `json:"path"`
ArticleID uint32 `json:"article_id"` Filename string `json:"filename"`
CreatedAt time.Time `json:"created_at"` Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at"` ContentType string `json:"content_type"`
Width int `json:"width"`
Height int `json:"height"`
SHA256 string `json:"sha256"`
UploadedByID uint32 `json:"uploaded_by_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
@@ -1,6 +1,17 @@
package image package image
type SearchQuery struct {
Search string
Hash string
}
type Repository interface { type Repository interface {
Create(input ImageInput) (Image, error) Create(input ImageInput) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
FindByHash(hash string) ([]Image, error)
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
} }
@@ -2,22 +2,14 @@ package mappers
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
) )
func ArticleToDomain(m models.Article) article.Article { func ArticleToDomain(m models.Article) article.Article {
domainImages := make([]*image.Image, len(m.Images))
for i, img := range m.Images {
domainImg := ImageToDomain(*img)
domainImages[i] = &domainImg
}
return article.Article{ return article.Article{
ID: m.ID, ID: m.ID,
Title: m.Title, Title: m.Title,
Body: m.Body, Body: m.Body,
Images: domainImages,
IsOnline: m.IsOnline, IsOnline: m.IsOnline,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
@@ -25,16 +17,9 @@ func ArticleToDomain(m models.Article) article.Article {
} }
func ArticleToModel(a article.Article) models.Article { func ArticleToModel(a article.Article) models.Article {
modelImages := make([]*models.Image, len(a.Images))
for i, img := range a.Images {
modelImage := ImageToModel(*img)
modelImages[i] = &modelImage
}
return models.Article{ return models.Article{
Title: a.Title, Title: a.Title,
Body: a.Body, Body: a.Body,
Images: modelImages,
IsOnline: a.IsOnline, IsOnline: a.IsOnline,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
@@ -7,19 +7,32 @@ import (
func ImageToDomain(imageModel models.Image) image.Image { func ImageToDomain(imageModel models.Image) image.Image {
return image.Image{ return image.Image{
ID: imageModel.ID, ID: imageModel.ID,
Path: imageModel.Path, Path: imageModel.Path,
ArticleID: imageModel.ArticleID, Filename: imageModel.Filename,
CreatedAt: imageModel.CreatedAt, Size: imageModel.Size,
UpdatedAt: imageModel.UpdatedAt, ContentType: imageModel.ContentType,
Width: imageModel.Width,
Height: imageModel.Height,
SHA256: imageModel.SHA256,
UploadedByID: imageModel.UploadedByID,
CreatedAt: imageModel.CreatedAt,
UpdatedAt: imageModel.UpdatedAt,
} }
} }
func ImageToModel(imageDomain image.Image) models.Image { func ImageToModel(imageDomain image.Image) models.Image {
return models.Image{ return models.Image{
Path: imageDomain.Path, ID: imageDomain.ID,
ArticleID: imageDomain.ArticleID, Path: imageDomain.Path,
CreatedAt: imageDomain.CreatedAt, Filename: imageDomain.Filename,
UpdatedAt: imageDomain.UpdatedAt, Size: imageDomain.Size,
ContentType: imageDomain.ContentType,
Width: imageDomain.Width,
Height: imageDomain.Height,
SHA256: imageDomain.SHA256,
UploadedByID: imageDomain.UploadedByID,
CreatedAt: imageDomain.CreatedAt,
UpdatedAt: imageDomain.UpdatedAt,
} }
} }
@@ -11,7 +11,6 @@ type Article struct {
ID uint32 `gorm:"primary_key;auto_increment;not_null"` ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string Title string
Body string Body string
Images []*Image `gorm:"many2many:article_images;"`
IsOnline bool `gorm:"not_null;default:false"` IsOnline bool `gorm:"not_null;default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -8,9 +8,15 @@ import (
type Image struct { type Image struct {
gorm.Model gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"` ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string Path string
ArticleID uint32 Filename string
CreatedAt time.Time `gorm:"autoCreateTime"` Size int64
UpdatedAt time.Time `gorm:"autoUpdateTime"` ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
} }
@@ -2,7 +2,6 @@ package persistence
import ( import (
domain "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
@@ -18,7 +17,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
} }
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) { func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{}).Preload("Images") query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil { if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline) query = query.Where("is_online = ?", *filters.IsOnline)
@@ -37,7 +36,7 @@ func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Arti
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) { func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
var article entity.Article var article entity.Article
err := a.db.Model(&entity.Article{}).Preload("Images").First(&article, id).Error err := a.db.Model(&entity.Article{}).First(&article, id).Error
if err != nil { if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound) return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
} }
@@ -125,25 +124,18 @@ func (a *ArticleRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domain.ErrArticleNotFound) return deleteOrNotFound(result, domain.ErrArticleNotFound)
} }
func (a *ArticleRepository) AddImage(articleId uint32, imageId uint32) error { func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var localArticle entity.Article var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, articleId).Error err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
if err != nil { if err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound) return nil, err
} }
var localImage entity.Image domainArticles := make([]domain.Article, 0, len(entityArticles))
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error for _, entityArticle := range entityArticles {
if err != nil { domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
return mapNotFoundErr(err, imageDomain.ErrImageNotFound)
} }
return domainArticles, nil
err = a.db.Model(&localArticle).Association("Images").Append(&localImage)
if err != nil {
return err
}
return nil
} }
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
@@ -0,0 +1,77 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,14 +4,18 @@ import (
domain "RenewCMS/internal/domain/image" domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct { type ImageRepository struct {
db *gorm.DB db *gorm.DB
uploadDir string uploadDir string
@@ -29,49 +33,142 @@ var contentTypeExtensions = map[string]string{
} }
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) { func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
uploadDir := i.uploadDir
fileBytes, err := io.ReadAll(input.Content) fileBytes, err := io.ReadAll(input.Content)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
err = os.MkdirAll(uploadDir, 0755) err = os.MkdirAll(i.uploadDir, 0755)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
extension := contentTypeExtensions[input.ContentType] newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
newName := uuid.NewString() + extension
finalPath := filepath.Join(uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666) err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil { if err != nil {
return domain.Image{}, err return domain.Image{}, err
} }
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName}) model := mappers.ImageToModel(domain.Image{
var createdImage entity.Image Path: uploadedImagesURLPrefix + newName,
newImage.Scan(&createdImage) Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(createdImage), nil return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) Get(id uint32) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
return mappers.ImageToDomain(model), nil
} }
func (i ImageRepository) Delete(id uint32) error { func (i ImageRepository) Delete(id uint32) error {
uploadDir := i.uploadDir var model entity.Image
var entityImage entity.Image err := i.db.First(&model, id).Error
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
if err != nil { if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound) return mapNotFoundErr(err, domain.ErrImageNotFound)
} }
fileName := filepath.Base(entityImage.Path) err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
err = os.Remove(filepath.Join(uploadDir, fileName)) if err != nil && !errors.Is(err, os.ErrNotExist) {
if err != nil {
return err return err
} }
return i.db.Delete(&entity.Image{}, id).Error return i.db.Delete(&entity.Image{}, id).Error
} }
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
}
var models []entity.Image
if err := statement.Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) FindByHash(hash string) ([]domain.Image, error) {
if hash == "" {
return []domain.Image{}, nil
}
var models []entity.Image
if err := i.db.Where("sha256 = ?", hash).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Rename(id uint32, filename string) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
model.Filename = filename
if err := i.db.Save(&model).Error; err != nil {
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) ListDuplicates() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("sha256 IN (?)", i.duplicatedHashes()).Order("sha256 asc, id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Counts() (domain.Counts, error) {
var all int64
if err := i.db.Model(&entity.Image{}).Count(&all).Error; err != nil {
return domain.Counts{}, err
}
var groups int64
if err := i.db.Table("(?) as duplicated", i.duplicatedHashes()).Count(&groups).Error; err != nil {
return domain.Counts{}, err
}
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
}
func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}).
Select("sha256").
Where("sha256 <> ''").
Group("sha256").
Having("COUNT(*) > 1")
}
func imagesToDomain(models []entity.Image) []domain.Image {
images := make([]domain.Image, 0, len(models))
for _, model := range models {
images = append(images, mappers.ImageToDomain(model))
}
return images
}
var _ domain.Repository = &ImageRepository{} var _ domain.Repository = &ImageRepository{}
@@ -2,8 +2,26 @@ package useCases
import ( import (
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
stdimage "image"
"io"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
) )
const svgRootElement = "svg"
var rasterFormats = map[string]string{
"image/png": "png",
"image/jpeg": "jpeg",
"image/webp": "webp",
}
type CreateImageUseCase struct { type CreateImageUseCase struct {
imageRepository image.Repository imageRepository image.Repository
} }
@@ -16,8 +34,68 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
if !image.IsAllowedContentType(input.ContentType) { if !image.IsAllowedContentType(input.ContentType) {
return image.Image{}, image.ErrImageTypeNotAllowed return image.Image{}, image.ErrImageTypeNotAllowed
} }
if input.Size > image.MaxImageSize {
return image.Image{}, image.ErrImageTooLarge data, err := readWithinLimit(input.Content)
if err != nil {
return image.Image{}, err
}
width, height, err := measure(data, input.ContentType)
if err != nil {
return image.Image{}, err
}
checksum := sha256.Sum256(data)
return g.imageRepository.Create(image.ImageInput{
Content: bytes.NewReader(data),
Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType),
Size: int64(len(data)),
ContentType: input.ContentType,
Width: width,
Height: height,
SHA256: hex.EncodeToString(checksum[:]),
UploadedByID: input.UploadedByID,
})
}
func readWithinLimit(content io.Reader) ([]byte, error) {
if content == nil {
return nil, image.ErrImageUnreadable
}
data, err := io.ReadAll(io.LimitReader(content, image.MaxImageSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > image.MaxImageSize {
return nil, image.ErrImageTooLarge
}
return data, nil
}
func measure(data []byte, contentType string) (int, int, error) {
if contentType == "image/svg+xml" {
if !hasSvgRoot(data) {
return 0, 0, image.ErrImageUnreadable
}
return 0, 0, nil
}
config, format, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil || format != rasterFormats[contentType] {
return 0, 0, image.ErrImageUnreadable
}
return config.Width, config.Height, nil
}
func hasSvgRoot(data []byte) bool {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return false
}
if start, ok := token.(xml.StartElement); ok {
return start.Name.Local == svgRootElement
}
} }
return g.imageRepository.Create(input)
} }
@@ -1,30 +1,15 @@
package useCases package useCases
import ( import "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
type DeleteArticleUseCase struct { type DeleteArticleUseCase struct {
articleRepository article.Repository articleRepository article.Repository
imageRepository image.Repository
} }
func NewDeleteArticleUseCase(articleRepository article.Repository, imageRepository image.Repository) *DeleteArticleUseCase { func NewDeleteArticleUseCase(articleRepository article.Repository) *DeleteArticleUseCase {
return &DeleteArticleUseCase{articleRepository, imageRepository} return &DeleteArticleUseCase{articleRepository}
} }
func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error { func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error {
a, err := g.articleRepository.Get(id)
if err != nil {
return err
}
for _, img := range a.Images {
if err := g.imageRepository.Delete(img.ID); err != nil {
return err
}
}
return g.articleRepository.Delete(id) return g.articleRepository.Delete(id)
} }
@@ -1,15 +0,0 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteImageUseCase struct {
imageRepository image.Repository
}
func NewDeleteImageUseCase(imageRepository image.Repository) *DeleteImageUseCase {
return &DeleteImageUseCase{imageRepository}
}
func (g *DeleteImageUseCase) DeleteImage(imageId uint32) error {
return g.imageRepository.Delete(imageId)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteMediaUseCase struct {
imageRepository image.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository}
}
func (u *DeleteMediaUseCase) Delete(id uint32) error {
return u.imageRepository.Delete(id)
}
@@ -0,0 +1,23 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
type GetMediaUsageUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository, articleRepository}
}
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]article.Article, error) {
img, err := u.imageRepository.Get(id)
if err != nil {
return nil, err
}
return u.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
}
@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"encoding/base64"
"strconv"
"strings"
)
const cursorPrefix = "offset:"
type MediaItem struct {
Image image.Image
UploadedBy string
UsedIn int
Duplicate bool
}
type MediaCounts struct {
All int
Unused int
Duplicates int
}
type MediaPage struct {
Items []MediaItem
NextCursor string
Counts MediaCounts
}
type ListMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
userRepository user.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err := query.Validate(); err != nil {
return MediaPage{}, err
}
offset, err := decodeCursor(query.Cursor)
if err != nil {
return MediaPage{}, err
}
usage, err := l.usageCounter()
if err != nil {
return MediaPage{}, err
}
duplicateHashes, err := l.duplicateHashes()
if err != nil {
return MediaPage{}, err
}
counts, err := l.counts(usage)
if err != nil {
return MediaPage{}, err
}
images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
if err != nil {
return MediaPage{}, err
}
image.SortImages(images, query.Sort)
matching := make([]image.Image, 0, len(images))
for _, img := range images {
if matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
matching = append(matching, img)
}
}
pageEnd := min(offset+query.Limit, len(matching))
pageStart := min(offset, pageEnd)
names := newUploaderNames(l.userRepository)
items := []MediaItem{}
for _, img := range matching[pageStart:pageEnd] {
uploadedBy, err := names.nameOf(img.UploadedByID)
if err != nil {
return MediaPage{}, err
}
items = append(items, MediaItem{
Image: img,
UploadedBy: uploadedBy,
UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
})
}
page := MediaPage{Items: items, Counts: counts}
if pageEnd < len(matching) {
page.NextCursor = encodeCursor(pageEnd)
}
return page, nil
}
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
articles, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
if err != nil {
return MediaItem{}, err
}
uploadedBy, err := newUploaderNames(l.userRepository).nameOf(img.UploadedByID)
if err != nil {
return MediaItem{}, err
}
duplicate := false
if img.SHA256 != "" {
sameContent, err := l.imageRepository.FindByHash(img.SHA256)
if err != nil {
return MediaItem{}, err
}
duplicate = len(sameContent) > 1
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate}, nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
articles, err := l.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := 0
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
used++
}
}
return used
}, nil
}
func (l *ListMediaUseCase) duplicateHashes() (map[string]bool, error) {
duplicates, err := l.imageRepository.ListDuplicates()
if err != nil {
return nil, err
}
hashes := make(map[string]bool, len(duplicates))
for _, img := range duplicates {
hashes[img.SHA256] = true
}
return hashes, nil
}
func (l *ListMediaUseCase) counts(usage func(image.Image) int) (MediaCounts, error) {
libraryCounts, err := l.imageRepository.Counts()
if err != nil {
return MediaCounts{}, err
}
everything, err := l.imageRepository.List(image.SearchQuery{})
if err != nil {
return MediaCounts{}, err
}
unused := 0
for _, img := range everything {
if usage(img) == 0 {
unused++
}
}
return MediaCounts{All: libraryCounts.All, Unused: unused, Duplicates: libraryCounts.DuplicateGroups}, nil
}
func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
switch filter {
case image.FilterUnused:
return usedIn == 0
case image.FilterDuplicates:
return duplicate
default:
return true
}
}
func encodeCursor(offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(cursorPrefix + strconv.Itoa(offset)))
}
func decodeCursor(cursor string) (int, error) {
if cursor == "" {
return 0, nil
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || !strings.HasPrefix(string(raw), cursorPrefix) {
return 0, image.ErrInvalidQuery
}
offset, err := strconv.Atoi(strings.TrimPrefix(string(raw), cursorPrefix))
if err != nil || offset < 0 {
return 0, image.ErrInvalidQuery
}
return offset, nil
}
@@ -0,0 +1,137 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"cmp"
"errors"
"slices"
"strings"
)
type MergeMediaResult struct {
ArticlesUpdated int
Merged int
}
type MergeMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository}
}
type bodyRewrite struct {
articleId uint32
original string
rewritten string
}
func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) {
if err := validateMergeRequest(keepId, mergeIds); err != nil {
return MergeMediaResult{}, err
}
kept, err := u.imageRepository.Get(keepId)
if err != nil {
return MergeMediaResult{}, err
}
copies, err := u.loadCopies(kept, mergeIds)
if err != nil {
return MergeMediaResult{}, err
}
rewrites, err := u.planRewrites(kept, copies)
if err != nil {
return MergeMediaResult{}, err
}
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.imageRepository.Delete(duplicate.ID); err != nil {
return MergeMediaResult{}, err
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
}
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
if keepId == 0 || len(mergeIds) == 0 {
return image.ErrInvalidMergeRequest
}
seen := map[uint32]bool{keepId: true}
for _, id := range mergeIds {
if id == 0 || seen[id] {
return image.ErrInvalidMergeRequest
}
seen[id] = true
}
return nil
}
func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]image.Image, error) {
copies := make([]image.Image, 0, len(mergeIds))
for _, id := range mergeIds {
duplicate, err := u.imageRepository.Get(id)
if errors.Is(err, image.ErrImageNotFound) {
return nil, image.ErrMergeCopyMissing
}
if err != nil {
return nil, err
}
if duplicate.SHA256 == "" || duplicate.SHA256 != kept.SHA256 {
return nil, image.ErrMergeHashMismatch
}
copies = append(copies, duplicate)
}
return copies, nil
}
func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image) ([]bodyRewrite, error) {
keptName := image.StoredName(kept.Path)
rewritten := map[uint32]*bodyRewrite{}
for _, duplicate := range copies {
copyName := image.StoredName(duplicate.Path)
articles, err := u.articleRepository.FindByBodyContaining(copyName)
if err != nil {
return nil, err
}
for _, a := range articles {
plan, ok := rewritten[a.ID]
if !ok {
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
rewritten[a.ID] = plan
}
plan.rewritten = strings.ReplaceAll(plan.rewritten, copyName, keptName)
}
}
plans := make([]bodyRewrite, 0, len(rewritten))
for _, plan := range rewritten {
plans = append(plans, *plan)
}
slices.SortFunc(plans, func(a, b bodyRewrite) int { return cmp.Compare(a.articleId, b.articleId) })
return plans, nil
}
func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
for applied, plan := range rewrites {
if _, err := u.articleRepository.UpdateBody(plan.articleId, plan.rewritten); err != nil {
u.restoreBodies(rewrites[:applied])
return err
}
}
return nil
}
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
for _, plan := range applied {
_, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original)
}
}
@@ -0,0 +1,25 @@
package useCases
import "RenewCMS/internal/domain/image"
type RenameMediaUseCase struct {
imageRepository image.Repository
}
func NewRenameMediaUseCase(imageRepository image.Repository) *RenameMediaUseCase {
return &RenameMediaUseCase{imageRepository}
}
func (u *RenameMediaUseCase) Rename(id uint32, stem string) (image.Image, error) {
validStem, err := image.ValidateFilenameStem(stem)
if err != nil {
return image.Image{}, err
}
current, err := u.imageRepository.Get(id)
if err != nil {
return image.Image{}, err
}
return u.imageRepository.Rename(id, validStem+image.ExtensionOf(current.Filename))
}
@@ -14,10 +14,6 @@ func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Artic
return g.articleRepository.UpdateBody(id, body) return g.articleRepository.UpdateBody(id, body)
} }
func (g *UpdateArticleUseCase) AddImage(articleId uint32, imageId uint32) error {
return g.articleRepository.AddImage(articleId, imageId)
}
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline) return g.articleRepository.UpdateIsOnline(id, isOnline)
} }
@@ -0,0 +1,40 @@
package useCases
import (
"RenewCMS/internal/domain/user"
"errors"
)
type uploaderNames struct {
userRepository user.Repository
names map[uint32]string
}
func newUploaderNames(userRepository user.Repository) *uploaderNames {
return &uploaderNames{userRepository: userRepository, names: map[uint32]string{}}
}
func (u *uploaderNames) nameOf(userId uint32) (string, error) {
if userId == 0 {
return "", nil
}
if name, ok := u.names[userId]; ok {
return name, nil
}
uploader, err := u.userRepository.Get(userId)
if errors.Is(err, user.ErrUserNotFound) {
u.names[userId] = ""
return "", nil
}
if err != nil {
return "", err
}
name := uploader.DisplayName
if name == "" {
name = uploader.Username
}
u.names[userId] = name
return name, nil
}
+1 -1
View File
@@ -26,7 +26,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+19 -20
View File
@@ -2,25 +2,24 @@ package test
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"sort" "sort"
"strings"
"testing" "testing"
) )
type mockArticleRepo struct { type mockArticleRepo struct {
articles map[uint32]article.Article articles map[uint32]article.Article
images map[uint32]*imageDomain.Image failUpdateBodyFor map[uint32]error
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
createCalled bool createCalled bool
} }
func newMockArticleRepo() *mockArticleRepo { func newMockArticleRepo() *mockArticleRepo {
return &mockArticleRepo{ return &mockArticleRepo{
articles: make(map[uint32]article.Article), articles: make(map[uint32]article.Article),
images: make(map[uint32]*imageDomain.Image),
} }
} }
@@ -95,6 +94,9 @@ func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, e
if err := m.fail.trigger("UpdateBody"); err != nil { if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err return article.Article{}, err
} }
if err, ok := m.failUpdateBodyFor[id]; ok {
return article.Article{}, err
}
a, ok := m.articles[id] a, ok := m.articles[id]
if !ok { if !ok {
return article.Article{}, article.ErrArticleNotFound return article.Article{}, article.ErrArticleNotFound
@@ -128,21 +130,18 @@ func (m *mockArticleRepo) Delete(id uint32) error {
return nil return nil
} }
func (m *mockArticleRepo) AddImage(articleId uint32, imageId uint32) error { func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Article, error) {
if err := m.fail.trigger("AddImage"); err != nil { if err := m.fail.trigger("FindByBodyContaining"); err != nil {
return err return nil, err
} }
a, ok := m.articles[articleId] result := make([]article.Article, 0)
if !ok { for _, id := range m.sortedIds() {
return article.ErrArticleNotFound a := m.articles[id]
if strings.Contains(a.Body, filename) {
result = append(result, a)
}
} }
img, ok := m.images[imageId] return result, nil
if !ok {
return imageDomain.ErrImageNotFound
}
a.Images = append(a.Images, img)
m.articles[articleId] = a
return nil
} }
var _ article.Repository = &mockArticleRepo{} var _ article.Repository = &mockArticleRepo{}
+1 -1
View File
@@ -29,7 +29,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
useCases.NewCreateArticleUseCase(repo), useCases.NewCreateArticleUseCase(repo),
useCases.NewGetArticleUseCase(repo), useCases.NewGetArticleUseCase(repo),
useCases.NewListArticlesUseCase(repo), useCases.NewListArticlesUseCase(repo),
useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo), useCases.NewUpdateArticleUseCase(repo),
getUserUseCase, getUserUseCase,
) )
+8 -30
View File
@@ -3,7 +3,6 @@ package test
import ( import (
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -24,7 +23,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
@@ -43,16 +42,9 @@ func TestArticlesListHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("GET /v1/article/ returns each article's images array populated", func(t *testing.T) { t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[4] = domainArticle.Article{ repo.articles[4] = domainArticle.Article{ID: 4, Title: "Winter checklist for timber windows"}
ID: 4,
Title: "Winter checklist for timber windows",
Images: []*domainImage.Image{
{ID: 10, ArticleID: 4},
{ID: 11, ArticleID: 4},
},
}
router := newArticlesListRouter(repo, token) router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
@@ -64,29 +56,15 @@ func TestArticlesListHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", rec.Code) t.Fatalf("expected status 200, got %d", rec.Code)
} }
var got []domainArticle.Article var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 {
var found *domainArticle.Article t.Fatalf("expected 1 article, got %d", len(got))
for i := range got {
if got[i].ID == 4 {
found = &got[i]
}
} }
if found == nil { if _, present := got[0]["images"]; present {
t.Fatalf("expected article id 4 in response, got %v", got) t.Errorf("expected no images field, got %v", got[0]["images"])
}
if len(found.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(found.Images))
}
ids := map[uint32]bool{}
for _, img := range found.Images {
ids[img.ID] = true
}
if !ids[10] || !ids[11] {
t.Errorf("expected images 10 and 11, got %v", found.Images)
} }
}) })
} }
-230
View File
@@ -1,230 +0,0 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func seedArticleImages(imageRepo *mockImageRepo, ids ...uint32) []*domainImage.Image {
images := make([]*domainImage.Image, len(ids))
for i, id := range ids {
imageRepo.images[id] = domainImage.Image{ID: id}
seeded := imageRepo.images[id]
images[i] = &seeded
}
return images
}
func TestDeleteArticleUseCaseCascadesImageDeletion(t *testing.T) {
t.Run("deletes every image before deleting the article, in Images slice order", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
if err := uc.DeleteArticle(1); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 2 || got[0] != 10 || got[1] != 11 {
t.Errorf("expected image Delete to be called with [10 11] in order, got %v", got)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
})
t.Run("stops at the first image Delete failure and leaves the article untouched", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(3)
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected errRepositoryFailure, got %v", err)
}
if got := imageRepo.deleteCalls; len(got) != 1 || got[0] != 20 {
t.Errorf("expected image Delete to be called only once with 20, got %v", got)
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
t.Run("propagates ErrArticleNotFound without touching the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
uc := useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)
err := uc.DeleteArticle(9999)
if !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Fatalf("expected article.ErrArticleNotFound, got %v", err)
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
}
func newDeleteArticleCascadesImagesRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle)
})
})
})
return r
}
func TestDeleteArticleCascadesImages(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Deleting an article with images removes the article and every one of its images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10, 11)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; exists {
t.Errorf("expected article 1 to have been deleted")
}
if _, exists := imageRepo.images[10]; exists {
t.Errorf("expected image 10 to have been deleted")
}
if _, exists := imageRepo.images[11]; exists {
t.Errorf("expected image 11 to have been deleted")
}
})
t.Run("Deleting an article with no images still succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[2] = domainArticle.Article{ID: 2}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/2", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, exists := articleRepo.articles[2]; exists {
t.Errorf("expected article 2 to have been deleted")
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an unknown article still answers 404 and deletes no images", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected no call to the image repository's Delete method, got %v", imageRepo.deleteCalls)
}
})
t.Run("Deleting an article still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Images: seedArticleImages(imageRepo, 10)}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, exists := articleRepo.articles[1]; !exists {
t.Errorf("expected article 1 to still be present")
}
if _, exists := imageRepo.images[10]; !exists {
t.Errorf("expected image 10 to still be present")
}
})
t.Run("A failure deleting one of an article's images aborts the whole delete", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
articleRepo.articles[3] = domainArticle.Article{ID: 3, Images: seedArticleImages(imageRepo, 20, 21)}
imageRepo.failDeleteFor = map[uint32]error{20: errRepositoryFailure}
router := newDeleteArticleCascadesImagesRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/3", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
if rec.Body.String() != "Failed to delete article\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if _, exists := articleRepo.articles[3]; !exists {
t.Errorf("expected article 3 to still be present")
}
if _, exists := imageRepo.images[21]; !exists {
t.Errorf("expected image 21 to still be present")
}
})
}
+84
View File
@@ -0,0 +1,84 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"reflect"
"testing"
)
func TestDeleteArticleKeepsImages(t *testing.T) {
newEnv := func(t *testing.T) *mediaEnv {
env := newMediaEnv(t)
env.seedImage(10, "ten.png", 1, "image/png", 1, 1, hashA, 1)
env.seedImage(11, "eleven.png", 1, "image/png", 1, 1, hashB, 1)
env.seedArticle(1, "Kitchen Renovation", true, env.urlOf(10)+" "+env.urlOf(11))
return env
}
t.Run("Deleting an article leaves every image it used", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", mediaVerifiedUserId)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", rec.Code)
}
if _, ok := env.articles.articles[1]; ok {
t.Errorf("expected article 1 to be gone")
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected image Delete to never be called, got %v", env.images.deleteCalls)
}
})
t.Run("Deleting an unknown article is 404 and touches no image", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/9999", "", mediaVerifiedUserId)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("expected 404 Article not found, got %d %q", rec.Code, rec.Body.String())
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected the image repository to be untouched")
}
})
t.Run("Deleting still requires authentication", func(t *testing.T) {
env := newEnv(t)
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", 0)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, ok := env.articles.articles[1]; !ok {
t.Errorf("expected article 1 to still exist")
}
if _, ok := env.images.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("An article repository failure is 500 and everything stays", func(t *testing.T) {
env := newEnv(t)
bodiesBefore := env.bodies()
env.articles.fail.failNext("Delete")
rec := env.send(t, http.MethodDelete, "/v1/article/1", "", mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Failed to delete article\n" {
t.Errorf("expected 500 Failed to delete article, got %d %q", rec.Code, rec.Body.String())
}
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
t.Errorf("expected the article to stay")
}
expectIds(t, env.imageIds(), []uint32{10, 11})
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected the image repository to be untouched")
}
})
}
+1 -1
View File
@@ -27,7 +27,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+144 -8
View File
@@ -3,36 +3,86 @@ package test
import ( import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors"
"io" "io"
"sort"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
) )
var mockImageEpoch = time.Date(2026, time.June, 1, 12, 0, 0, 0, time.UTC)
var mockImageExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
type mockImageRepo struct { type mockImageRepo struct {
images map[uint32]imageDomain.Image images map[uint32]imageDomain.Image
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
failDeleteFor map[uint32]error failDeleteFor map[uint32]error
deleteCalls []uint32 deleteCalls []uint32
createCalls int
lastContent []byte
} }
func newMockImageRepo() *mockImageRepo { func newMockImageRepo() *mockImageRepo {
return &mockImageRepo{images: make(map[uint32]imageDomain.Image)} return &mockImageRepo{images: make(map[uint32]imageDomain.Image)}
} }
func (m *mockImageRepo) sortedImages() []imageDomain.Image {
result := make([]imageDomain.Image, 0, len(m.images))
for _, img := range m.images {
result = append(result, img)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) { func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) {
m.createCalls++
if err := m.fail.trigger("Create"); err != nil { if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err return imageDomain.Image{}, err
} }
if input.Content != nil { if input.Content != nil {
_, _ = io.ReadAll(input.Content) m.lastContent, _ = io.ReadAll(input.Content)
}
for m.images[m.nextID+1].ID != 0 {
m.nextID++
} }
m.nextID++ m.nextID++
img := imageDomain.Image{ID: m.nextID, Path: "/static/uploadedImages/" + input.Filename} img := imageDomain.Image{
ID: m.nextID,
Path: "/static/uploadedImages/" + strconv.Itoa(int(m.nextID)) + mockImageExtensions[input.ContentType],
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
CreatedAt: mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute),
}
m.images[img.ID] = img m.images[img.ID] = img
return img, nil return img, nil
} }
func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
if err := m.fail.trigger("Get"); err != nil {
return imageDomain.Image{}, err
}
img, ok := m.images[id]
if !ok {
return imageDomain.Image{}, imageDomain.ErrImageNotFound
}
return img, nil
}
func (m *mockImageRepo) Delete(id uint32) error { func (m *mockImageRepo) Delete(id uint32) error {
m.deleteCalls = append(m.deleteCalls, id) m.deleteCalls = append(m.deleteCalls, id)
if err := m.fail.trigger("Delete"); err != nil { if err := m.fail.trigger("Delete"); err != nil {
@@ -48,16 +98,96 @@ func (m *mockImageRepo) Delete(id uint32) error {
return nil return nil
} }
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if query.Search != "" && !strings.Contains(strings.ToLower(img.Filename), strings.ToLower(query.Search)) {
continue
}
if query.Hash != "" && img.SHA256 != query.Hash {
continue
}
result = append(result, img)
}
return result, nil
}
func (m *mockImageRepo) FindByHash(hash string) ([]imageDomain.Image, error) {
if err := m.fail.trigger("FindByHash"); err != nil {
return nil, err
}
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if hash != "" && img.SHA256 == hash {
result = append(result, img)
}
}
return result, nil
}
func (m *mockImageRepo) Rename(id uint32, filename string) (imageDomain.Image, error) {
if err := m.fail.trigger("Rename"); err != nil {
return imageDomain.Image{}, err
}
img, ok := m.images[id]
if !ok {
return imageDomain.Image{}, imageDomain.ErrImageNotFound
}
img.Filename = filename
m.images[id] = img
return img, nil
}
func (m *mockImageRepo) hashCounts() map[string]int {
counts := map[string]int{}
for _, img := range m.images {
if img.SHA256 != "" {
counts[img.SHA256]++
}
}
return counts
}
func (m *mockImageRepo) ListDuplicates() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListDuplicates"); err != nil {
return nil, err
}
counts := m.hashCounts()
result := make([]imageDomain.Image, 0)
for _, img := range m.sortedImages() {
if img.SHA256 != "" && counts[img.SHA256] > 1 {
result = append(result, img)
}
}
return result, nil
}
func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
if err := m.fail.trigger("Counts"); err != nil {
return imageDomain.Counts{}, err
}
groups := 0
for _, count := range m.hashCounts() {
if count > 1 {
groups++
}
}
return imageDomain.Counts{All: len(m.images), DuplicateGroups: groups}, nil
}
var _ imageDomain.Repository = &mockImageRepo{} var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) { func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
deleteUc := useCases.NewDeleteImageUseCase(repo) deleteUc := useCases.NewDeleteMediaUseCase(repo)
t.Run("Create Image", func(t *testing.T) { t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{ input := imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"), Content: strings.NewReader(string(pngBytes(t, 3, 2))),
Filename: "test.png", Filename: "test.png",
ContentType: "image/png", ContentType: "image/png",
} }
@@ -69,21 +199,27 @@ func TestImageUseCases(t *testing.T) {
if res.ID == 0 { if res.ID == 0 {
t.Errorf("Expected valid ID, got 0") t.Errorf("Expected valid ID, got 0")
} }
if !strings.Contains(res.Path, "test.png") { if res.Filename != "test.png" {
t.Errorf("Path mismatch: %s", res.Path) t.Errorf("Filename mismatch: %s", res.Filename)
} }
}) })
t.Run("Delete Image", func(t *testing.T) { t.Run("Delete Image", func(t *testing.T) {
repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"} repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"}
err := deleteUc.DeleteImage(99) err := deleteUc.Delete(99)
if err != nil { if err != nil {
t.Fatalf("DeleteImage failed: %v", err) t.Fatalf("Delete failed: %v", err)
} }
if _, exists := repo.images[99]; exists { if _, exists := repo.images[99]; exists {
t.Errorf("Image 99 should have been deleted") t.Errorf("Image 99 should have been deleted")
} }
}) })
t.Run("Delete unknown image", func(t *testing.T) {
if err := deleteUc.Delete(12345); !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
} }
+33 -24
View File
@@ -3,10 +3,37 @@ package test
import ( import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors" "errors"
"testing" "testing"
) )
func validBytesFor(t *testing.T, contentType string) []byte {
t.Helper()
switch contentType {
case "image/png":
return pngBytes(t, 3, 2)
case "image/jpeg":
return jpegBytes(t, 3, 2)
case "image/webp":
return webpBytes(t)
default:
return []byte(svgFixture)
}
}
func paddedTo(data []byte, size int) []byte {
padding := byte(0)
if bytes.HasPrefix(data, []byte("<svg")) {
padding = ' '
}
return append(append([]byte{}, data...), bytes.Repeat([]byte{padding}, size-len(data))...)
}
func inputOf(content []byte, contentType string) imageDomain.ImageInput {
return imageDomain.ImageInput{Content: bytes.NewReader(content), Filename: "upload", ContentType: contentType}
}
func TestImageUploadRules(t *testing.T) { func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage accepts every allowed content type within the size limit", func(t *testing.T) { t.Run("CreateImage accepts every allowed content type within the size limit", func(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} { for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
@@ -14,10 +41,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
result, err := createUc.CreateImage(imageDomain.ImageInput{ result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
ContentType: contentType,
Size: imageDomain.MaxImageSize,
})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -36,10 +60,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf"))
ContentType: "application/pdf",
Size: 1024,
})
if !errors.Is(err, imageDomain.ErrImageTypeNotAllowed) { if !errors.Is(err, imageDomain.ErrImageTypeNotAllowed) {
t.Fatalf("expected ErrImageTypeNotAllowed, got %v", err) t.Fatalf("expected ErrImageTypeNotAllowed, got %v", err)
@@ -53,10 +74,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize + 1,
})
if !errors.Is(err, imageDomain.ErrImageTooLarge) { if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err) t.Fatalf("expected ErrImageTooLarge, got %v", err)
@@ -70,10 +88,7 @@ func TestImageUploadRules(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize,
})
if err != nil { if err != nil {
t.Fatalf("expected no error at the exact limit, got %v", err) t.Fatalf("expected no error at the exact limit, got %v", err)
} }
@@ -81,10 +96,7 @@ func TestImageUploadRules(t *testing.T) {
t.Fatalf("expected Create to be called once, got %d calls", len(repo.images)) t.Fatalf("expected Create to be called once, got %d calls", len(repo.images))
} }
_, err = createUc.CreateImage(imageDomain.ImageInput{ _, err = createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
ContentType: "image/png",
Size: imageDomain.MaxImageSize + 1,
})
if !errors.Is(err, imageDomain.ErrImageTooLarge) { if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err) t.Fatalf("expected ErrImageTooLarge, got %v", err)
} }
@@ -98,10 +110,7 @@ func TestImageUploadRules(t *testing.T) {
repo.fail.failNextWith("Create", errRepositoryFailure) repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo) createUc := useCases.NewCreateImageUseCase(repo)
_, err := createUc.CreateImage(imageDomain.ImageInput{ _, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png"))
ContentType: "image/png",
Size: 1024,
})
if !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the generic repository error, got %v", err) t.Fatalf("expected the generic repository error, got %v", err)
+72
View File
@@ -0,0 +1,72 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/base64"
"net/http"
"net/url"
"reflect"
"testing"
)
type mediaOrderRecordingArticleRepo struct {
*mockArticleRepo
updated []uint32
}
func (r *mediaOrderRecordingArticleRepo) UpdateBody(id uint32, body string) (domainArticle.Article, error) {
r.updated = append(r.updated, id)
return r.mockArticleRepo.UpdateBody(id, body)
}
func TestMediaCursorBoundaries(t *testing.T) {
seed := func(env *mediaEnv) {
env.seedImage(1, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
env.seedImage(2, "b.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
}
encode := func(raw string) string {
return url.QueryEscape(base64.RawURLEncoding.EncodeToString([]byte(raw)))
}
t.Run("A cursor at offset zero is accepted and starts from the first item", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:0"), "", mediaVerifiedUserId)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
})
t.Run("A negative cursor offset is refused", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:-1"), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
})
}
func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env := newMediaEnv(t)
env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
env.seedImage(3, "floorboards-copy.jpg", 20, "image/jpeg", 3, 2, hashB, 2)
for id := uint32(1); id <= 8; id++ {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder)
if _, err := uc.Merge(2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := []uint32{1, 2, 3, 4, 5, 6, 7, 8}; !reflect.DeepEqual(recorder.updated, want) {
t.Errorf("expected updates in id order %v, got %v", want, recorder.updated)
}
}
+160
View File
@@ -0,0 +1,160 @@
package test
import (
"RenewCMS/api"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const (
mediaVerifiedUserId uint32 = 1
mediaSecondUserId uint32 = 2
mediaUnverifiedUserId uint32 = 3
mediaStaleUserId uint32 = 4
)
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
users *mockUserRepo
token *jwtauth.JWTAuth
router http.Handler
}
func newMediaEnv(t *testing.T) *mediaEnv {
t.Helper()
validator.InitValidator()
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
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}
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles),
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),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
env.router = router
return env
}
func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: tokenString}
}
func (e *mediaEnv) serve(t *testing.T, req *http.Request, userId uint32) *httptest.ResponseRecorder {
t.Helper()
if userId != 0 {
req.AddCookie(e.cookie(t, userId, 0))
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *mediaEnv) send(t *testing.T, method string, path string, body string, userId uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return e.serve(t, req, userId)
}
func (e *mediaEnv) seedImage(id uint32, filename string, size int64, contentType string, width int, height int, hash string, uploadedBy uint32) {
extension := map[string]string{"image/jpeg": ".jpeg", "image/svg+xml": ".svg", "image/png": ".png"}[contentType]
e.images.images[id] = domainImage.Image{
ID: id,
Path: "/static/uploadedImages/img-" + strconv.Itoa(int(id)) + "-" + extension,
Filename: filename,
Size: size,
ContentType: contentType,
Width: width,
Height: height,
SHA256: hash,
UploadedByID: uploadedBy,
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
}
if id > e.images.nextID {
e.images.nextID = id
}
}
func (e *mediaEnv) urlOf(id uint32) string {
return e.images.images[id].Path
}
func (e *mediaEnv) seedArticle(id uint32, title string, online bool, body string) {
e.articles.articles[id] = domainArticle.Article{ID: id, Title: title, IsOnline: online, Body: body}
if id > e.articles.nextID {
e.articles.nextID = id
}
}
func (e *mediaEnv) bodies() map[uint32]string {
result := map[uint32]string{}
for id, a := range e.articles.articles {
result[id] = a.Body
}
return result
}
func (e *mediaEnv) imageIds() []uint32 {
ids := make([]uint32, 0)
for _, img := range e.images.sortedImages() {
ids = append(ids, img.ID)
}
return ids
}
func decodeObject(t *testing.T, body string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Fatalf("failed to decode %q: %v", body, err)
}
return decoded
}
func numberOf(value any) uint32 {
number, _ := value.(float64)
return uint32(number)
}
+103
View File
@@ -0,0 +1,103 @@
package test
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
)
const webpFixtureBase64 = "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=="
const svgFixture = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>`
func solidImage(width int, height int) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
canvas.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 200, A: 255})
}
}
return canvas
}
func pngBytes(t *testing.T, width int, height int) []byte {
t.Helper()
buffer := &bytes.Buffer{}
if err := png.Encode(buffer, solidImage(width, height)); err != nil {
t.Fatalf("failed to encode png: %v", err)
}
return buffer.Bytes()
}
func jpegBytes(t *testing.T, width int, height int) []byte {
t.Helper()
buffer := &bytes.Buffer{}
if err := jpeg.Encode(buffer, solidImage(width, height), nil); err != nil {
t.Fatalf("failed to encode jpeg: %v", err)
}
return buffer.Bytes()
}
func webpBytes(t *testing.T) []byte {
t.Helper()
data, err := base64.StdEncoding.DecodeString(webpFixtureBase64)
if err != nil {
t.Fatalf("failed to decode webp fixture: %v", err)
}
return data
}
func paddedPngBytes(t *testing.T, size int) []byte {
t.Helper()
data := pngBytes(t, 3, 2)
return append(data, bytes.Repeat([]byte{0}, size-len(data))...)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func newMultipartFileRequest(t *testing.T, method string, url string, filename string, contentType string, content []byte) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.Copy(part, bytes.NewReader(content)); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(method, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func newMultipartWithoutFileRequest(t *testing.T, url string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
File diff suppressed because it is too large. Load diff
+253
View File
@@ -0,0 +1,253 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"gorm.io/gorm"
)
func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, string) {
t.Helper()
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Article{}, &models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
uploadDir := t.TempDir()
return db, persistence.NewImageRepository(db, uploadDir), uploadDir
}
func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename string, content []byte, contentType string) domainImage.Image {
t.Helper()
created, err := repo.Create(domainImage.ImageInput{
Content: bytes.NewReader(content),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
Width: 3,
Height: 2,
SHA256: sha256Hex(content),
})
if err != nil {
t.Fatalf("Create failed: %v", err)
}
return created
}
func TestMediaImageRepository(t *testing.T) {
t.Run("Create stores the file and the metadata and returns the assigned id", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
content := pngBytes(t, 3, 2)
created := createStoredImage(t, repo, "a.png", content, "image/png")
if created.ID == 0 || created.Filename != "a.png" || created.SHA256 != sha256Hex(content) || created.Width != 3 || created.Size != int64(len(content)) {
t.Errorf("unexpected image %+v", created)
}
onDisk, err := os.ReadFile(filepath.Join(uploadDir, filepath.Base(created.Path)))
if err != nil || !bytes.Equal(onDisk, content) {
t.Errorf("expected the file on disk, got %v", err)
}
got, err := repo.Get(created.ID)
if err != nil || got.SHA256 != created.SHA256 {
t.Errorf("Get returned %+v, %v", got, err)
}
})
t.Run("Get reports a missing image as ErrImageNotFound", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
if _, err := repo.Get(9); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
t.Run("Delete removes the file and the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(uploadDir, filepath.Base(created.Path))); !os.IsNotExist(err) {
t.Errorf("expected the file to be gone")
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("A file already missing from disk still deletes the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := os.Remove(filepath.Join(uploadDir, filepath.Base(created.Path))); err != nil {
t.Fatalf("failed to remove the file: %v", err)
}
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("List, FindByHash, Rename, ListDuplicates and Counts", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
same := pngBytes(t, 3, 2)
first := createStoredImage(t, repo, "North-Wall.png", same, "image/png")
second := createStoredImage(t, repo, "copy.png", same, "image/png")
createStoredImage(t, repo, "other.png", pngBytes(t, 4, 4), "image/png")
found, err := repo.List(domainImage.SearchQuery{Search: "NORTH"})
if err != nil || len(found) != 1 || found[0].ID != first.ID {
t.Errorf("unexpected search result %+v, %v", found, err)
}
byHash, err := repo.List(domainImage.SearchQuery{Hash: sha256Hex(same)})
if err != nil || len(byHash) != 2 {
t.Errorf("unexpected hash result %+v, %v", byHash, err)
}
sameContent, err := repo.FindByHash(sha256Hex(same))
if err != nil || len(sameContent) != 2 || sameContent[1].ID != second.ID {
t.Errorf("unexpected FindByHash result %+v, %v", sameContent, err)
}
if none, err := repo.FindByHash(""); err != nil || len(none) != 0 {
t.Errorf("expected no result for an empty hash, got %+v, %v", none, err)
}
duplicates, err := repo.ListDuplicates()
if err != nil || len(duplicates) != 2 {
t.Errorf("unexpected duplicates %+v, %v", duplicates, err)
}
counts, err := repo.Counts()
if err != nil || counts.All != 3 || counts.DuplicateGroups != 1 {
t.Errorf("unexpected counts %+v, %v", counts, err)
}
renamed, err := repo.Rename(first.ID, "renamed.png")
if err != nil || renamed.Filename != "renamed.png" || renamed.Path != first.Path {
t.Errorf("unexpected rename result %+v, %v", renamed, err)
}
if _, err := repo.Rename(9999, "x.png"); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
}
func TestMediaArticleRepositoryFindsBodiesContainingAFilename(t *testing.T) {
db, _, _ := newMediaPersistence(t)
repo := persistence.NewArticleRepository(db)
if _, err := repo.Create(domainArticle.Article{Title: "Kitchen Renovation", Body: "see /static/uploadedImages/aaa.png"}); err != nil {
t.Fatalf("failed to seed article: %v", err)
}
if _, err := repo.Create(domainArticle.Article{Title: "Backyard Notes", Body: "nothing"}); err != nil {
t.Fatalf("failed to seed article: %v", err)
}
found, err := repo.FindByBodyContaining("aaa.png")
if err != nil || len(found) != 1 || found[0].Title != "Kitchen Renovation" {
t.Errorf("unexpected result %+v, %v", found, err)
}
if none, err := repo.FindByBodyContaining("zzz.png"); err != nil || none == nil || len(none) != 0 {
t.Errorf("expected an empty non-nil slice, got %+v, %v", none, err)
}
}
func TestMediaImageBackfill(t *testing.T) {
seedLegacyRow := func(t *testing.T, db *gorm.DB, name string) models.Image {
t.Helper()
row := models.Image{Path: "/static/uploadedImages/" + name}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("failed to seed a legacy row: %v", err)
}
return row
}
writeFile := func(t *testing.T, dir string, name string, content []byte) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), content, 0666); err != nil {
t.Fatalf("failed to write %s: %v", name, err)
}
}
t.Run("Existing rows get metadata and hash from the files on disk", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
png, svg := pngBytes(t, 3, 2), []byte(svgFixture)
writeFile(t, uploadDir, "one.png", png)
writeFile(t, uploadDir, "two.svg", svg)
pngRow, svgRow := seedLegacyRow(t, db, "one.png"), seedLegacyRow(t, db, "two.svg")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
gotPng, _ := repo.Get(pngRow.ID)
if gotPng.Filename != "one.png" || gotPng.Size != int64(len(png)) || gotPng.ContentType != "image/png" || gotPng.Width != 3 || gotPng.Height != 2 || gotPng.SHA256 != sha256Hex(png) || gotPng.UploadedByID != 0 {
t.Errorf("unexpected png row %+v", gotPng)
}
gotSvg, _ := repo.Get(svgRow.ID)
if gotSvg.ContentType != "image/svg+xml" || gotSvg.Width != 0 || gotSvg.Height != 0 || gotSvg.SHA256 != sha256Hex(svg) {
t.Errorf("unexpected svg row %+v", gotSvg)
}
})
t.Run("A row whose file is missing does not stop the boot", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
row := seedLegacyRow(t, db, "gone.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
got, _ := repo.Get(row.ID)
if got.Filename != "" || got.SHA256 != "" {
t.Errorf("expected the row to be left as is, got %+v", got)
}
})
t.Run("An unreadable file leaves its row as is and the others are still filled", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "broken.png", []byte("not an image"))
writeFile(t, uploadDir, "fine.png", pngBytes(t, 3, 2))
broken, fine := seedLegacyRow(t, db, "broken.png"), seedLegacyRow(t, db, "fine.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
if got, _ := repo.Get(broken.ID); got.Filename != "" {
t.Errorf("expected the broken row to be left as is, got %+v", got)
}
if got, _ := repo.Get(fine.ID); got.Filename != "fine.png" {
t.Errorf("expected the fine row to be filled, got %+v", got)
}
})
t.Run("The backfill is idempotent", func(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "one.png", pngBytes(t, 3, 2))
row := seedLegacyRow(t, db, "one.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("backfill failed: %v", err)
}
afterFirst, _ := repo.Get(row.ID)
if _, err := repo.Rename(row.ID, "renamed.png"); err != nil {
t.Fatal(err)
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
t.Fatalf("second backfill failed: %v", err)
}
afterSecond, _ := repo.Get(row.ID)
if afterSecond.Filename != "renamed.png" || afterSecond.SHA256 != afterFirst.SHA256 {
t.Errorf("expected the second run to change nothing, got %+v", afterSecond)
}
})
}
+1 -1
View File
@@ -129,7 +129,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+1 -1
View File
@@ -45,7 +45,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+1 -1
View File
@@ -27,7 +27,7 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
-196
View File
@@ -1,196 +0,0 @@
package test
import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRemoveArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Delete("/{id}/image/{imageId}", imageHandler.DeleteImage)
})
})
})
})
return r
}
func setRemoveImageJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
func TestRemoveArticleImageHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Removing an image from an article it belongs to succeeds", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image from an unknown article answers 404 and never touches the image repository", func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/9999/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.deleteCalls) != 0 {
t.Errorf("expected Delete to never be called, got %d calls", len(imageRepo.deleteCalls))
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("Removing an unknown image from an existing article answers 404", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/9999", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ImageNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("Removing an image id that belongs to a different article still succeeds, because the check is shallow", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.articles[2] = domainArticle.Article{ID: 2, Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body %q", rec.Code, rec.Body.String())
}
if _, ok := imageRepo.images[10]; ok {
t.Errorf("expected image 10 to have been removed")
}
})
t.Run("Removing an image still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Images: []*domainImage.Image{{ID: 10}}}
imageRepo := newMockImageRepo()
imageRepo.images[10] = domainImage.Image{ID: 10, Path: "/static/uploadedImages/before.png"}
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/1/image/10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
if _, ok := imageRepo.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
})
t.Run("A malformed id in the URL answers 400 before either repository is touched", func(t *testing.T) {
testCases := []struct {
name string
articleId string
imageId string
}{
{"malformed article id", "abc", "10"},
{"malformed image id", "1", "abc"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
articleRepo := newMockArticleRepo()
imageRepo := newMockImageRepo()
router := newRemoveArticleImageRouter(articleRepo, imageRepo, token)
req := httptest.NewRequest(http.MethodDelete, "/v1/article/"+tc.articleId+"/image/"+tc.imageId, nil)
setRemoveImageJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
})
}
+6 -2
View File
@@ -136,7 +136,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
@@ -144,7 +144,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo), SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(imageRepo), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
+97 -280
View File
@@ -1,152 +1,59 @@
package test package test
import ( import (
"RenewCMS/api/handlers/image"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"github.com/go-chi/chi/v5" domainImage "RenewCMS/internal/domain/image"
"github.com/go-chi/jwtauth/v5"
) )
func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/{id}/image", imageHandler.PostImage)
})
})
})
})
return r
}
func setUploadJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) {
t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
func newMultipartImageUploadRequest(t *testing.T, url string, filename string, contentType string) *http.Request {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.WriteString(part, "fake-image-data"); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestUploadArticleImageHttp(t *testing.T) { func TestUploadArticleImageHttp(t *testing.T) {
validator.InitValidator() newEnv := func(t *testing.T) *mediaEnv {
token := jwtauth.New("HS256", []byte("test-secret"), nil) env := newMediaEnv(t)
env.seedArticle(1, "Backyard Renovation Update", true, "unchanged body")
return env
}
t.Run("Uploading an allowed image type to an existing article succeeds", func(t *testing.T) { t.Run("Uploading through the alias creates a library image", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.images[1] = &domainImage.Image{ID: 1, Path: "/static/uploadedImages/before.png"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/1/image", "before.png", "image/png", pngBytes(t, 3, 2))
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String()) t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
} }
got := decodeObject(t, rec.Body.String())
var got map[string]any if len(env.images.images) != 1 {
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("expected exactly one library image, got %d", len(env.images.images))
t.Fatalf("failed to decode response: %v", err)
} }
if got["location"] != "/static/uploadedImages/before.png" { created := env.images.images[1]
t.Errorf("expected location %q, got %q", "/static/uploadedImages/before.png", got["location"]) if numberOf(got["id"]) != created.ID {
t.Errorf("expected id %d, got %v", created.ID, got["id"])
}
if got["location"] != created.Path {
t.Errorf("expected location %q, got %v", created.Path, got["location"])
}
if created.UploadedByID != mediaVerifiedUserId {
t.Errorf("expected UploadedByID 1, got %d", created.UploadedByID)
} }
if len(imageRepo.images) != 1 { listing := env.send(t, http.MethodGet, "/v1/media/", "", mediaVerifiedUserId)
t.Fatalf("expected the image repository to gain exactly one entry, got %d", len(imageRepo.images)) items := decodeObject(t, listing.Body.String())["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["uploaded_by"] != "Inès Duarte" {
t.Errorf("expected the media list to hold the image uploaded by user 1, got %v", items)
} }
var storedImageID uint32 if env.articles.articles[1].Body != "unchanged body" {
found := false t.Errorf("expected article 1 to be unchanged, got %+v", env.articles.articles[1])
for id, img := range imageRepo.images {
if img.Path == "/static/uploadedImages/before.png" {
storedImageID = id
found = true
}
}
if !found {
t.Fatalf("expected the image repository to contain the new image")
}
gotId, ok := got["id"].(float64)
if !ok || uint32(gotId) != storedImageID {
t.Errorf("expected id %v, got %v", storedImageID, got["id"])
}
updatedArticle := articleRepo.articles[1]
imageAttached := false
for _, img := range updatedArticle.Images {
if img.ID == storedImageID {
imageAttached = true
}
}
if !imageAttached {
t.Errorf("expected article 1 to reference the new image's id, got %+v", updatedArticle.Images)
} }
}) })
t.Run("Uploading to an unknown article answers 404 and creates no image", func(t *testing.T) { t.Run("An unknown article is 404 and creates no image", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/9999/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/9999/image", "before.png", "image/png", pngBytes(t, 3, 2))
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code) t.Fatalf("expected status 404, got %d", rec.Code)
@@ -154,111 +61,101 @@ func TestUploadArticleImageHttp(t *testing.T) {
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String()) t.Errorf("unexpected body: %q", rec.Body.String())
} }
if len(imageRepo.images) != 0 { if env.images.createCalls != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) t.Errorf("expected Create to never be called, got %d calls", env.images.createCalls)
} }
}) })
t.Run("Uploading a disallowed content type answers 400 before the article is touched", func(t *testing.T) { t.Run("Rejected files use the media status codes", func(t *testing.T) {
articleRepo := newMockArticleRepo() examples := []struct {
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} name string
imageRepo := newMockImageRepo() filename string
router := newUploadArticleImageRouter(articleRepo, imageRepo, token) contentType string
content []byte
status int
}{
{"notes.pdf as application/pdf", "notes.pdf", "application/pdf", []byte("%PDF"), http.StatusUnsupportedMediaType},
{"a valid PNG padded to 10485761 bytes", "huge.png", "image/png", paddedPngBytes(t, int(domainImage.MaxImageSize)+1), http.StatusRequestEntityTooLarge},
{"broken.png as image/png with text bytes", "broken.png", "image/png", []byte("not an image"), http.StatusBadRequest},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
env := newEnv(t)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "notes.pdf", "application/pdf") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/1/image", example.filename, example.contentType, example.content)
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != example.status {
t.Fatalf("expected status 400, got %d", rec.Code) t.Fatalf("expected status %d, got %d", example.status, rec.Code)
} }
if rec.Body.String() != "the file must be a PNG, JPEG, WEBP, or SVG image\n" { if env.images.createCalls != 0 {
t.Errorf("unexpected body: %q", rec.Body.String()) t.Errorf("expected Create to never be called, got %d calls", env.images.createCalls)
} }
if len(imageRepo.images) != 0 { })
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
if len(articleRepo.articles[1].Images) != 0 {
t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images)
} }
}) })
t.Run("Uploading an image still requires authentication", func(t *testing.T) { t.Run("The alias still requires authentication", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/1/image", "before.png", "image/png", pngBytes(t, 3, 2))
rec := httptest.NewRecorder() rec := env.serve(t, req, 0)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized { if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code) t.Fatalf("expected status 401, got %d", rec.Code)
} }
if len(imageRepo.images) != 0 { if env.images.createCalls != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) t.Errorf("expected Create to never be called, got %d calls", env.images.createCalls)
} }
if len(articleRepo.articles[1].Images) != 0 { })
t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images)
t.Run("The removed delete route is gone", func(t *testing.T) {
env := newEnv(t)
env.seedImage(10, "before.png", 10, "image/png", 3, 2, sha256Hex([]byte("x")), 1)
rec := env.send(t, http.MethodDelete, "/v1/article/1/image/10", "", mediaVerifiedUserId)
if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 404 or 405, got %d", rec.Code)
}
if _, ok := env.images.images[10]; !ok {
t.Errorf("expected image 10 to still exist")
}
if len(env.images.deleteCalls) != 0 {
t.Errorf("expected Delete to never be called, got %v", env.images.deleteCalls)
} }
}) })
t.Run("Uploading with a non-numeric article id answers 400", func(t *testing.T) { t.Run("Uploading with a non-numeric article id answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/abc/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/abc/image", "before.png", "image/png", pngBytes(t, 3, 2))
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Fatalf("expected status 400, got %d", rec.Code) t.Fatalf("expected 400 with the id message, got %d %q", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
} }
}) })
t.Run("Uploading without a file field answers 400", func(t *testing.T) { t.Run("Uploading without a file field answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
body := &bytes.Buffer{} rec := env.serve(t, newMultipartWithoutFileRequest(t, "/v1/article/1/image"), mediaVerifiedUserId)
writer := multipart.NewWriter(body)
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code) t.Fatalf("expected status 400, got %d", rec.Code)
} }
if len(imageRepo.images) != 0 { if env.images.createCalls != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) t.Errorf("expected Create to never be called, got %d calls", env.images.createCalls)
} }
}) })
t.Run("A repository failure while fetching the article answers 500", func(t *testing.T) { t.Run("A repository failure while fetching the article answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} env.articles.fail.failNext("Get")
articleRepo.fail.failNext("Get")
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/1/image", "before.png", "image/png", pngBytes(t, 3, 2))
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code) t.Fatalf("expected status 500, got %d", rec.Code)
@@ -266,94 +163,14 @@ func TestUploadArticleImageHttp(t *testing.T) {
}) })
t.Run("A repository failure while creating the image answers 500", func(t *testing.T) { t.Run("A repository failure while creating the image answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo() env := newEnv(t)
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} env.images.fail.failNext("Create")
imageRepo := newMockImageRepo()
imageRepo.fail.failNext("Create")
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") req := newMultipartFileRequest(t, http.MethodPost, "/v1/article/1/image", "before.png", "image/png", pngBytes(t, 3, 2))
setUploadJwtCookie(t, req, token) rec := env.serve(t, req, mediaVerifiedUserId)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError { if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code) t.Fatalf("expected status 500, got %d", rec.Code)
} }
}) })
t.Run("Uploading a file over the size limit answers 400", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="file"; filename="huge.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("failed to create multipart part: %v", err)
}
if _, err := io.Copy(part, strings.NewReader(strings.Repeat("a", int(domainImage.MaxImageSize)+1))); err != nil {
t.Fatalf("failed to write file content: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if rec.Body.String() != domainImage.ErrImageTooLarge.Error()+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if len(imageRepo.images) != 0 {
t.Errorf("expected no image to be created, got %d", len(imageRepo.images))
}
})
t.Run("A repository failure while attaching the image to the article answers 500", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.fail.failNext("AddImage")
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
})
t.Run("AddImage reporting a missing article after the initial lookup still answers 404", func(t *testing.T) {
articleRepo := newMockArticleRepo()
articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"}
articleRepo.fail.failNextWith("AddImage", domainArticle.ErrArticleNotFound)
imageRepo := newMockImageRepo()
router := newUploadArticleImageRouter(articleRepo, imageRepo, token)
req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png")
setUploadJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
} }
+14 -44
View File
@@ -5,6 +5,7 @@ import (
imageDomain "RenewCMS/internal/domain/image" imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
@@ -236,7 +237,6 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
newRepo := func() *mockArticleRepo { newRepo := func() *mockArticleRepo {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, Title: "Existing"} repo.articles[1] = article.Article{ID: 1, Title: "Existing"}
repo.images[1] = &imageDomain.Image{ID: 1, Path: "/static/one.png"}
return repo return repo
} }
@@ -281,47 +281,13 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
}) })
t.Run("AddImage_article1_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
updated, err := repo.Get(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(updated.Images) != 1 || updated.Images[0].ID != 1 {
t.Errorf("expected image 1 attached, got %+v", updated.Images)
}
})
t.Run("AddImage_article9_image1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(9, 1)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
})
t.Run("AddImage_article1_image9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo)
err := uc.AddImage(1, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
})
} }
func TestDeleteArticleReportsMissingArticle(t *testing.T) { func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_1", func(t *testing.T) { t.Run("id_1", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1) err := uc.DeleteArticle(1)
if err != nil { if err != nil {
@@ -332,7 +298,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_9", func(t *testing.T) { t.Run("id_9", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(9) err := uc.DeleteArticle(9)
if !errors.Is(err, article.ErrArticleNotFound) { if !errors.Is(err, article.ErrArticleNotFound) {
@@ -345,7 +311,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1} repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete") repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo, newMockImageRepo()) uc := useCases.NewDeleteArticleUseCase(repo)
err := uc.DeleteArticle(1) err := uc.DeleteArticle(1)
if !errors.Is(err, errRepositoryFailure) { if !errors.Is(err, errRepositoryFailure) {
@@ -370,8 +336,12 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo) uc := useCases.NewCreateImageUseCase(repo)
content := []byte("fake-image-data")
if tc.valid {
content = validBytesFor(t, tc.contentType)
}
result, err := uc.CreateImage(imageDomain.ImageInput{ result, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"), Content: bytes.NewReader(content),
Filename: "test.img", Filename: "test.img",
ContentType: tc.contentType, ContentType: tc.contentType,
}) })
@@ -401,7 +371,7 @@ func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
uc := useCases.NewCreateImageUseCase(repo) uc := useCases.NewCreateImageUseCase(repo)
_, err := uc.CreateImage(imageDomain.ImageInput{ _, err := uc.CreateImage(imageDomain.ImageInput{
Content: strings.NewReader("data"), Content: bytes.NewReader(pngBytes(t, 3, 2)),
Filename: "test.png", Filename: "test.png",
ContentType: "image/png", ContentType: "image/png",
}) })
@@ -415,9 +385,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) { t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo) uc := useCases.NewDeleteMediaUseCase(repo)
err := uc.DeleteImage(1) err := uc.Delete(1)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@@ -429,9 +399,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) { t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteImageUseCase(repo) uc := useCases.NewDeleteMediaUseCase(repo)
err := uc.DeleteImage(9) err := uc.Delete(9)
if !errors.Is(err, imageDomain.ErrImageNotFound) { if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err) t.Errorf("expected image.ErrImageNotFound, got %v", err)
} }
+2 -4
View File
@@ -27,15 +27,13 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, imageRepo), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )
imageHandler := image.NewHandler( imageHandler := image.NewHandler(
useCases.NewCreateImageUseCase(imageRepo), useCases.NewCreateImageUseCase(imageRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
useCases.NewDeleteImageUseCase(imageRepo),
) )
r := chi.NewRouter() r := chi.NewRouter()
@@ -52,7 +50,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle) r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
r.Post("/{id}/image", imageHandler.PostImage) r.Post("/{id}/image", imageHandler.PostLegacyArticleImage)
}) })
}) })
}) })
+1 -22
View File
@@ -85,20 +85,6 @@ func TestTheArticleRepositoryReportsAMissingArticleAsADomainErrorOnAWrite(t *tes
}) })
} }
func TestAddImageReportsAMissingArticleAsADomainErrorNotAMissingImage(t *testing.T) {
repo := newSeededArticleRepository(t)
err := repo.AddImage(9, 1)
assertWriteResultIsArticleNotFound(t, err)
}
func TestAddImageReportsAMissingImageAsADomainErrorOnceTheArticleExists(t *testing.T) {
repo := newSeededArticleRepository(t)
err := repo.AddImage(1, 9)
assertWriteResultIsImageNotFound(t, err)
}
func newSeededUserRepository(t *testing.T) *persistence.UserRepository { func newSeededUserRepository(t *testing.T) *persistence.UserRepository {
t.Helper() t.Helper()
db := openInMemoryDb(t) db := openInMemoryDb(t)
@@ -205,13 +191,6 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
if err := articleRepo.Delete(9); !errors.Is(err, domainArticle.ErrArticleNotFound) { if err := articleRepo.Delete(9); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err) t.Errorf("expected article.ErrArticleNotFound, got %v", err)
} }
if err := articleRepo.AddImage(9, 1); !errors.Is(err, domainArticle.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
articleRepo.images[1] = &domainImage.Image{ID: 1}
if err := articleRepo.AddImage(1, 9); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice"} userRepo.users[1] = domainUser.User{ID: 1, Username: "alice"}
@@ -237,7 +216,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
useCases.NewCreateArticleUseCase(articleRepo), useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo), useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()), useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo), useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase, getUserUseCase,
) )