362 lines
14 KiB
Go
362 lines
14 KiB
Go
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, discardRecorder, plainSites(), plainAuthors()),
|
|
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
|
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
|
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
|
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
|
|
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(articleItemsJSON(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(articleItemsJSON(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)
|
|
}
|
|
}
|
|
}
|