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
|
GetUseCase *useCases.GetArticleUseCase
|
||||||
ListUseCase *useCases.ListArticlesUseCase
|
ListUseCase *useCases.ListArticlesUseCase
|
||||||
DeleteUseCase *useCases.DeleteArticleUseCase
|
DeleteUseCase *useCases.DeleteArticleUseCase
|
||||||
|
UpdateUseCase *useCases.UpdateArticleUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostArticle struct {
|
type PostArticle struct {
|
||||||
@@ -28,23 +29,29 @@ type PostArticle struct {
|
|||||||
Body string `json:"body" validate:"required,max=10000"`
|
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{
|
return &Handler{
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
GetUseCase: get,
|
GetUseCase: get,
|
||||||
ListUseCase: list,
|
ListUseCase: list,
|
||||||
DeleteUseCase: delete,
|
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)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
article, err := h.GetUseCase.GetOnlineArticle(uint32(id))
|
article, err := h.GetUseCase.GetOnlineArticle(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
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 {
|
func requestHasValidJwt(r *http.Request) bool {
|
||||||
token, _, err := jwtauth.FromContext(r.Context())
|
token, _, err := jwtauth.FromContext(r.Context())
|
||||||
return token != nil && err == nil
|
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) {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DeleteUseCase.DeleteArticle(uint32(id)); err != nil {
|
if err := h.DeleteUseCase.DeleteArticle(id); err != nil {
|
||||||
if errors.Is(err, domain.ErrArticleNotFound) {
|
if errors.Is(err, domain.ErrArticleNotFound) {
|
||||||
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|||||||
+3
-1
@@ -31,7 +31,7 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
|||||||
func NewArticleRouter(container *UseCases) http.Handler {
|
func NewArticleRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
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.Group(func(r chi.Router) {
|
||||||
r.Get("/", articleHandler.ListArticles)
|
r.Get("/", articleHandler.ListArticles)
|
||||||
@@ -41,6 +41,8 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
r.Get("/{id}", articleHandler.GetArticle)
|
r.Get("/{id}", articleHandler.GetArticle)
|
||||||
r.Post("/", articleHandler.PostArticle)
|
r.Post("/", articleHandler.PostArticle)
|
||||||
r.Delete("/{id}", articleHandler.DeleteArticle)
|
r.Delete("/{id}", articleHandler.DeleteArticle)
|
||||||
|
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
|
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||||
})
|
})
|
||||||
return r
|
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.NewGetArticleUseCase(repo),
|
||||||
useCases.NewListArticlesUseCase(repo),
|
useCases.NewListArticlesUseCase(repo),
|
||||||
useCases.NewDeleteArticleUseCase(repo),
|
useCases.NewDeleteArticleUseCase(repo),
|
||||||
|
useCases.NewUpdateArticleUseCase(repo),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
|||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
authHandler := auth.NewHandler(
|
authHandler := auth.NewHandler(
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
|
|||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
|
|||||||
useCases.NewGetArticleUseCase(articleRepo),
|
useCases.NewGetArticleUseCase(articleRepo),
|
||||||
useCases.NewListArticlesUseCase(articleRepo),
|
useCases.NewListArticlesUseCase(articleRepo),
|
||||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||||
|
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
Reference in new issue
Block a user