From c16fdbd26dd0aa3b21fa111ec3c57ef431781aaa Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 23 Sep 2026 23:24:03 +0200 Subject: [PATCH] refac: authenticated user can edit an article's body; an unknown id returns 404 --- api/handlers/article/articleHandler.go | 57 +++++-- api/router.go | 1 + features/edit_article_body.feature | 103 ++++++++++++ features/edit_article_body.qa.md | 58 +++++++ test/edit_article_body_http_test.go | 215 +++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 9 deletions(-) create mode 100644 features/edit_article_body.feature create mode 100644 features/edit_article_body.qa.md create mode 100644 test/edit_article_body_http_test.go diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index a67b526..dfaf8ff 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -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) { diff --git a/api/router.go b/api/router.go index 7cb3ea1..5a9d48b 100644 --- a/api/router.go +++ b/api/router.go @@ -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) }) diff --git a/features/edit_article_body.feature b/features/edit_article_body.feature new file mode 100644 index 0000000..0b701d2 --- /dev/null +++ b/features/edit_article_body.feature @@ -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 + 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 diff --git a/features/edit_article_body.qa.md b/features/edit_article_body.qa.md new file mode 100644 index 0000000..1701f14 --- /dev/null +++ b/features/edit_article_body.qa.md @@ -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. diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go new file mode 100644 index 0000000..3544152 --- /dev/null +++ b/test/edit_article_body_http_test.go @@ -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) + } + }) +}