refac: authenticated user can edit an article's body; an unknown id returns 404

This commit is contained in:
fsylvain committed 2026-09-23 23:24:03 +02:00
1 parent 8822bdddd0
commit c16fdbd26d
5 files changed
+425 -9

No files matched your search

+48 -9
View File
@@ -109,6 +109,26 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
}
}
func writeArticleErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) {
articleJson, err := json.Marshal(article)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline bool) {
id, err := parseArticleId(r)
if err != nil {
@@ -118,23 +138,42 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline)
if err != nil {
if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
writeArticleErrorResponse(w, err)
return
}
articleJson, err := json.Marshal(updatedArticle)
writeArticleJsonResponse(w, updatedArticle)
}
type UpdateArticleBody struct {
Body string `json:"body" validate:"required,max=10000"`
}
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
id, err := parseArticleId(r)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return
}
if _, err := w.Write(articleJson); err != nil {
log.Printf("Failed to write response: %v", err)
var localArticle UpdateArticleBody
if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(localArticle); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body)
if err != nil {
writeArticleErrorResponse(w, err)
return
}
writeArticleJsonResponse(w, updatedArticle)
}
func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -41,6 +41,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
r.Patch("/{id}/publish", articleHandler.PublishArticle)
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
})
+103
View File
@@ -0,0 +1,103 @@
Feature: An authenticated user can edit an article's body
As an authenticated RenewCMS user managing the CMS
I want to change an existing article's body from an HTTP endpoint
So that a published or draft article's content can be corrected or updated without
deleting and recreating it
Background:
Given internal/domain/article.Repository already exposes
UpdateBody(id uint32, body string) (Article, error), and
UpdateArticleUseCase.UpdateBody delegates to it unchanged
And UpdateArticleUseCase is already built in api/dependecyInjection.go as
container.UpdateArticleUseCase, but before this slice no handler or route calls
UpdateBody
And this slice adds Handler.UpdateArticleBody, backed by
UpdateArticleUseCase.UpdateBody(id, body)
And this slice mounts PATCH /v1/article/{id} inside the same jwtauth Verifier +
Authenticator group as GET /v1/article/{id}, POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}/publish and
PATCH /v1/article/{id}/unpublish in api/router.go, so it also requires a valid
jwt cookie
And the request body is JSON {"body": "..."}, validated with
validate:"required,max=10000" on the Body field, the same tag PostArticle.Body
already carries
And a successful edit answers 200 with the JSON body of the updated article.Article,
the same shape GetArticle, PostArticle, PublishArticle and UnpublishArticle
already return; the article's title, is_online flag and images are unchanged
And an id that does not parse as a uint32 answers 400 with body
apperr.IdUint32ErrorMessage, the same as GetArticle, DeleteArticle,
PublishArticle and UnpublishArticle
And a body that is not valid JSON answers 400 with body apperr.BodyErrorMessage,
the same as PostArticle
And a body that fails its own required/max=10000 validation answers 400 with the
raw validator error text, the same as PostArticle already does for its own
fields; no new apperr message is introduced for this
And an id absent from the article repository answers 404 with body
apperr.ArticleNotFoundErrorMessage, backed by
errors.Is(err, article.ErrArticleNotFound), the same as DeleteArticle,
PublishArticle and UnpublishArticle already do per
features/write_not_found_errors.feature
And this slice does not add any way to change an article's title after creation,
per features/article_title_rules.feature
Scenario: Editing an existing article's body updates only the body
Given a valid jwt cookie for a user with id 1
And the article repository contains an online article titled
"Backyard Renovation Update" with id 1 and body "Old body text"
When the client sends PATCH /v1/article/1 with that cookie and body
{"body": "New body text"}
Then the response status is 200
And the response body is the article with id 1, "body": "New body text",
"title": "Backyard Renovation Update" and "is_online": true
Scenario: Editing an offline article's body leaves it offline
Given a valid jwt cookie for a user with id 1
And the article repository contains an offline article titled
"Draft: Kitchen Remodel" with id 2 and body "Draft notes"
When the client sends PATCH /v1/article/2 with that cookie and body
{"body": "Revised draft notes"}
Then the response status is 200
And the response body is the article with id 2, "body": "Revised draft notes"
and "is_online": false
Scenario: Editing an unknown article answers 404
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 PATCH /v1/article/9999 with that cookie and body
{"body": "New body text"}
Then the response status is 404
And the response body is the text "Article not found"
Scenario: A malformed id answers 400 before any repository call
Given a valid jwt cookie for a user with id 1
When the client sends PATCH /v1/article/not-a-number with that cookie and body
{"body": "New body text"}
Then the response status is 400
And the response body is the text apperr.IdUint32ErrorMessage
Scenario: A non-JSON request body answers 400 with the body error message
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 with that cookie and a body that is not
valid JSON
Then the response status is 400
And the response body is the text apperr.BodyErrorMessage
Scenario Outline: A body value that fails validation answers 400
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 with that cookie and body <payload>
Then the response status is 400
Examples:
| payload |
| {"body": ""} |
| {"body": "<10001-character string>"} |
Scenario: Editing an article requires authentication
Given no jwt cookie is sent
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 without a cookie and body
{"body": "New body text"}
Then the response status is 401
+58
View File
@@ -0,0 +1,58 @@
# QA procedure: edit_article_body
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`) as-is; its `UpdateBody` already
supports everything this slice needs, no fake changes expected.
3. Wire the real use cases over the fake: `useCases.NewCreateArticleUseCase`,
`useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`,
`useCases.NewDeleteArticleUseCase`, `useCases.NewUpdateArticleUseCase`. Build
`article.NewHandler(...)` (existing signature, already takes the update use case),
exposing the new `UpdateArticleBody` method.
4. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
5. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s current
shape (per `article_publishing.qa.md`): `GET /` under `jwtauth.Verifier` only;
`GET /{id}`, `POST /`, `DELETE /{id}`, `PATCH /{id}/publish`,
`PATCH /{id}/unpublish` under `jwtauth.Verifier` + `jwtauth.Authenticator`. Add
`PATCH /{id}` to that same authenticated group.
6. 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`.
7. Seed the repo directly via the map pattern already used elsewhere, e.g.
`repo.articles[1] = article.Article{ID: 1, Title: "Backyard Renovation Update", Body: "Old body text", IsOnline: true}`
and `repo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "Draft notes", IsOnline: false}`.
## HTTP checks
8. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": "New body text"}`,
repo seeded per step 7 -> status `200`; decode the JSON response and assert
`id == 1`, `body == "New body text"`, `title == "Backyard Renovation Update"`,
`is_online == true`; assert `repo.articles[1].Body` is now `"New body text"` and
`repo.articles[1].Title`/`IsOnline` are unchanged.
9. `PATCH /v1/article/2` with the valid cookie and JSON body
`{"body": "Revised draft notes"}`, repo seeded per step 7 -> status `200`; decode
the JSON response and assert `id == 2`, `body == "Revised draft notes"`,
`is_online == false`.
10. `PATCH /v1/article/9999` with the valid cookie and a valid JSON body, empty repo
id `9999` -> status `404`, body `"Article not found\n"`.
11. `PATCH /v1/article/not-a-number` with the valid cookie and a valid JSON body ->
status `400`, body matching `apperr.IdUint32ErrorMessage + "\n"`.
12. `PATCH /v1/article/1` with the valid cookie and a malformed JSON body (e.g. raw
string `not-json`), repo seeded with article id 1 -> status `400`, body matching
`apperr.BodyErrorMessage + "\n"`.
13. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": ""}`, repo
seeded with article id 1 -> status `400`; assert `repo.articles[1].Body` is
unchanged (nothing stored).
14. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": "<10001-char
string>"}`, repo seeded with article id 1 -> status `400`; assert
`repo.articles[1].Body` is unchanged.
15. `PATCH /v1/article/1` with **no** cookie, repo seeded with article id 1 -> status
`401`.
## Manual verification (not exercised by the Go test)
- SPA-visible behaviour: confirm the back-office article editor exposes a body-editing
control that calls `PATCH /v1/article/{id}` and reflects the updated body, against
the real embedded frontend and a real SQLite file. Not exercised by `task test`;
requires `task dev` or a built `task build` binary. (No such UI exists yet as of this
slice.)
- No SMTP involvement.
+215
View File
@@ -0,0 +1,215 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo),
)
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.Patch("/{id}", articleHandler.UpdateArticleBody)
})
})
})
})
return r
}
func newEditArticleBodyTestRepo() *mockArticleRepo {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: "Old body text", IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "Draft notes", IsOnline: false}
return repo
}
func setEditArticleBodyJwtCookie(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 newEditArticleBodyRequest(method, target string, body string) *http.Request {
return httptest.NewRequest(method, target, bytes.NewBufferString(body))
}
func TestEditArticleBodyHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("Editing an existing article's body updates only the body", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", `{"body": "New body text"}`)
setEditArticleBodyJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var got domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got.ID != 1 || got.Body != "New body text" || got.Title != "Backyard Renovation Update" || !got.IsOnline {
t.Errorf("unexpected article: %+v", got)
}
stored := repo.articles[1]
if stored.Body != "New body text" || stored.Title != "Backyard Renovation Update" || !stored.IsOnline {
t.Errorf("unexpected stored article: %+v", stored)
}
})
t.Run("Editing an offline article's body leaves it offline", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/2", `{"body": "Revised draft notes"}`)
setEditArticleBodyJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var got domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got.ID != 2 || got.Body != "Revised draft notes" || got.IsOnline {
t.Errorf("unexpected article: %+v", got)
}
})
t.Run("Editing an unknown article answers 404", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/9999", `{"body": "New body text"}`)
setEditArticleBodyJwtCookie(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())
}
})
t.Run("A malformed id answers 400 before any repository call", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/not-a-number", `{"body": "New body text"}`)
setEditArticleBodyJwtCookie(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())
}
})
t.Run("A non-JSON request body answers 400 with the body error message", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", `not valid json`)
setEditArticleBodyJwtCookie(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.BodyErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
validationCases := []struct {
name string
payload string
}{
{"empty body", `{"body": ""}`},
{"body over 10000 characters", `{"body": "` + strings.Repeat("a", 10001) + `"}`},
}
for _, tc := range validationCases {
t.Run("A body value that fails validation answers 400: "+tc.name, func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", tc.payload)
setEditArticleBodyJwtCookie(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 repo.articles[1].Body != "Old body text" {
t.Errorf("expected stored body to be unchanged, got %q", repo.articles[1].Body)
}
})
}
t.Run("A response write failure is logged", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", `{"body": "New body text"}`)
setEditArticleBodyJwtCookie(t, req, token)
var logOutput bytes.Buffer
defaultLogOutput := log.Writer()
log.SetOutput(&logOutput)
defer log.SetOutput(defaultLogOutput)
router.ServeHTTP(&erroringResponseWriter{}, req)
if !strings.Contains(logOutput.String(), "Failed to write response") {
t.Errorf("expected the write failure to be logged, got %q", logOutput.String())
}
})
t.Run("Editing an article requires authentication", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", `{"body": "New body text"}`)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
})
}