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 = "

Plan

Replace the old deck.

" 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()), 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: "

Draft

", IsOnline: false} repo.articles[3] = domainArticle.Article{ID: 3, Title: "Second article", Body: "

Second

", 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", "

Second

"}, {"?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": "

A

", "json": paragraph("A")} bodiesB := expectedBodies{"markdown": "B", "html": "

B

", "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":"

A

"}`, 201, "markdown", bodiesA}, {http.MethodPost, "/v1/article/?format=html", `{"title":"Deck","body":"

A

"}`, 201, "html", bodiesA}, {http.MethodPost, "/v1/article/?format=json", `{"title":"Deck","body":"

A

"}`, 201, "json", bodiesA}, {http.MethodPatch, "/v1/article/1", `{"body":"

B

"}`, 200, "markdown", bodiesB}, {http.MethodPatch, "/v1/article/1?format=json", `{"body":"

B

"}`, 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":"

B

"}`}, {http.MethodPost, "/v1/article/?format=pdf", `{"title":"Deck","body":"

A

"}`}, {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) } } }