refac: article title rules are the same in the API request and the domain; an invalid title returns 400 instead of 500
This commit is contained in:
1 parent
ff8cd5f0ec
commit
4278d96882
9 files changed
+452
-3
No files matched your search
@@ -23,7 +23,7 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PostArticle struct {
|
type PostArticle struct {
|
||||||
Title string `json:"title" validate:"required,min=3,max=50"`
|
Title string `json:"title" validate:"required,min=3,max=200"`
|
||||||
Body string `json:"body" validate:"required,max=10000"`
|
Body string `json:"body" validate:"required,max=10000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +81,10 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
|
|||||||
Body: localArticle.Body,
|
Body: localArticle.Body,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrInvalidTitle) {
|
||||||
|
http.Error(w, apperr.ArticleTitleErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
http.Error(w, "Failed to create article", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ const LogsErrorMessage = "Access to the requested resource is forbidden due to i
|
|||||||
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
|
const BodyErrorMessage = "The request cannot be processed due to a mismatch in the format of the body."
|
||||||
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
||||||
const ArticleNotFoundErrorMessage = "Article not found"
|
const ArticleNotFoundErrorMessage = "Article not found"
|
||||||
|
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
Feature: The article title rule is the same in the request and the domain
|
||||||
|
|
||||||
|
As the maintainer of RenewCMS
|
||||||
|
I want PostArticle's title validation and article.CreateArticle's title validation to
|
||||||
|
enforce the identical rule, counted the identical way
|
||||||
|
So that a title the request layer accepts can never be rejected by the domain as a
|
||||||
|
surprise 500, and a domain title rejection that does happen is always reported as a
|
||||||
|
400 with a client-facing apperr message instead of a raw 500
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given internal/domain/article defines the sentinel article.ErrInvalidTitle
|
||||||
|
And CreateArticleUseCase.CreateArticle rejects a title whose length, counted with
|
||||||
|
utf8.RuneCountInString (runes, not bytes), is below 3 or above 200, returning
|
||||||
|
article.ErrInvalidTitle and storing nothing
|
||||||
|
And api/handlers/article/articleHandler.go's PostArticle.Title carries
|
||||||
|
validate:"required,min=3,max=200", and go-playground/validator's min/max on a
|
||||||
|
string already count runes with utf8.RuneCountInString, so the request tag and the
|
||||||
|
domain check now agree on both the bound (3-200) and the unit (runes)
|
||||||
|
And api/pkg/apperr defines ArticleTitleErrorMessage as "Article title must be
|
||||||
|
between 3 and 200 characters.", a client-facing string distinct from both the raw
|
||||||
|
validator error text and "Failed to create article"
|
||||||
|
And Handler.PostArticle maps errors.Is(err, article.ErrInvalidTitle) from
|
||||||
|
CreateArticleUseCase.CreateArticle to a 400 response with body
|
||||||
|
apperr.ArticleTitleErrorMessage; any other error from CreateArticle still answers
|
||||||
|
500 with body "Failed to create article"
|
||||||
|
And POST /v1/article/ sits behind the jwtauth Verifier and Authenticator group and
|
||||||
|
requires a valid jwt cookie
|
||||||
|
And UpdateArticleUseCase exposes only UpdateBody, AddImage and UpdateIsOnline; no
|
||||||
|
route or use case updates an article's title after creation, so this feature only
|
||||||
|
concerns article creation
|
||||||
|
|
||||||
|
Scenario Outline: CreateArticle now measures title length in runes, not bytes
|
||||||
|
Given an empty article repository fake
|
||||||
|
When CreateArticle is called with a title made of <rune_count> repetitions of the
|
||||||
|
4-byte rune "😀" and a body "Some body text"
|
||||||
|
Then the result is <outcome>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| rune_count | outcome |
|
||||||
|
| 2 | ErrInvalidTitle, nothing stored |
|
||||||
|
| 3 | created |
|
||||||
|
| 200 | created |
|
||||||
|
| 201 | ErrInvalidTitle, nothing stored |
|
||||||
|
|
||||||
|
Scenario Outline: POST /v1/article/ enforces the same 3-200 rune boundary over HTTP
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
When the client sends POST /v1/article/ with that cookie, a title of <length> ASCII
|
||||||
|
characters and a body "Some body text"
|
||||||
|
Then the response status is <status>
|
||||||
|
And <storage_effect>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| length | status | storage_effect |
|
||||||
|
| 2 | 400 | not created |
|
||||||
|
| 3 | 201 | created, is_online false |
|
||||||
|
| 200 | 201 | created, is_online false |
|
||||||
|
| 201 | 400 | not created |
|
||||||
|
|
||||||
|
Scenario: A 200-rune multibyte title is accepted end to end over HTTP
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
When the client sends POST /v1/article/ with that cookie, a title made of 200
|
||||||
|
repetitions of the 4-byte rune "😀" (200 runes, 800 bytes) and a body
|
||||||
|
"Some body text"
|
||||||
|
Then the response status is 201
|
||||||
|
And the response body is the created article's JSON with that title
|
||||||
|
|
||||||
|
Scenario: The handler maps a domain title rejection to 400, not 500
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository fake is configured so its next Create call returns
|
||||||
|
article.ErrInvalidTitle directly, standing in for any layer beneath the use
|
||||||
|
case's own check rejecting the title with that same sentinel
|
||||||
|
When the client sends POST /v1/article/ with that cookie, title "Valid Title" and
|
||||||
|
body "Some body text" (both already satisfying PostArticle's own validate tags)
|
||||||
|
Then the response status is 400
|
||||||
|
And the response body is the text apperr.ArticleTitleErrorMessage
|
||||||
|
And the response body is not "Failed to create article"
|
||||||
|
|
||||||
|
Scenario: A non-title repository failure during creation still answers 500
|
||||||
|
Given a valid jwt cookie for a user with id 1
|
||||||
|
And the article repository fake is configured to fail on its next Create call with
|
||||||
|
a generic repository error, not article.ErrInvalidTitle
|
||||||
|
When the client sends POST /v1/article/ with that cookie, title "Valid Title" and
|
||||||
|
body "Some body text"
|
||||||
|
Then the response status is 500
|
||||||
|
And the response body is the text "Failed to create article"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# QA procedure: article_title_rules
|
||||||
|
|
||||||
|
Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_test.go` /
|
||||||
|
`not_found_errors_test.go` / the hasher-domain-interface tests).
|
||||||
|
|
||||||
|
## Fakes needed
|
||||||
|
1. Extend `mockArticleRepo` (`test/article_test.go`) / its shared `failSwitch`
|
||||||
|
(`test/fakes_common.go`) so a configured failure can return a specific error, not
|
||||||
|
only the generic `errRepositoryFailure`. Today `failSwitch.trigger` always returns
|
||||||
|
the same sentinel; add a way to say "the next call to `Create` returns
|
||||||
|
`article.ErrInvalidTitle`" specifically, distinct from "the next call to `Create`
|
||||||
|
returns a generic repository error" (e.g. a `failNextWith(method string, err error)`
|
||||||
|
on `failSwitch`, keeping `errRepositoryFailure` as the default so existing
|
||||||
|
generic-failure tests don't change).
|
||||||
|
2. Add a `createCalled` flag (or equivalent) to `mockArticleRepo` if not already
|
||||||
|
present, so a test can assert `Create` was never reached when request-layer
|
||||||
|
validation rejects a title first.
|
||||||
|
|
||||||
|
## Use-case-level checks (fake repo, no HTTP)
|
||||||
|
3. `useCases.NewCreateArticleUseCase(repo)`, `CreateArticle(...)` with an empty fake
|
||||||
|
repo:
|
||||||
|
- Title `strings.Repeat("😀", 2)` (2 runes, 8 bytes) -> `errors.Is(err,
|
||||||
|
article.ErrInvalidTitle)`, nothing stored.
|
||||||
|
- Title `strings.Repeat("😀", 3)` (3 runes, 12 bytes) -> no error, created,
|
||||||
|
`is_online` false, one article stored.
|
||||||
|
- Title `strings.Repeat("😀", 200)` (200 runes, 800 bytes) -> no error, created.
|
||||||
|
Proves the check counts runes (`utf8.RuneCountInString`), not `len()` bytes.
|
||||||
|
- Title `strings.Repeat("😀", 201)` (201 runes, 804 bytes) -> `errors.Is(err,
|
||||||
|
article.ErrInvalidTitle)`, nothing stored.
|
||||||
|
- Keep the existing ASCII boundary cases in `TestCreateArticleTitleLengthLimit`
|
||||||
|
(0/2/3/200/201) passing unchanged, as a regression check that byte length and
|
||||||
|
rune length coincide for ASCII.
|
||||||
|
|
||||||
|
## HTTP checks (real CreateArticleUseCase over the fake repo, mirroring api/router.go)
|
||||||
|
4. Call `validator.InitValidator()`.
|
||||||
|
5. Build `useCases.NewCreateArticleUseCase(fakeRepo)` plus `GetArticleUseCase`,
|
||||||
|
`ListArticlesUseCase`, `DeleteArticleUseCase` (real, over the same fake repo). Build
|
||||||
|
`article.NewHandler(create, get, list, delete)`. Mount its routes inside a group
|
||||||
|
wrapped by `jwtauth.Verifier(token)` + `jwtauth.Authenticator(token)` on a fresh
|
||||||
|
`chi.NewRouter()`, with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`, as
|
||||||
|
in `api/router.go`.
|
||||||
|
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 each `httptest.NewRequest`).
|
||||||
|
7. `POST /v1/article/` with a 2-ASCII-character title (`"ab"`) and a valid body ->
|
||||||
|
status `400`, `fakeRepo.createCalled` is false. Assert the body is neither
|
||||||
|
`apperr.ArticleTitleErrorMessage` nor `"Failed to create article"`, to keep the two
|
||||||
|
400 sources (request-layer vs. domain-layer) distinguishable in the test.
|
||||||
|
8. `POST /v1/article/` with a 3-ASCII-character title -> status `201`, response body
|
||||||
|
decodes to the created article JSON, `is_online` false.
|
||||||
|
9. `POST /v1/article/` with a 200-ASCII-character title (`strings.Repeat("a", 200)`) ->
|
||||||
|
status `201`, created.
|
||||||
|
10. `POST /v1/article/` with a 201-ASCII-character title -> status `400`, `Create`
|
||||||
|
never called.
|
||||||
|
11. `POST /v1/article/` with a title of 200 repetitions of "😀" (200 runes, 800 bytes)
|
||||||
|
and a valid body -> status `201`, created, response JSON's `title` equals the sent
|
||||||
|
title.
|
||||||
|
12. Configure the fake repo (per step 1) so its next `Create` call returns
|
||||||
|
`article.ErrInvalidTitle` directly. `POST /v1/article/` with title "Valid Title"
|
||||||
|
and a valid body (both well inside 3-200 runes, so `validator.Validate.Struct`
|
||||||
|
passes and `CreateArticleUseCase`'s own check would also pass) -> status `400`,
|
||||||
|
body `apperr.ArticleTitleErrorMessage + "\n"`. This scenario is deliberately
|
||||||
|
artificial: with the rule aligned, no real HTTP input can make
|
||||||
|
`CreateArticleUseCase.CreateArticle`'s own check return `article.ErrInvalidTitle`
|
||||||
|
once `validator.Validate.Struct` has already accepted the same title, so the only
|
||||||
|
way to exercise `Handler.PostArticle`'s mapping branch today is to have the fake
|
||||||
|
repository hand back that sentinel directly, standing in for a future or
|
||||||
|
repository-level source of the same error.
|
||||||
|
13. Configure the fake repo to fail on its next `Create` with the generic
|
||||||
|
`errRepositoryFailure` (existing `failNext("Create")`). `POST /v1/article/` with
|
||||||
|
title "Valid Title" and a valid body -> status `500`, body `"Failed to create
|
||||||
|
article\n"` -- regression proof a non-title failure still answers 500.
|
||||||
|
14. `POST /v1/article/` with no jwt cookie -> status `401` (regression: the route is
|
||||||
|
still gated).
|
||||||
|
|
||||||
|
## Manual verification (not exercised by the Go test)
|
||||||
|
- None. This slice has no SPA-visible behaviour and no SMTP involvement.
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
domain "RenewCMS/internal/domain/image"
|
domain "RenewCMS/internal/domain/image"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -12,6 +13,19 @@ var (
|
|||||||
ErrArticleOffline = errors.New("article is offline")
|
ErrArticleOffline = errors.New("article is offline")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinTitleRunes = 3
|
||||||
|
MaxTitleRunes = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateTitle(title string) error {
|
||||||
|
runeCount := utf8.RuneCountInString(title)
|
||||||
|
if runeCount < MinTitleRunes || runeCount > MaxTitleRunes {
|
||||||
|
return ErrInvalidTitle
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Article struct {
|
type Article struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
|
func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) {
|
||||||
if len(createArticle.Title) < 3 || len(createArticle.Title) > 200 {
|
if err := article.ValidateTitle(createArticle.Title); err != nil {
|
||||||
return article.Article{}, article.ErrInvalidTitle
|
return article.Article{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return g.articleRepository.Create(article.Article{
|
return g.articleRepository.Create(article.Article{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type mockArticleRepo struct {
|
|||||||
images map[uint32]*imageDomain.Image
|
images map[uint32]*imageDomain.Image
|
||||||
nextID uint32
|
nextID uint32
|
||||||
fail failSwitch
|
fail failSwitch
|
||||||
|
createCalled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockArticleRepo() *mockArticleRepo {
|
func newMockArticleRepo() *mockArticleRepo {
|
||||||
@@ -82,6 +83,7 @@ func (m *mockArticleRepo) GetAll() []article.Article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||||
|
m.createCalled = true
|
||||||
if err := m.fail.trigger("Create"); err != nil {
|
if err := m.fail.trigger("Create"); err != nil {
|
||||||
return article.Article{}, err
|
return article.Article{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||||
|
articleHandler := article.NewHandler(
|
||||||
|
useCases.NewCreateArticleUseCase(repo),
|
||||||
|
useCases.NewGetArticleUseCase(repo),
|
||||||
|
useCases.NewListArticlesUseCase(repo),
|
||||||
|
useCases.NewDeleteArticleUseCase(repo),
|
||||||
|
)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/v1", func(r chi.Router) {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(token))
|
||||||
|
r.Use(jwtauth.Authenticator(token))
|
||||||
|
r.Route("/article", func(r chi.Router) {
|
||||||
|
r.Post("/", articleHandler.PostArticle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func postArticleRequest(t *testing.T, token *jwtauth.JWTAuth, title string, body string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(map[string]string{"title": title, "body": body})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal body: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/article/", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
setJwtCookie(t, req, token, 1)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArticleTitleTagMatchesDomainBounds(t *testing.T) {
|
||||||
|
field, ok := reflect.TypeOf(article.PostArticle{}).FieldByName("Title")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected PostArticle to have a Title field")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := fmt.Sprintf("required,min=%d,max=%d", domainArticle.MinTitleRunes, domainArticle.MaxTitleRunes)
|
||||||
|
got := field.Tag.Get("validate")
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("expected validate tag %q, got %q", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArticleTitleRunesInUseCase(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
runeCount int
|
||||||
|
created bool
|
||||||
|
}{
|
||||||
|
{2, false},
|
||||||
|
{3, true},
|
||||||
|
{200, true},
|
||||||
|
{201, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
uc := useCases.NewCreateArticleUseCase(repo)
|
||||||
|
|
||||||
|
title := strings.Repeat("\U0001F600", tc.runeCount)
|
||||||
|
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
|
||||||
|
|
||||||
|
if tc.created {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !repo.createCalled {
|
||||||
|
t.Errorf("expected the repository's Create to have been called")
|
||||||
|
}
|
||||||
|
if len(repo.articles) != 1 {
|
||||||
|
t.Errorf("expected 1 article stored, got %d", len(repo.articles))
|
||||||
|
}
|
||||||
|
if result.Title != title {
|
||||||
|
t.Errorf("expected title %q, got %q", title, result.Title)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, domainArticle.ErrInvalidTitle) {
|
||||||
|
t.Errorf("expected ErrInvalidTitle, got %v", err)
|
||||||
|
}
|
||||||
|
if repo.createCalled {
|
||||||
|
t.Errorf("expected the repository's Create not to have been called")
|
||||||
|
}
|
||||||
|
if len(repo.articles) != 0 {
|
||||||
|
t.Errorf("expected nothing stored, got %d article(s)", len(repo.articles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArticleTitleRunesOverHttp(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
length int
|
||||||
|
status int
|
||||||
|
created bool
|
||||||
|
}{
|
||||||
|
{2, http.StatusBadRequest, false},
|
||||||
|
{3, http.StatusCreated, true},
|
||||||
|
{200, http.StatusCreated, true},
|
||||||
|
{201, http.StatusBadRequest, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
router := newArticleTitleRulesRouter(repo, token)
|
||||||
|
|
||||||
|
title := strings.Repeat("a", tc.length)
|
||||||
|
req := postArticleRequest(t, token, title, "Some body text")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("expected status %d, got %d, body %q", tc.status, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.created {
|
||||||
|
if repo.createCalled {
|
||||||
|
t.Errorf("expected Create not to have been called")
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), apperr.ArticleTitleErrorMessage) {
|
||||||
|
t.Errorf("expected a request-layer validation error, got the domain error message: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "Failed to create article") {
|
||||||
|
t.Errorf("expected a request-layer validation error, got the generic failure message: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.created {
|
||||||
|
var got domainArticle.Article
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got.IsOnline {
|
||||||
|
t.Errorf("expected is_online false, got true")
|
||||||
|
}
|
||||||
|
if len(repo.articles) != 1 {
|
||||||
|
t.Errorf("expected 1 article stored, got %d", len(repo.articles))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(repo.articles) != 0 {
|
||||||
|
t.Errorf("expected nothing stored, got %d article(s)", len(repo.articles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("A 200-rune multibyte title is accepted end to end over HTTP", func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
router := newArticleTitleRulesRouter(repo, token)
|
||||||
|
|
||||||
|
title := strings.Repeat("\U0001F600", 200)
|
||||||
|
req := postArticleRequest(t, token, title, "Some body text")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got domainArticle.Article
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != title {
|
||||||
|
t.Errorf("expected title %q, got %q", title, got.Title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The handler maps a domain title rejection to 400, not 500", func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
repo.fail.failNextWith("Create", domainArticle.ErrInvalidTitle)
|
||||||
|
router := newArticleTitleRulesRouter(repo, token)
|
||||||
|
|
||||||
|
req := postArticleRequest(t, token, "Valid Title", "Some body text")
|
||||||
|
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.ArticleTitleErrorMessage+"\n" {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "Failed to create article") {
|
||||||
|
t.Errorf("did not expect the generic failure message, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A non-title repository failure during creation still answers 500", func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
repo.fail.failNext("Create")
|
||||||
|
router := newArticleTitleRulesRouter(repo, token)
|
||||||
|
|
||||||
|
req := postArticleRequest(t, token, "Valid Title", "Some body text")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected status 500, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec.Body.String() != "Failed to create article\n" {
|
||||||
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST /v1/article/ without a jwt cookie is rejected", func(t *testing.T) {
|
||||||
|
repo := newMockArticleRepo()
|
||||||
|
router := newArticleTitleRulesRouter(repo, token)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]string{"title": "Valid Title", "body": "Some body text"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal body: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/article/", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected status 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if repo.createCalled {
|
||||||
|
t.Errorf("expected Create not to have been called")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,15 +6,27 @@ var errRepositoryFailure = errors.New("repository failure")
|
|||||||
|
|
||||||
type failSwitch struct {
|
type failSwitch struct {
|
||||||
method string
|
method string
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *failSwitch) failNext(method string) {
|
func (f *failSwitch) failNext(method string) {
|
||||||
f.method = method
|
f.method = method
|
||||||
|
f.err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failSwitch) failNextWith(method string, err error) {
|
||||||
|
f.method = method
|
||||||
|
f.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *failSwitch) trigger(method string) error {
|
func (f *failSwitch) trigger(method string) error {
|
||||||
if f.method == method {
|
if f.method == method {
|
||||||
f.method = ""
|
f.method = ""
|
||||||
|
if f.err != nil {
|
||||||
|
err := f.err
|
||||||
|
f.err = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
return errRepositoryFailure
|
return errRepositoryFailure
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in new issue
Block a user