refac: authenticated user can publish and unpublish an article; an unknown id returns 404
This commit is contained in:
1 parent
790d4e6da6
commit
8822bdddd0
9 files changed
+538
-6
No files matched your search
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<action> 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/<action> 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/<action> 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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user