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
+456
-7
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user