feat: articles format option with markdown new default
This commit is contained in:
1 parent
68401d66e0
commit
7e9bd9ba30
61 files changed
+2269
-243
No files matched your search
@@ -0,0 +1,361 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/markdown"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type fakeBodyRenderer struct {
|
||||
fail bool
|
||||
calls int
|
||||
}
|
||||
|
||||
var _ domainArticle.BodyRenderer = &fakeBodyRenderer{}
|
||||
|
||||
func (f *fakeBodyRenderer) Render(html string, format domainArticle.BodyFormat) (string, error) {
|
||||
f.calls++
|
||||
if f.fail {
|
||||
return "", errors.New("render failed")
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
const (
|
||||
bodyFormatArticleOneHTML = "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
|
||||
bodyFormatArticleOneMD = "## Plan\n\nReplace the **old** deck."
|
||||
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
|
||||
)
|
||||
|
||||
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
r.Get("/{id}", articleHandler.GetArticle)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Authenticator(token))
|
||||
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
|
||||
r.Post("/", articleHandler.PostArticle)
|
||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||
})
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func seedBodyFormatRepo() *mockArticleRepo {
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update", Body: bodyFormatArticleOneHTML, IsOnline: true}
|
||||
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Draft: Kitchen Remodel", Body: "<p>Draft</p>", IsOnline: false}
|
||||
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "<p>Second</p>", IsOnline: true}
|
||||
repo.nextID = 3
|
||||
return repo
|
||||
}
|
||||
|
||||
type bodyFormatResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
BodyFormat string `json:"body_format"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
}
|
||||
|
||||
func serveBodyFormat(router http.Handler, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeBodyFormatArticle(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) bodyFormatResponse {
|
||||
t.Helper()
|
||||
if rec.Code != expectedStatus {
|
||||
t.Fatalf("expected status %d, got %d, body %q", expectedStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
var got bodyFormatResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func assertBodyFormat(t *testing.T, got bodyFormatResponse, format, expectedBody string) {
|
||||
t.Helper()
|
||||
if got.BodyFormat != format {
|
||||
t.Errorf("expected body_format %q, got %q", format, got.BodyFormat)
|
||||
}
|
||||
if format == "json" {
|
||||
assertSameJSON(t, string(got.Body), expectedBody)
|
||||
return
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(got.Body, &text); err != nil {
|
||||
t.Fatalf("body is not a JSON string: %s", got.Body)
|
||||
}
|
||||
if text != expectedBody {
|
||||
t.Errorf("expected body %q, got %q", expectedBody, text)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSameJSON(t *testing.T, actual, expected string) {
|
||||
t.Helper()
|
||||
var actualValue, expectedValue any
|
||||
if err := json.Unmarshal([]byte(actual), &actualValue); err != nil {
|
||||
t.Fatalf("actual is not JSON: %s", actual)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(expected), &expectedValue); err != nil {
|
||||
t.Fatalf("expected is not JSON: %s", expected)
|
||||
}
|
||||
actualBytes, _ := json.Marshal(actualValue)
|
||||
expectedBytes, _ := json.Marshal(expectedValue)
|
||||
if string(actualBytes) != string(expectedBytes) {
|
||||
t.Errorf("expected %s, got %s", expectedBytes, actualBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleBodyFormatHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
|
||||
return newBodyFormatRouter(repo, users, renderer, token)
|
||||
}
|
||||
realRouter := func() (*chi.Mux, *mockArticleRepo) {
|
||||
repo := seedBodyFormatRepo()
|
||||
return newRouter(repo, newPublicArticleReadUserRepo(true), markdown.NewConverter()), repo
|
||||
}
|
||||
verifiedCookie := mintPublicArticleReadCookie(t, token, 1)
|
||||
|
||||
t.Run("Single read negotiates the body format", func(t *testing.T) {
|
||||
examples := []struct {
|
||||
query, format, body string
|
||||
}{
|
||||
{"", "markdown", bodyFormatArticleOneMD},
|
||||
{"?format=", "markdown", bodyFormatArticleOneMD},
|
||||
{"?format=markdown", "markdown", bodyFormatArticleOneMD},
|
||||
{"?format=MarkDown", "markdown", bodyFormatArticleOneMD},
|
||||
{"?format=html", "html", bodyFormatArticleOneHTML},
|
||||
{"?format=HTML", "html", bodyFormatArticleOneHTML},
|
||||
{"?format=json", "json", bodyFormatArticleOneJSON},
|
||||
{"?format=JSON", "json", bodyFormatArticleOneJSON},
|
||||
}
|
||||
for _, example := range examples {
|
||||
router, _ := realRouter()
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1"+example.query, "", nil)
|
||||
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
|
||||
assertBodyFormat(t, got, example.format, example.body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("List read negotiates every element", func(t *testing.T) {
|
||||
router, _ := realRouter()
|
||||
expectations := []struct {
|
||||
query, format, secondBody string
|
||||
}{
|
||||
{"", "markdown", "Second"},
|
||||
{"?format=html", "html", "<p>Second</p>"},
|
||||
{"?format=json", "json", `[{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}]`},
|
||||
}
|
||||
for _, expectation := range expectations {
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
|
||||
var items []bodyFormatResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("failed to decode list: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 online articles, got %d", len(items))
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.BodyFormat != expectation.format {
|
||||
t.Errorf("expected body_format %q, got %q", expectation.format, item.BodyFormat)
|
||||
}
|
||||
if item.ID == 3 {
|
||||
assertBodyFormat(t, item, expectation.format, expectation.secondBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unsupported format is rejected", func(t *testing.T) {
|
||||
for _, path := range []string{"/v1/article/1?format=pdf", "/v1/article/?format=xml", "/v1/article/9999?format=pdf", "/v1/article/1?format=md"} {
|
||||
renderer := &fakeBodyRenderer{}
|
||||
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), renderer)
|
||||
rec := serveBodyFormat(router, http.MethodGet, path, "", nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: expected status 400, got %d", path, rec.Code)
|
||||
}
|
||||
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
|
||||
t.Errorf("%s: unexpected body %q", path, rec.Body.String())
|
||||
}
|
||||
if renderer.calls != 0 {
|
||||
t.Errorf("%s: renderer must not be called", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An invalid id is reported before an invalid format", func(t *testing.T) {
|
||||
router, _ := realRouter()
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/abc?format=pdf", "", nil)
|
||||
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
|
||||
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The first repeated format parameter wins", func(t *testing.T) {
|
||||
router, _ := realRouter()
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1?format=html&format=json", "", nil)
|
||||
got := decodeBodyFormatArticle(t, rec, http.StatusOK)
|
||||
assertBodyFormat(t, got, "html", bodyFormatArticleOneHTML)
|
||||
})
|
||||
|
||||
t.Run("Offline articles stay hidden whatever the format", func(t *testing.T) {
|
||||
unverified := newRouter(seedBodyFormatRepo(), newMockUserRepo(), markdown.NewConverter())
|
||||
rec := serveBodyFormat(unverified, http.MethodGet, "/v1/article/2?format=json", "", nil)
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
|
||||
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Authenticated routes negotiate the response body", func(t *testing.T) {
|
||||
paragraph := func(text string) string {
|
||||
return `[{"type":"paragraph","children":[{"type":"text","text":"` + text + `","marks":[]}]}]`
|
||||
}
|
||||
type expectedBodies map[string]string
|
||||
bodiesA := expectedBodies{"markdown": "A", "html": "<p>A</p>", "json": paragraph("A")}
|
||||
bodiesB := expectedBodies{"markdown": "B", "html": "<p>B</p>", "json": paragraph("B")}
|
||||
bodiesOne := expectedBodies{"markdown": bodyFormatArticleOneMD, "html": bodyFormatArticleOneHTML, "json": bodyFormatArticleOneJSON}
|
||||
examples := []struct {
|
||||
method, path, body string
|
||||
status int
|
||||
format string
|
||||
bodies expectedBodies
|
||||
}{
|
||||
{http.MethodPost, "/v1/article/", `{"title":"Deck","body":"<p>A</p>"}`, 201, "markdown", bodiesA},
|
||||
{http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"<p>A</p>"}`, 201, "html", bodiesA},
|
||||
{http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"<p>A</p>"}`, 201, "json", bodiesA},
|
||||
{http.MethodPatch, "/v1/article/1", `{"body":"<p>B</p>"}`, 200, "markdown", bodiesB},
|
||||
{http.MethodPatch, "/v1/article/1?format=json", `{"body":"<p>B</p>"}`, 200, "json", bodiesB},
|
||||
{http.MethodPatch, "/v1/article/1/publish", "", 200, "markdown", bodiesOne},
|
||||
{http.MethodPatch, "/v1/article/1/unpublish?format=html", "", 200, "html", bodiesOne},
|
||||
}
|
||||
for _, example := range examples {
|
||||
router, repo := realRouter()
|
||||
rec := serveBodyFormat(router, example.method, example.path, example.body, verifiedCookie)
|
||||
got := decodeBodyFormatArticle(t, rec, example.status)
|
||||
assertBodyFormat(t, got, example.format, example.bodies[example.format])
|
||||
stored := repo.articles[got.ID].Body
|
||||
if example.bodies["html"] != stored {
|
||||
t.Errorf("%s %s: stored body must stay HTML %q, got %q", example.method, example.path, example.bodies["html"], stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unsupported format on a write route changes nothing", func(t *testing.T) {
|
||||
requests := []struct {
|
||||
method, path, body string
|
||||
}{
|
||||
{http.MethodPatch, "/v1/article/1?format=pdf", `{"body":"<p>B</p>"}`},
|
||||
{http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"<p>A</p>"}`},
|
||||
{http.MethodPatch, "/v1/article/1/publish?format=pdf", ""},
|
||||
{http.MethodPatch, "/v1/article/1/unpublish?format=pdf", ""},
|
||||
}
|
||||
for _, request := range requests {
|
||||
repo := seedBodyFormatRepo()
|
||||
renderer := &fakeBodyRenderer{}
|
||||
router := newRouter(repo, newPublicArticleReadUserRepo(true), renderer)
|
||||
rec := serveBodyFormat(router, request.method, request.path, request.body, verifiedCookie)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s %s: expected 400, got %d", request.method, request.path, rec.Code)
|
||||
}
|
||||
if rec.Body.String() != apperr.ArticleBodyFormatErrorMessage+"\n" {
|
||||
t.Errorf("%s %s: unexpected body %q", request.method, request.path, rec.Body.String())
|
||||
}
|
||||
if renderer.calls != 0 {
|
||||
t.Errorf("%s %s: renderer must not be called", request.method, request.path)
|
||||
}
|
||||
if repo.articles[1].Body != bodyFormatArticleOneHTML {
|
||||
t.Errorf("%s %s: stored body changed to %q", request.method, request.path, repo.articles[1].Body)
|
||||
}
|
||||
if !repo.articles[1].IsOnline {
|
||||
t.Errorf("%s %s: is_online changed", request.method, request.path)
|
||||
}
|
||||
if repo.createCalled {
|
||||
t.Errorf("%s %s: no article may be created", request.method, request.path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The authenticated list includes offline articles in the negotiated format", func(t *testing.T) {
|
||||
router, _ := realRouter()
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
|
||||
var items []bodyFormatResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("failed to decode list: %v", err)
|
||||
}
|
||||
seen := map[uint32]bool{}
|
||||
for _, item := range items {
|
||||
seen[item.ID] = true
|
||||
if item.BodyFormat != "json" {
|
||||
t.Errorf("expected json, got %q", item.BodyFormat)
|
||||
}
|
||||
}
|
||||
if !seen[1] || !seen[2] {
|
||||
t.Errorf("expected articles 1 and 2, got %v", seen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A converter failure answers 500", func(t *testing.T) {
|
||||
router := newRouter(seedBodyFormatRepo(), newMockUserRepo(), &fakeBodyRenderer{fail: true})
|
||||
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/1", "", nil)
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
|
||||
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseBodyFormat(t *testing.T) {
|
||||
valid := map[string]domainArticle.BodyFormat{
|
||||
"": domainArticle.BodyFormatMarkdown, "markdown": domainArticle.BodyFormatMarkdown, "MARKDOWN": domainArticle.BodyFormatMarkdown,
|
||||
"html": domainArticle.BodyFormatHTML, "Html": domainArticle.BodyFormatHTML, "json": domainArticle.BodyFormatJSON,
|
||||
}
|
||||
for input, expected := range valid {
|
||||
got, err := domainArticle.ParseBodyFormat(input)
|
||||
if err != nil || got != expected {
|
||||
t.Errorf("ParseBodyFormat(%q) = %q, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"pdf", "md", " html"} {
|
||||
if _, err := domainArticle.ParseBodyFormat(input); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) {
|
||||
t.Errorf("ParseBodyFormat(%q) should be unsupported, got %v", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user