From 8822bdddd01b208114c88eadd5ab141dcc139a45 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 23 Sep 2026 22:54:44 +0200 Subject: [PATCH] refac: authenticated user can publish and unpublish an article; an unknown id returns 404 --- api/handlers/article/articleHandler.go | 53 ++++- api/router.go | 4 +- features/article_publishing.feature | 109 +++++++++ features/article_publishing.qa.md | 57 +++++ test/article_publishing_http_test.go | 317 +++++++++++++++++++++++++ test/article_title_rules_test.go | 1 + test/not_found_errors_test.go | 1 + test/public_article_listing_test.go | 1 + test/write_not_found_errors_test.go | 1 + 9 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 features/article_publishing.feature create mode 100644 features/article_publishing.qa.md create mode 100644 test/article_publishing_http_test.go diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index e08c0b2..a67b526 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -21,6 +21,7 @@ type Handler struct { GetUseCase *useCases.GetArticleUseCase ListUseCase *useCases.ListArticlesUseCase DeleteUseCase *useCases.DeleteArticleUseCase + UpdateUseCase *useCases.UpdateArticleUseCase } type PostArticle struct { @@ -28,23 +29,29 @@ type PostArticle struct { Body string `json:"body" validate:"required,max=10000"` } -func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase) *Handler { +func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler { return &Handler{ CreateUseCase: create, GetUseCase: get, ListUseCase: list, DeleteUseCase: delete, + UpdateUseCase: update, } } -func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { +func parseArticleId(r *http.Request) (uint32, error) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + return uint32(id), err +} + +func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { + id, err := parseArticleId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } - article, err := h.GetUseCase.GetOnlineArticle(uint32(id)) + article, err := h.GetUseCase.GetOnlineArticle(id) if err != nil { if errors.Is(err, domain.ErrArticleNotFound) { http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) @@ -102,6 +109,42 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline bool) { + id, err := parseArticleId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + 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) + return + } + + articleJson, err := json.Marshal(updatedArticle) + 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) PublishArticle(w http.ResponseWriter, r *http.Request) { + h.setIsOnline(w, r, true) +} + +func (h *Handler) UnpublishArticle(w http.ResponseWriter, r *http.Request) { + h.setIsOnline(w, r, false) +} + func requestHasValidJwt(r *http.Request) bool { token, _, err := jwtauth.FromContext(r.Context()) return token != nil && err == nil @@ -132,13 +175,13 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { } func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) { - id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + id, err := parseArticleId(r) if err != nil { http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) return } - if err := h.DeleteUseCase.DeleteArticle(uint32(id)); err != nil { + if err := h.DeleteUseCase.DeleteArticle(id); err != nil { if errors.Is(err, domain.ErrArticleNotFound) { http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) return diff --git a/api/router.go b/api/router.go index 016a110..7cb3ea1 100644 --- a/api/router.go +++ b/api/router.go @@ -31,7 +31,7 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) { func NewArticleRouter(container *UseCases) http.Handler { r := chi.NewRouter() - articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase) + articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase) r.Group(func(r chi.Router) { r.Get("/", articleHandler.ListArticles) @@ -41,6 +41,8 @@ func NewArticleRouter(container *UseCases) http.Handler { r.Get("/{id}", articleHandler.GetArticle) r.Post("/", articleHandler.PostArticle) r.Delete("/{id}", articleHandler.DeleteArticle) + r.Patch("/{id}/publish", articleHandler.PublishArticle) + r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) }) return r } diff --git a/features/article_publishing.feature b/features/article_publishing.feature new file mode 100644 index 0000000..368b7a5 --- /dev/null +++ b/features/article_publishing.feature @@ -0,0 +1,109 @@ +Feature: An authenticated user can publish and unpublish an article + + As an authenticated RenewCMS user managing the CMS + I want to flip an article's is_online flag from an HTTP endpoint + So that a draft can be published to the public site and a published article can be + pulled back offline, without deleting it + + Background: + Given internal/domain/article.Repository already exposes + UpdateIsOnline(id uint32, isOnline bool) (Article, error), and + UpdateArticleUseCase.UpdateIsOnline 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 it + And this slice adds Handler.PublishArticle and Handler.UnpublishArticle, backed by + UpdateArticleUseCase.UpdateIsOnline(id, true) and UpdateIsOnline(id, false) + And this slice mounts PATCH /v1/article/{id}/publish and + PATCH /v1/article/{id}/unpublish inside the same jwtauth Verifier + Authenticator + group as GET /v1/article/{id}, POST /v1/article/ and DELETE /v1/article/{id} in + api/router.go, so both require a valid jwt cookie + And a successful publish or unpublish answers 200 with the JSON body of the updated + article.Article, the same shape GetArticle and PostArticle already return + And an id that does not parse as a uint32 answers 400 with body + apperr.IdUint32ErrorMessage, the same as GetArticle and DeleteArticle + 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 already does per features/write_not_found_errors.feature + And GET /v1/article/ with no jwt cookie only returns online articles, per + features/public_article_listing.feature; this slice does not change that behavior, + only feeds it + + Scenario: Publishing an offline article makes it online + 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 + When the client sends PATCH /v1/article/2/publish with that cookie + Then the response status is 200 + And the response body is the article with id 2 and "is_online": true + + Scenario: Unpublishing an online article makes it offline + 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 + When the client sends PATCH /v1/article/1/unpublish with that cookie + Then the response status is 200 + And the response body is the article with id 1 and "is_online": false + + Scenario: Publishing an already-online article is idempotent + Given a valid jwt cookie for a user with id 1 + And the article repository contains an online article with id 1 + When the client sends PATCH /v1/article/1/publish with that cookie + Then the response status is 200 + And the response body is the article with id 1 and "is_online": true + And no error is returned + + Scenario: Unpublishing an already-offline article is idempotent + Given a valid jwt cookie for a user with id 1 + And the article repository contains an offline article with id 2 + When the client sends PATCH /v1/article/2/unpublish with that cookie + Then the response status is 200 + And the response body is the article with id 2 and "is_online": false + And no error is returned + + Scenario Outline: Publishing or unpublishing 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 + Then the response status is 404 + And the response body is the text "Article not found" + + Examples: + | action | + | publish | + | unpublish | + + Scenario Outline: 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 + Then the response status is 400 + And the response body is the text apperr.IdUint32ErrorMessage + + Examples: + | action | + | publish | + | unpublish | + + Scenario Outline: Publish and unpublish require 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 + Then the response status is 401 + + Examples: + | action | + | publish | + | unpublish | + + Scenario: Publishing an article makes it appear in the public online-only list + 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 + When the client sends PATCH /v1/article/2/publish with that cookie + And the client then sends GET /v1/article/ with no jwt cookie + Then the response status of the second request is 200 + And the response body of the second request is a JSON array containing the article with id 2 + + Scenario: Unpublishing an article removes it from the public online-only list + 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 + When the client sends PATCH /v1/article/1/unpublish with that cookie + And the client then sends GET /v1/article/ with no jwt cookie + Then the response status of the second request is 200 + And the article with id 1 is absent from the response body of the second request diff --git a/features/article_publishing.qa.md b/features/article_publishing.qa.md new file mode 100644 index 0000000..36dd516 --- /dev/null +++ b/features/article_publishing.qa.md @@ -0,0 +1,57 @@ +# QA procedure: article_publishing + +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 `UpdateIsOnline` and `FindByFilters` + already support 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(...)` extended to also take the update use case (matching whatever + `NewHandler` signature the coder lands on), exposing the new `PublishArticle` and + `UnpublishArticle` methods. +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 `public_article_listing.qa.md`): `GET /` under `jwtauth.Verifier` only; `GET /{id}`, + `POST /`, `DELETE /{id}` under `jwtauth.Verifier` + `jwtauth.Authenticator`. Add + `PATCH /{id}/publish` and `PATCH /{id}/unpublish` 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", IsOnline: true}` + and `repo.articles[2] = article.Article{ID: 2, Title: "Draft: Kitchen Remodel", IsOnline: false}`. + +## HTTP checks +8. `PATCH /v1/article/2/publish` with the valid cookie, repo seeded per step 7 -> status `200`; + decode the JSON body and assert `id == 2` and `is_online == true`; assert `repo.articles[2].IsOnline` + is now `true`. +9. `PATCH /v1/article/1/unpublish` with the valid cookie, repo seeded per step 7 -> status `200`; + decode the JSON body and assert `id == 1` and `is_online == false`; assert + `repo.articles[1].IsOnline` is now `false`. +10. `PATCH /v1/article/1/publish` with the valid cookie against an article already seeded + `IsOnline: true` -> status `200`, body `is_online == true`, no error (idempotency). +11. `PATCH /v1/article/2/unpublish` with the valid cookie against an article already seeded + `IsOnline: false` -> status `200`, body `is_online == false`, no error (idempotency). +12. `PATCH /v1/article/9999/publish` with the valid cookie, empty repo id `9999` -> status `404`, + body `"Article not found\n"`. Repeat for `PATCH /v1/article/9999/unpublish` -> same assertions. +13. `PATCH /v1/article/not-a-number/publish` with the valid cookie -> status `400`, body matching + `apperr.IdUint32ErrorMessage + "\n"`. Repeat for `.../unpublish`. +14. `PATCH /v1/article/1/publish` with **no** cookie, repo seeded with article id 1 -> status `401`. + Repeat for `PATCH /v1/article/1/unpublish` with no cookie -> status `401`. +15. Integration with the public listing: seed repo with an offline article id 2, call + `PATCH /v1/article/2/publish` with the valid cookie (assert `200`), then call + `GET /v1/article/` with no cookie and assert the decoded JSON array contains an element with + id `2`. +16. Mirror check: seed repo with an online article id 1, call `PATCH /v1/article/1/unpublish` with + the valid cookie (assert `200`), then call `GET /v1/article/` with no cookie and assert the + decoded JSON array does not contain an element with id `1`. + +## Manual verification (not exercised by the Go test) +- SPA-visible behaviour: confirm the back-office article editor exposes a publish/unpublish + control that calls the new endpoints and updates the visible online/offline state, 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 — this note applies once + the SPA is wired to the new endpoints.) +- No SMTP involvement. diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go new file mode 100644 index 0000000..e809336 --- /dev/null +++ b/test/article_publishing_http_test.go @@ -0,0 +1,317 @@ +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" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" +) + +func newArticlePublishingRouter(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.Get("/", articleHandler.ListArticles) + r.Group(func(r chi.Router) { + r.Use(jwtauth.Authenticator(token)) + r.Get("/{id}", articleHandler.GetArticle) + r.Post("/", articleHandler.PostArticle) + r.Delete("/{id}", articleHandler.DeleteArticle) + r.Patch("/{id}/publish", articleHandler.PublishArticle) + r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) + }) + }) + }) + }) + return r +} + +func newPublishingTestArticleRepo() *mockArticleRepo { + repo := newMockArticleRepo() + repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", IsOnline: true} + repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", IsOnline: false} + return repo +} + +func setPublishingJwtCookie(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 TestArticlePublishingHttp(t *testing.T) { + validator.InitValidator() + token := jwtauth.New("HS256", []byte("test-secret"), nil) + + t.Run("Publishing an offline article makes it online", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/2/publish", nil) + setPublishingJwtCookie(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.IsOnline { + t.Errorf("expected article 2 online, got %+v", got) + } + if !repo.articles[2].IsOnline { + t.Errorf("expected repository article 2 to be online") + } + }) + + t.Run("Unpublishing an online article makes it offline", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/1/unpublish", nil) + setPublishingJwtCookie(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.IsOnline { + t.Errorf("expected article 1 offline, got %+v", got) + } + if repo.articles[1].IsOnline { + t.Errorf("expected repository article 1 to be offline") + } + }) + + t.Run("Publishing an already-online article is idempotent", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/1/publish", nil) + setPublishingJwtCookie(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.IsOnline { + t.Errorf("expected article 1 online, got %+v", got) + } + }) + + t.Run("Unpublishing an already-offline article is idempotent", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/2/unpublish", nil) + setPublishingJwtCookie(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.IsOnline { + t.Errorf("expected article 2 offline, got %+v", got) + } + }) + + notFoundCases := []struct { + name string + action string + }{ + {"publish", "publish"}, + {"unpublish", "unpublish"}, + } + for _, tc := range notFoundCases { + t.Run("Publishing or unpublishing an unknown article answers 404: "+tc.name, func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/9999/"+tc.action, nil) + setPublishingJwtCookie(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()) + } + }) + } + + for _, tc := range notFoundCases { + t.Run("A malformed id answers 400 before any repository call: "+tc.name, func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/not-a-number/"+tc.action, nil) + setPublishingJwtCookie(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()) + } + }) + } + + for _, tc := range notFoundCases { + t.Run("Publish and unpublish require authentication: "+tc.action, func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/1/"+tc.action, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", rec.Code) + } + }) + } + + t.Run("Publishing an article makes it appear in the public online-only list", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + + req := httptest.NewRequest(http.MethodPatch, "/v1/article/2/publish", nil) + setPublishingJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + listReq := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", listRec.Code) + } + var articles []domainArticle.Article + if err := json.Unmarshal(listRec.Body.Bytes(), &articles); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + found := false + for _, a := range articles { + if a.ID == 2 { + found = true + } + } + if !found { + t.Errorf("expected article 2 in the public listing, got %+v", articles) + } + }) + + t.Run("Publishing an article does not log a write failure on success", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/2/publish", nil) + setPublishingJwtCookie(t, req, token) + rec := httptest.NewRecorder() + + var logBuf bytes.Buffer + previousOutput := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(previousOutput) + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if logBuf.Len() != 0 { + t.Errorf("expected no write-failure log, got %q", logBuf.String()) + } + }) + + t.Run("A repository failure answers 500", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + repo.fail.failNext("UpdateIsOnline") + router := newArticlePublishingRouter(repo, token) + req := httptest.NewRequest(http.MethodPatch, "/v1/article/1/publish", nil) + setPublishingJwtCookie(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("Unpublishing an article removes it from the public online-only list", func(t *testing.T) { + repo := newPublishingTestArticleRepo() + repo.articles[3] = domainArticle.Article{ID: 3, Title: "Still Online Article", IsOnline: true} + router := newArticlePublishingRouter(repo, token) + + req := httptest.NewRequest(http.MethodPatch, "/v1/article/1/unpublish", nil) + setPublishingJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + listReq := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", listRec.Code) + } + var articles []domainArticle.Article + if err := json.Unmarshal(listRec.Body.Bytes(), &articles); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + foundStillOnline := false + for _, a := range articles { + if a.ID == 1 { + t.Errorf("expected article 1 absent from the public listing, got %+v", articles) + } + if a.ID == 3 { + foundStillOnline = true + } + } + if !foundStillOnline { + t.Errorf("expected article 3 present in the public listing, got %+v", articles) + } + }) +} diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index 935fa85..de40354 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -26,6 +26,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) * useCases.NewGetArticleUseCase(repo), useCases.NewListArticlesUseCase(repo), useCases.NewDeleteArticleUseCase(repo), + useCases.NewUpdateArticleUseCase(repo), ) r := chi.NewRouter() diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index b79f785..7ceba79 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -129,6 +129,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep useCases.NewGetArticleUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo), ) hasher := hashing.NewBcryptHasher() authHandler := auth.NewHandler( diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 9e85b03..91d61f1 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -42,6 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth. useCases.NewGetArticleUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo), ) r := chi.NewRouter() diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index e7e9301..3d72eca 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -193,6 +193,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth useCases.NewGetArticleUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo), ) r := chi.NewRouter()